summaryrefslogtreecommitdiffstats
path: root/toolkit/mozapps/extensions
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/mozapps/extensions')
-rw-r--r--toolkit/mozapps/extensions/.eslintrc.js36
-rw-r--r--toolkit/mozapps/extensions/AbuseReporter.sys.mjs676
-rw-r--r--toolkit/mozapps/extensions/AddonContentPolicy.cpp484
-rw-r--r--toolkit/mozapps/extensions/AddonContentPolicy.h21
-rw-r--r--toolkit/mozapps/extensions/AddonManager.sys.mjs5233
-rw-r--r--toolkit/mozapps/extensions/AddonManagerStartup-inlines.h229
-rw-r--r--toolkit/mozapps/extensions/AddonManagerStartup.cpp882
-rw-r--r--toolkit/mozapps/extensions/AddonManagerStartup.h59
-rw-r--r--toolkit/mozapps/extensions/AddonManagerWebAPI.cpp170
-rw-r--r--toolkit/mozapps/extensions/AddonManagerWebAPI.h32
-rw-r--r--toolkit/mozapps/extensions/Blocklist.sys.mjs1491
-rw-r--r--toolkit/mozapps/extensions/LightweightThemeManager.sys.mjs32
-rw-r--r--toolkit/mozapps/extensions/amContentHandler.sys.mjs103
-rw-r--r--toolkit/mozapps/extensions/amIAddonManagerStartup.idl82
-rw-r--r--toolkit/mozapps/extensions/amIWebInstallPrompt.idl32
-rw-r--r--toolkit/mozapps/extensions/amInstallTrigger.sys.mjs271
-rw-r--r--toolkit/mozapps/extensions/amManager.sys.mjs359
-rw-r--r--toolkit/mozapps/extensions/amWebAPI.sys.mjs289
-rw-r--r--toolkit/mozapps/extensions/components.conf45
-rw-r--r--toolkit/mozapps/extensions/content/OpenH264-license.txt59
-rw-r--r--toolkit/mozapps/extensions/content/aboutaddons.css768
-rw-r--r--toolkit/mozapps/extensions/content/aboutaddons.html780
-rw-r--r--toolkit/mozapps/extensions/content/aboutaddons.js4232
-rw-r--r--toolkit/mozapps/extensions/content/aboutaddonsCommon.js275
-rw-r--r--toolkit/mozapps/extensions/content/abuse-report-frame.html202
-rw-r--r--toolkit/mozapps/extensions/content/abuse-report-panel.css185
-rw-r--r--toolkit/mozapps/extensions/content/abuse-report-panel.js886
-rw-r--r--toolkit/mozapps/extensions/content/abuse-reports.js317
-rw-r--r--toolkit/mozapps/extensions/content/drag-drop-addon-installer.js81
-rw-r--r--toolkit/mozapps/extensions/content/rating-star.css41
-rw-r--r--toolkit/mozapps/extensions/content/shortcuts.css138
-rw-r--r--toolkit/mozapps/extensions/content/shortcuts.js659
-rw-r--r--toolkit/mozapps/extensions/content/view-controller.js201
-rw-r--r--toolkit/mozapps/extensions/default-theme/icon.svg7
-rw-r--r--toolkit/mozapps/extensions/default-theme/manifest.json95
-rw-r--r--toolkit/mozapps/extensions/default-theme/preview.svg46
-rw-r--r--toolkit/mozapps/extensions/docs/AMRemoteSettings-JSONSchema.json56
-rw-r--r--toolkit/mozapps/extensions/docs/AMRemoteSettings-UISchema.json10
-rw-r--r--toolkit/mozapps/extensions/docs/AMRemoteSettings-overview.rst173
-rw-r--r--toolkit/mozapps/extensions/docs/AMRemoteSettings.rst5
-rw-r--r--toolkit/mozapps/extensions/docs/AddonManager.rst4
-rw-r--r--toolkit/mozapps/extensions/docs/SystemAddons.rst275
-rw-r--r--toolkit/mozapps/extensions/docs/index.rst21
-rw-r--r--toolkit/mozapps/extensions/extensions.manifest9
-rw-r--r--toolkit/mozapps/extensions/gen_built_in_addons.py99
-rw-r--r--toolkit/mozapps/extensions/internal/AddonRepository.sys.mjs1139
-rw-r--r--toolkit/mozapps/extensions/internal/AddonSettings.sys.mjs138
-rw-r--r--toolkit/mozapps/extensions/internal/AddonTestUtils.sys.mjs1815
-rw-r--r--toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs621
-rw-r--r--toolkit/mozapps/extensions/internal/GMPProvider.sys.mjs910
-rw-r--r--toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs601
-rw-r--r--toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs663
-rw-r--r--toolkit/mozapps/extensions/internal/XPIDatabase.jsm3790
-rw-r--r--toolkit/mozapps/extensions/internal/XPIInstall.jsm4845
-rw-r--r--toolkit/mozapps/extensions/internal/XPIProvider.jsm3378
-rw-r--r--toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs41
-rw-r--r--toolkit/mozapps/extensions/internal/moz.build28
-rw-r--r--toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs72
-rw-r--r--toolkit/mozapps/extensions/jar.mn26
-rw-r--r--toolkit/mozapps/extensions/metrics.yaml257
-rw-r--r--toolkit/mozapps/extensions/moz.build101
-rw-r--r--toolkit/mozapps/extensions/test/browser/.eslintrc.js14
-rw-r--r--toolkit/mozapps/extensions/test/browser/addon_prefs.xhtml6
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/manifest.mf8
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/mozilla.rsabin0 -> 4210 bytes
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/mozilla.sf5
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/manifest.json12
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/manifest.mf8
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/mozilla.rsabin0 -> 4210 bytes
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/mozilla.sf5
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/manifest.json12
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/manifest.mf8
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/mozilla.rsabin0 -> 4218 bytes
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/mozilla.sf5
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/manifest.json13
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/manifest.mf8
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/mozilla.rsabin0 -> 4213 bytes
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/mozilla.sf5
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_installssl/manifest.json12
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_theme/manifest.json22
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/manifest.mf12
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/mozilla.rsabin0 -> 4197 bytes
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/mozilla.sf4
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/options_signed/manifest.json11
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/options_signed/options.html9
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser.ini127
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_about_debugging_link.js129
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_addon_list_reordering.js197
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_bug572561.js96
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_checkAddonCompatibility.js36
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_colorwaybuiltins_migration.js262
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_dragdrop.js270
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_file_xpi_no_process_switch.js122
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_globalwarnings.js168
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_gmpProvider.js384
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_history_navigation.js617
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_abuse_report.js1029
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_abuse_report_dialog.js178
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_detail_permissions.js826
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js1198
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js660
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_discover_view_clientid.js219
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_discover_view_prefs.js83
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_list_view.js1038
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_list_view_recommendations.js293
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_message_bar.js185
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_options_ui.js650
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_options_ui_in_tab.js136
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_pending_updates.js311
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_recent_updates.js180
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_recommendations.js165
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_scroll_restoration.js223
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_sitepermission_addons.js178
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_updates.js743
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_warning_messages.js291
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_installssl.js378
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_installtrigger_install.js350
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts.js322
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts_hidden.js198
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts_remove.js172
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_menu_button_accessibility.js81
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_page_accessibility.js15
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_page_options_install_addon.js128
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_page_options_updates.js124
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_permission_prompt.js178
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_reinstall.js277
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_shortcuts_duplicate_check.js253
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_sidebar_categories.js160
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_sidebar_hidden_categories.js214
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_sidebar_restore_category.js76
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_subframe_install.js234
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_task_next_test.js17
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_updateid.js87
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_updatessl.js389
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_updatessl.json17
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_updatessl.json^headers^1
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_verify_l10n_strings.js62
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi.js125
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi_abuse_report.js370
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi_access.js146
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi_addon_listener.js124
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi_enable.js63
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi_install.js601
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi_install_disabled.js60
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi_theme.js79
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi_uninstall.js72
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webext_icon.js82
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webext_incognito.js593
-rw-r--r--toolkit/mozapps/extensions/test/browser/discovery/api_response.json679
-rw-r--r--toolkit/mozapps/extensions/test/browser/discovery/api_response_empty.json1
-rw-r--r--toolkit/mozapps/extensions/test/browser/discovery/small-1x1.pngbin0 -> 82 bytes
-rw-r--r--toolkit/mozapps/extensions/test/browser/head.js1701
-rw-r--r--toolkit/mozapps/extensions/test/browser/head_abuse_report.js587
-rw-r--r--toolkit/mozapps/extensions/test/browser/head_disco.js125
-rw-r--r--toolkit/mozapps/extensions/test/browser/moz.build31
-rw-r--r--toolkit/mozapps/extensions/test/browser/plugin_test.html7
-rw-r--r--toolkit/mozapps/extensions/test/browser/redirect.sjs5
-rw-r--r--toolkit/mozapps/extensions/test/browser/sandboxed.html11
-rw-r--r--toolkit/mozapps/extensions/test/browser/sandboxed.html^headers^1
-rw-r--r--toolkit/mozapps/extensions/test/browser/webapi_addon_listener.html30
-rw-r--r--toolkit/mozapps/extensions/test/browser/webapi_checkavailable.html13
-rw-r--r--toolkit/mozapps/extensions/test/browser/webapi_checkchromeframe.xhtml6
-rw-r--r--toolkit/mozapps/extensions/test/browser/webapi_checkframed.html7
-rw-r--r--toolkit/mozapps/extensions/test/browser/webapi_checknavigatedwindow.html29
-rw-r--r--toolkit/mozapps/extensions/test/create_xpi.py21
-rw-r--r--toolkit/mozapps/extensions/test/mochitest/chrome.ini2
-rw-r--r--toolkit/mozapps/extensions/test/mochitest/file_empty.html2
-rw-r--r--toolkit/mozapps/extensions/test/mochitest/mochitest.ini6
-rw-r--r--toolkit/mozapps/extensions/test/mochitest/test_blocklist_gfx_initialized.html31
-rw-r--r--toolkit/mozapps/extensions/test/mochitest/test_bug887098.html70
-rw-r--r--toolkit/mozapps/extensions/test/mochitest/test_default_theme.html33
-rw-r--r--toolkit/mozapps/extensions/test/moz.build20
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/.eslintrc.js24
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/bug455906_block.xml18
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/bug455906_empty.xml7
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/bug455906_start.xml30
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/bug455906_warn.xml33
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/corrupt.xpi1
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/corruptfile.xpibin0 -> 633 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/empty.xpibin0 -> 197 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/mlbf-blocked1-unblocked2.binbin0 -> 32 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/pluginInfoURL_block.xml45
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.txt1
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.xml3
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad2.xml3
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_aus_ee.pem15
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_aus_ee.pem.certspec5
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_int.pem18
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_int.pem.certspec4
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/empty.xml5
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/good.xml11
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/missing.xml3
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/unsigned.xpibin0 -> 452 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/langpack_signed.xpibin0 -> 4452 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/langpack_unsigned.xpibin0 -> 413 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/long.xpibin0 -> 4761 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/privileged.xpibin0 -> 4659 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/signed1.xpibin0 -> 4702 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/signed2.xpibin0 -> 4697 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/unsigned.xpibin0 -> 528 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_cache.json134
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_empty.json7
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_fail.json1
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getAddonsByIDs.json116
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_backgroundupdate.json46
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_metadata_filters_1.xml21
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_prefs_1.xml28
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug393285.xml30
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app-extensions.json332
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app-plugins.json332
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app.xml333
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit-extensions.json189
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit-plugins.json189
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit.xml208
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug468528.xml15
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_1.xml17
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_2.xml10
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_empty.xml4
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_outdated_1.xml13
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_outdated_2.xml13
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug655254.json17
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_corrupt.json30
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete.json12
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete_legacy.json18
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer.json12
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer_legacy.json18
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore.json12
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore_legacy.json18
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_staged.json32
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist.json377
-rwxr-xr-xtoolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist_AllOS.json581
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist_OSVersion.json20
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_install_addons.json31
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_install_compat.json27
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_no_update.json7
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/ancient.xml8
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/new.xml8
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/old.xml8
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_pluginBlocklistCtp.xml26
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_pluginBlocklistCtpUndo.xml10
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_softblocked1.xml9
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_trash_directory.worker.js42
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_update.json137
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_update_addons.json14
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_update_compat.json28
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_updatecheck.json269
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/unsigned.xpibin0 -> 463 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/webext-implicit-id.xpibin0 -> 4182 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/head_addons.js1226
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/head_amremotesettings.js31
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/head_cert_handling.js33
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/head_compat.js49
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/head_sideload.js76
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/head_system_addons.js472
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/head_unpack.js3
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/head.js120
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_android_blocklist_dump.js120
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_addonBlockURL.js56
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_appversion.js293
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_clients.js228
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_gfx.js113
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_metadata_filters.js116
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf.js267
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_dump.js156
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_fetch.js231
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_stashes.js219
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_telemetry.js188
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_update.js75
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_osabi.js286
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_prefs.js106
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_regexp_split.js225
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_severities.js504
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_statechange_telemetry.js411
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_targetapp_filter.js392
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_telemetry.js138
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklistchange.js1410
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklistchange_v2.js13
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Device.js73
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_DriverNew.js67
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_DriverNew.js112
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_DriverOld.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_OK.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_GTE_DriverOld.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_GTE_OK.js70
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_No_Comparison.js69
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OK.js69
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OS.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_match.js70
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_mismatch_DriverVersion.js70
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_mismatch_OSVersion.js71
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Vendor.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Version.js190
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_prefs.js124
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_softblocked.js61
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/xpcshell.ini67
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_AbuseReporter.js904
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository.js316
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache.js714
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache_locale.js217
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_langpacks.js135
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_paging.js91
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker.js292
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker_signatures.js201
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_QuarantinedDomains_AMRemoteSettings.js79
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_XPIStates.js133
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_XPIcancel.js70
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_addonStartup.js93
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_addon_manager_telemetry_events.js875
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_amo_stats_telemetry.js102
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_aom_startup.js189
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_bad_json.js41
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_badschema.js237
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_bug587088.js194
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_builtin_location.js149
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_cacheflush.js86
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_childprocess.js25
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_colorways_builtin_theme_upgrades.js582
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_cookies.js102
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_corrupt.js216
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_crash_annotation_quoting.js25
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_db_path.js64
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_delay_update_webextension.js556
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_dependencies.js140
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_dictionary_webextension.js233
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_distribution.js115
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_distribution_langpack.js112
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_embedderDisabled.js124
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_error.js75
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_ext_management.js223
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_filepointer.js327
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_general.js49
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_getInstallSourceFromHost.js47
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_gmpProvider.js457
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_harness.js13
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_hidden.js251
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_install.js1050
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_installOrigins.js535
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_install_cancel.js92
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_install_file_change.js180
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_install_icons.js62
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_installtrigger_deprecation.js346
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_installtrigger_schemes.js75
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_isDebuggable.js21
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_isReady.js71
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_loadManifest_isPrivileged.js229
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_locale.js103
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_moved_extension_metadata.js186
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_no_addons.js79
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_nodisable_hidden.js99
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_onPropertyChanged_appDisabled.js52
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_permissions.js199
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_permissions_prefs.js99
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_pref_properties.js221
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_provider_markSafe.js43
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_provider_shutdown.js96
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_provider_unsafe_access_shutdown.js65
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_provider_unsafe_access_startup.js59
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_proxies.js235
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_recommendations.js712
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_registerchrome.js88
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_registry.js160
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_reinstall_disabled_addon.js213
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_reload.js188
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_safemode.js90
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_schema_change.js157
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_seen.js277
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js131
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_shutdown_barriers.js218
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_shutdown_early.js62
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_sideload_scopes.js188
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_sideloads.js117
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_sideloads_after_rebuild.js149
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_signed_inject.js429
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_signed_install.js337
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_signed_langpack.js67
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_signed_long.js23
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_signed_updatepref.js130
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_signed_verify.js109
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_sitePermsAddonProvider.js967
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_startup.js648
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_startup_enable.js47
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_startup_isPrivileged.js58
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_startup_scan.js125
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_strictcompatibility.js156
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_syncGUID.js116
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_allowed.js54
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_delay_update.js485
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_profile_location.js204
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_repository.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_reset.js533
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_blank.js117
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_checkSizeHash.js181
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_custom.js492
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_empty.js141
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_enterprisepolicy.js77
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_fail.js185
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_installTelemetryInfo.js94
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_newset.js165
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_overlapping.js180
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_uninstall_check.js56
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_upgrades.js165
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_upgrades.js417
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_systemaddomstartupprefs.js55
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_temporary.js765
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_trash_directory.js47
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_types.js117
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_undouninstall.js584
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update.js834
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_updateCancel.js139
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update_addontype.js75
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update_compatmode.js112
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update_ignorecompat.js116
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update_isPrivileged.js181
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update_noSystemAddonUpdate.js42
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update_strictcompat.js216
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update_theme.js121
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update_webextensions.js209
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_updatecheck.js167
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_updatecheck_errors.js52
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_updatecheck_json.js379
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_updateid.js82
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_updateversion.js101
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_upgrade.js199
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_upgrade_incompatible.js73
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_webextension.js676
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_webextension_events.js94
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_webextension_icons.js212
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_webextension_install.js694
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_webextension_install_syntax_error.js42
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_webextension_langpack.js669
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_webextension_paths.js47
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_webextension_theme.js365
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/xpcshell-unpack.ini13
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini226
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/amosigned.xpibin0 -> 4287 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/authRedirect.sjs21
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser.ini122
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_trigger.js86
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_trigger_iframe.js77
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_url.js60
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_auth.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_auth2.js73
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_auth3.js72
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_auth4.js71
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_badargs.js49
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_badargs2.js55
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_badhash.js46
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_badhashtype.js46
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_block_fullscreen_prompt.js129
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_bug540558.js31
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_bug611242.js31
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_bug638292.js51
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_bug645699.js69
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_bug645699_postDownload.js55
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_bug672485.js63
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_containers.js116
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_cookies.js42
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_cookies2.js64
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_cookies3.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_cookies4.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_corrupt.js53
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_datauri.js80
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_doorhanger_installs.js1542
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_empty.js39
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_enabled.js103
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_hash.js47
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_hash2.js47
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_httphash.js55
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_httphash2.js52
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_httphash3.js52
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_httphash4.js49
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_httphash5.js53
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_httphash6.js107
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_installchrome.js36
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_localfile.js42
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_localfile2.js61
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_localfile3.js42
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_localfile4.js55
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_localfile4_postDownload.js54
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_newwindow.js89
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_offline.js79
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_privatebrowsing.js133
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_relative.js67
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_required_useractivation.js156
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_signed_url.js29
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_softwareupdate.js36
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_trigger_redirect.js45
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_iframe.js77
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_xorigin.js58
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_url.js43
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/bug540558.html24
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/bug638292.html17
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/bug645699.html32
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/cookieRedirect.sjs23
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/corrupt.xpi1
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/empty.xpibin0 -> 197 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/enabled.html25
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/hashRedirect.sjs14
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/head.js545
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/incompatible.xpibin0 -> 428 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/installchrome.html23
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/installtrigger.html57
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/installtrigger_frame.html30
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/navigate.html25
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/recommended.xpibin0 -> 7884 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/redirect.sjs39
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/restartless.xpibin0 -> 4447 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/slowinstall.sjs103
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/startsoftwareupdate.html21
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/triggerredirect.html37
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/unsigned.xpibin0 -> 312 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/unsigned_mv3.xpibin0 -> 316 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/webmidi_permission.xpibin0 -> 7533 bytes
514 files changed, 108688 insertions, 0 deletions
diff --git a/toolkit/mozapps/extensions/.eslintrc.js b/toolkit/mozapps/extensions/.eslintrc.js
new file mode 100644
index 0000000000..2e1909b8fb
--- /dev/null
+++ b/toolkit/mozapps/extensions/.eslintrc.js
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+module.exports = {
+ rules: {
+ // Warn about cyclomatic complexity in functions.
+ // XXX Bug 1326071 - This should be reduced down - probably to 20 or to
+ // be removed & synced with the mozilla/recommended value.
+ complexity: ["error", { max: 68 }],
+
+ "no-unused-vars": [
+ "error",
+ {
+ args: "none",
+ vars: "all",
+ },
+ ],
+ },
+ overrides: [
+ {
+ files: "test/xpcshell/head*.js",
+ rules: {
+ "no-unused-vars": [
+ "error",
+ {
+ args: "none",
+ vars: "local",
+ },
+ ],
+ },
+ },
+ ],
+};
diff --git a/toolkit/mozapps/extensions/AbuseReporter.sys.mjs b/toolkit/mozapps/extensions/AbuseReporter.sys.mjs
new file mode 100644
index 0000000000..682796fd92
--- /dev/null
+++ b/toolkit/mozapps/extensions/AbuseReporter.sys.mjs
@@ -0,0 +1,676 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const PREF_ABUSE_REPORT_URL = "extensions.abuseReport.url";
+const PREF_AMO_DETAILS_API_URL = "extensions.abuseReport.amoDetailsURL";
+
+// Name associated with the report dialog window.
+const DIALOG_WINDOW_NAME = "addons-abuse-report-dialog";
+
+// Maximum length of the string properties sent to the API endpoint.
+const MAX_STRING_LENGTH = 255;
+
+// Minimum time between report submissions (in ms).
+const MIN_MS_BETWEEN_SUBMITS = 30000;
+
+// The addon types currently supported by the integrated abuse report panel.
+const SUPPORTED_ADDON_TYPES = [
+ "extension",
+ "theme",
+ "sitepermission",
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ "sitepermission-deprecated",
+];
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AMTelemetry: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ ClientID: "resource://gre/modules/ClientID.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "ABUSE_REPORT_URL",
+ PREF_ABUSE_REPORT_URL
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "AMO_DETAILS_API_URL",
+ PREF_AMO_DETAILS_API_URL
+);
+
+const PRIVATE_REPORT_PROPS = Symbol("privateReportProps");
+
+const ERROR_TYPES = Object.freeze([
+ "ERROR_ABORTED_SUBMIT",
+ "ERROR_ADDON_NOTFOUND",
+ "ERROR_CLIENT",
+ "ERROR_NETWORK",
+ "ERROR_UNKNOWN",
+ "ERROR_RECENT_SUBMIT",
+ "ERROR_SERVER",
+ "ERROR_AMODETAILS_NOTFOUND",
+ "ERROR_AMODETAILS_FAILURE",
+]);
+
+export class AbuseReportError extends Error {
+ constructor(errorType, errorInfo = undefined) {
+ if (!ERROR_TYPES.includes(errorType)) {
+ throw new Error(`Unknown AbuseReportError type "${errorType}"`);
+ }
+
+ let message = errorInfo ? `${errorType} - ${errorInfo}` : errorType;
+
+ super(message);
+ this.name = "AbuseReportError";
+ this.errorType = errorType;
+ this.errorInfo = errorInfo;
+ }
+}
+
+/**
+ * Create an error info string from a fetch response object.
+ *
+ * @param {Response} response
+ * A fetch response object to convert into an errorInfo string.
+ *
+ * @returns {Promise<string>}
+ * The errorInfo string to be included in an AbuseReportError.
+ */
+async function responseToErrorInfo(response) {
+ return JSON.stringify({
+ status: response.status,
+ responseText: await response.text().catch(err => ""),
+ });
+}
+
+/**
+ * A singleton object used to create new AbuseReport instances for a given addonId
+ * and enforce a minium amount of time between two report submissions .
+ */
+export const AbuseReporter = {
+ _lastReportTimestamp: null,
+
+ // Error types.
+ updateLastReportTimestamp() {
+ this._lastReportTimestamp = Date.now();
+ },
+
+ getTimeFromLastReport() {
+ const currentTimestamp = Date.now();
+ if (this._lastReportTimestamp > currentTimestamp) {
+ // Reset the last report timestamp if it is in the future.
+ this._lastReportTimestamp = null;
+ }
+
+ if (!this._lastReportTimestamp) {
+ return Infinity;
+ }
+
+ return currentTimestamp - this._lastReportTimestamp;
+ },
+
+ isSupportedAddonType(addonType) {
+ return SUPPORTED_ADDON_TYPES.includes(addonType);
+ },
+
+ /**
+ * Create an AbuseReport instance, given the addonId and a reportEntryPoint.
+ *
+ * @param {string} addonId
+ * The id of the addon to create the report instance for.
+ * @param {object} options
+ * @param {string} options.reportEntryPoint
+ * An identifier that represent the entry point for the report flow.
+ *
+ * @returns {Promise<AbuseReport>}
+ * Returns a promise that resolves to an instance of the AbuseReport
+ * class, which represent an ongoing report.
+ */
+ async createAbuseReport(addonId, { reportEntryPoint } = {}) {
+ let addon = await lazy.AddonManager.getAddonByID(addonId);
+
+ if (!addon) {
+ // The addon isn't installed, query the details from the AMO API endpoint.
+ addon = await this.queryAMOAddonDetails(addonId, reportEntryPoint);
+ }
+
+ if (!addon) {
+ lazy.AMTelemetry.recordReportEvent({
+ addonId,
+ errorType: "ERROR_ADDON_NOTFOUND",
+ reportEntryPoint,
+ });
+ throw new AbuseReportError("ERROR_ADDON_NOTFOUND");
+ }
+
+ const reportData = await this.getReportData(addon);
+
+ return new AbuseReport({
+ addon,
+ reportData,
+ reportEntryPoint,
+ });
+ },
+
+ /**
+ * Retrieves the addon details from the AMO API endpoint, used to create
+ * abuse reports on non-installed addon-ons.
+ *
+ * For the addon details that may be translated (e.g. addon name, description etc.)
+ * the function will try to retrieve the string localized in the same locale used
+ * by Gecko (and fallback to "en-US" if that locale is unavailable).
+ *
+ * The addon creator properties are set to the first author available.
+ *
+ * @param {string} addonId
+ * The id of the addon to retrieve the details available on AMO.
+ * @param {string} reportEntryPoint
+ * The entry point for the report flow (to be included in the telemetry
+ * recorded in case of failures).
+ *
+ * @returns {Promise<AMOAddonDetails|null>}
+ * Returns a promise that resolves to an AMOAddonDetails object,
+ * which has the subset of the AddonWrapper properties which are
+ * needed by the abuse report panel or the report data sent to
+ * the abuse report API endpoint), or null if it fails to
+ * retrieve the details from AMO.
+ *
+ * @typedef {object} AMOAddonDetails
+ * @prop {string} id
+ * @prop {string} name
+ * @prop {string} version
+ * @prop {string} description
+ * @prop {string} type
+ * @prop {string} iconURL
+ * @prop {string} homepageURL
+ * @prop {string} supportURL
+ * @prop {AMOAddonCreator} creator
+ * @prop {boolean} isRecommended
+ * @prop {number} signedState=AddonManager.SIGNEDSTATE_UNKNOWN
+ * @prop {object} installTelemetryInfo={ source: "not_installed" }
+ *
+ * @typedef {object} AMOAddonCreator
+ * @prop {string} name
+ * @prop {string} url
+ */
+ async queryAMOAddonDetails(addonId, reportEntryPoint) {
+ let details;
+ try {
+ // This should be the API endpoint documented at:
+ // https://addons-server.readthedocs.io/en/latest/topics/api/addons.html#detail
+ details = await fetch(`${lazy.AMO_DETAILS_API_URL}/${addonId}`, {
+ credentials: "omit",
+ referrerPolicy: "no-referrer",
+ headers: { "Content-Type": "application/json" },
+ }).then(async response => {
+ if (response.status === 200) {
+ return response.json();
+ }
+
+ let errorInfo = await responseToErrorInfo(response).catch(
+ err => undefined
+ );
+
+ if (response.status === 404) {
+ // Record a different telemetry event for 404 errors.
+ throw new AbuseReportError("ERROR_AMODETAILS_NOTFOUND", errorInfo);
+ }
+
+ throw new AbuseReportError("ERROR_AMODETAILS_FAILURE", errorInfo);
+ });
+ } catch (err) {
+ // Log the original error in the browser console.
+ Cu.reportError(err);
+
+ lazy.AMTelemetry.recordReportEvent({
+ addonId,
+ errorType: err.errorType || "ERROR_AMODETAILS_FAILURE",
+ reportEntryPoint,
+ });
+
+ return null;
+ }
+
+ const locale = Services.locale.appLocaleAsBCP47;
+
+ // Get a string value from a translated value
+ // (https://addons-server.readthedocs.io/en/latest/topics/api/overview.html#api-overview-translations)
+ const getTranslatedValue = value => {
+ if (typeof value === "string") {
+ return value;
+ }
+ return value && (value[locale] || value["en-US"]);
+ };
+
+ const getAuthorField = fieldName =>
+ details.authors && details.authors[0] && details.authors[0][fieldName];
+
+ // Normalize type "statictheme" (which is the type used on the AMO API side)
+ // into "theme" (because it is the type we use and expect on the Firefox side
+ // for this addon type).
+ const addonType = details.type === "statictheme" ? "theme" : details.type;
+
+ return {
+ id: addonId,
+ name: getTranslatedValue(details.name),
+ version: details.current_version.version,
+ description: getTranslatedValue(details.summary),
+ type: addonType,
+ iconURL: details.icon_url,
+ homepageURL: getTranslatedValue(details.homepage),
+ supportURL: getTranslatedValue(details.support_url),
+ // Set the addon creator to the first author in the AMO details.
+ creator: {
+ name: getAuthorField("name"),
+ url: getAuthorField("url"),
+ },
+ isRecommended: details.is_recommended,
+ // Set signed state to unknown because it isn't installed.
+ signedState: lazy.AddonManager.SIGNEDSTATE_UNKNOWN,
+ // Set the installTelemetryInfo.source to "not_installed".
+ installTelemetryInfo: { source: "not_installed" },
+ };
+ },
+
+ /**
+ * Helper function that retrieves from an addon object all the data to send
+ * as part of the submission request, besides the `reason`, `message` which are
+ * going to be received from the submit method of the report object returned
+ * by `createAbuseReport`.
+ * (See https://addons-server.readthedocs.io/en/latest/topics/api/abuse.html)
+ *
+ * @param {AddonWrapper} addon
+ * The addon object to collect the detail from.
+ *
+ * @return {object}
+ * An object that contains the collected details.
+ */
+ async getReportData(addon) {
+ const truncateString = text =>
+ typeof text == "string" ? text.slice(0, MAX_STRING_LENGTH) : text;
+
+ // Normalize addon_install_source and addon_install_method values
+ // as expected by the server API endpoint. Returns null if the
+ // value is not a string.
+ const normalizeValue = text =>
+ typeof text == "string"
+ ? text.toLowerCase().replace(/[- :]/g, "_")
+ : null;
+
+ const installInfo = addon.installTelemetryInfo || {};
+
+ const data = {
+ addon: addon.id,
+ addon_version: addon.version,
+ addon_name: truncateString(addon.name),
+ addon_summary: truncateString(addon.description),
+ addon_install_origin:
+ addon.sourceURI && truncateString(addon.sourceURI.spec),
+ install_date: addon.installDate && addon.installDate.toISOString(),
+ addon_install_source: normalizeValue(installInfo.source),
+ addon_install_source_url:
+ installInfo.sourceURL && truncateString(installInfo.sourceURL),
+ addon_install_method: normalizeValue(installInfo.method),
+ };
+
+ switch (addon.signedState) {
+ case lazy.AddonManager.SIGNEDSTATE_BROKEN:
+ data.addon_signature = "broken";
+ break;
+ case lazy.AddonManager.SIGNEDSTATE_UNKNOWN:
+ data.addon_signature = "unknown";
+ break;
+ case lazy.AddonManager.SIGNEDSTATE_MISSING:
+ data.addon_signature = "missing";
+ break;
+ case lazy.AddonManager.SIGNEDSTATE_PRELIMINARY:
+ data.addon_signature = "preliminary";
+ break;
+ case lazy.AddonManager.SIGNEDSTATE_SIGNED:
+ data.addon_signature = "signed";
+ break;
+ case lazy.AddonManager.SIGNEDSTATE_SYSTEM:
+ data.addon_signature = "system";
+ break;
+ case lazy.AddonManager.SIGNEDSTATE_PRIVILEGED:
+ data.addon_signature = "privileged";
+ break;
+ default:
+ data.addon_signature = `unknown: ${addon.signedState}`;
+ }
+
+ // Set "curated" as addon_signature on recommended addons
+ // (addon.isRecommended internally checks that the addon is also
+ // signed correctly).
+ if (addon.isRecommended) {
+ data.addon_signature = "curated";
+ }
+
+ data.client_id = await lazy.ClientID.getClientIdHash();
+
+ data.app = Services.appinfo.name.toLowerCase();
+ data.appversion = Services.appinfo.version;
+ data.lang = Services.locale.appLocaleAsBCP47;
+ data.operating_system = AppConstants.platform;
+ data.operating_system_version = Services.sysinfo.getProperty("version");
+
+ return data;
+ },
+
+ /**
+ * Helper function that returns a reference to a report dialog window
+ * already opened (if any).
+ *
+ * @returns {Window?}
+ */
+ getOpenDialog() {
+ return Services.ww.getWindowByName(DIALOG_WINDOW_NAME);
+ },
+
+ /**
+ * Helper function that opens an abuse report form in a new dialog window.
+ *
+ * @param {string} addonId
+ * The addonId being reported.
+ * @param {string} reportEntryPoint
+ * The entry point from which the user has triggered the abuse report
+ * flow.
+ * @param {XULElement} browser
+ * The browser element (if any) that is opening the report window.
+ *
+ * @return {Promise<AbuseReportDialog>}
+ * Returns an AbuseReportDialog object, rejects if it fails to open
+ * the dialog.
+ *
+ * @typedef {object} AbuseReportDialog
+ * An object that represents the abuse report dialog.
+ * @prop {function} close
+ * A method that closes the report dialog (used by the caller
+ * to close the dialog when the user chooses to close the window
+ * that started the abuse report flow).
+ * @prop {Promise<AbuseReport|undefined>} promiseReport
+ * A promise resolved to an AbuseReport instance if the report should
+ * be submitted, or undefined if the user has cancelled the report.
+ * Rejects if it fails to create an AbuseReport instance or to open
+ * the abuse report window.
+ */
+ async openDialog(addonId, reportEntryPoint, browser) {
+ const chromeWin = browser && browser.ownerGlobal;
+ if (!chromeWin) {
+ throw new Error("Abuse Reporter dialog cancelled, opener tab closed");
+ }
+
+ const dialogWin = this.getOpenDialog();
+
+ if (dialogWin) {
+ // If an abuse report dialog is already open, cancel the
+ // previous report flow and start a new one.
+ const { deferredReport, promiseReport } =
+ dialogWin.arguments[0].wrappedJSObject;
+ deferredReport.resolve({ userCancelled: true });
+ await promiseReport;
+ }
+
+ const report = await AbuseReporter.createAbuseReport(addonId, {
+ reportEntryPoint,
+ });
+
+ if (!SUPPORTED_ADDON_TYPES.includes(report.addon.type)) {
+ throw new Error(
+ `Addon type "${report.addon.type}" is not currently supported by the integrated abuse reporting feature`
+ );
+ }
+
+ const params = Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ );
+
+ const dialogInit = {
+ report,
+ openWebLink(url) {
+ chromeWin.openWebLinkIn(url, "tab", {
+ relatedToCurrent: true,
+ });
+ },
+ };
+
+ params.appendElement(dialogInit);
+
+ let win;
+ function closeDialog() {
+ if (win && !win.closed) {
+ win.close();
+ }
+ }
+
+ const promiseReport = new Promise((resolve, reject) => {
+ dialogInit.deferredReport = { resolve, reject };
+ }).then(
+ ({ userCancelled }) => {
+ closeDialog();
+ return userCancelled ? undefined : report;
+ },
+ err => {
+ Cu.reportError(
+ `Unexpected abuse report panel error: ${err} :: ${err.stack}`
+ );
+ closeDialog();
+ return Promise.reject({
+ message: "Unexpected abuse report panel error",
+ });
+ }
+ );
+
+ const promiseReportPanel = new Promise((resolve, reject) => {
+ dialogInit.deferredReportPanel = { resolve, reject };
+ });
+
+ dialogInit.promiseReport = promiseReport;
+ dialogInit.promiseReportPanel = promiseReportPanel;
+
+ win = Services.ww.openWindow(
+ chromeWin,
+ "chrome://mozapps/content/extensions/abuse-report-frame.html",
+ DIALOG_WINDOW_NAME,
+ // Set the dialog window options (including a reasonable initial
+ // window height size, eventually adjusted by the panel once it
+ // has been rendered its content).
+ "dialog,centerscreen,height=700",
+ params
+ );
+
+ return {
+ close: closeDialog,
+ promiseReport,
+
+ // Properties used in tests
+ promiseReportPanel,
+ window: win,
+ };
+ },
+};
+
+/**
+ * Represents an ongoing abuse report. Instances of this class are created
+ * by the `AbuseReporter.createAbuseReport` method.
+ *
+ * This object is used by the reporting UI panel and message bars to:
+ *
+ * - get an errorType in case of a report creation error (e.g. because of a
+ * previously submitted report)
+ * - get the addon details used inside the reporting panel
+ * - submit the abuse report (and re-submit if a previous submission failed
+ * and the user choose to retry to submit it again)
+ * - abort an ongoing submission
+ *
+ * @param {object} options
+ * @param {AddonWrapper|null} options.addon
+ * AddonWrapper instance for the extension/theme being reported.
+ * (May be null if the extension has not been found).
+ * @param {object|null} options.reportData
+ * An object which contains addon and environment details to send as part of a submission
+ * (may be null if the report has a createErrorType).
+ * @param {string} options.reportEntryPoint
+ * A string that identify how the report has been triggered.
+ */
+class AbuseReport {
+ constructor({ addon, createErrorType, reportData, reportEntryPoint }) {
+ this[PRIVATE_REPORT_PROPS] = {
+ aborted: false,
+ abortController: new AbortController(),
+ addon,
+ reportData,
+ reportEntryPoint,
+ // message and reason are initially null, and then set by the panel
+ // using the related set method.
+ message: null,
+ reason: null,
+ };
+ }
+
+ recordTelemetry(errorType) {
+ const { addon, reportEntryPoint } = this;
+ lazy.AMTelemetry.recordReportEvent({
+ addonId: addon.id,
+ addonType: addon.type,
+ errorType,
+ reportEntryPoint,
+ });
+ }
+
+ /**
+ * Submit the current report, given a reason and a message.
+ *
+ * @returns {Promise<void>}
+ * Resolves once the report has been successfully submitted.
+ * It rejects with an AbuseReportError if the report couldn't be
+ * submitted for a known reason (or another Error type otherwise).
+ */
+ async submit() {
+ const {
+ aborted,
+ abortController,
+ message,
+ reason,
+ reportData,
+ reportEntryPoint,
+ } = this[PRIVATE_REPORT_PROPS];
+
+ // Record telemetry event and throw an AbuseReportError.
+ const rejectReportError = async (errorType, { response } = {}) => {
+ this.recordTelemetry(errorType);
+
+ // Leave errorInfo empty if there is no response or fails to
+ // be converted into an error info object.
+ const errorInfo = response
+ ? await responseToErrorInfo(response).catch(err => undefined)
+ : undefined;
+
+ throw new AbuseReportError(errorType, errorInfo);
+ };
+
+ if (aborted) {
+ // Report aborted before being actually submitted.
+ return rejectReportError("ERROR_ABORTED_SUBMIT");
+ }
+
+ // Prevent submit of a new abuse report in less than MIN_MS_BETWEEN_SUBMITS.
+ let msFromLastReport = AbuseReporter.getTimeFromLastReport();
+ if (msFromLastReport < MIN_MS_BETWEEN_SUBMITS) {
+ return rejectReportError("ERROR_RECENT_SUBMIT");
+ }
+
+ let response;
+ try {
+ response = await fetch(lazy.ABUSE_REPORT_URL, {
+ signal: abortController.signal,
+ method: "POST",
+ credentials: "omit",
+ referrerPolicy: "no-referrer",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ ...reportData,
+ report_entry_point: reportEntryPoint,
+ message,
+ reason,
+ }),
+ });
+ } catch (err) {
+ if (err.name === "AbortError") {
+ return rejectReportError("ERROR_ABORTED_SUBMIT");
+ }
+ Cu.reportError(err);
+ return rejectReportError("ERROR_NETWORK");
+ }
+
+ if (response.ok && response.status >= 200 && response.status < 400) {
+ // Ensure that the response is also a valid json format.
+ try {
+ await response.json();
+ } catch (err) {
+ this.recordTelemetry("ERROR_UNKNOWN");
+ throw err;
+ }
+ AbuseReporter.updateLastReportTimestamp();
+ this.recordTelemetry();
+ return undefined;
+ }
+
+ if (response.status >= 400 && response.status < 500) {
+ return rejectReportError("ERROR_CLIENT", { response });
+ }
+
+ if (response.status >= 500 && response.status < 600) {
+ return rejectReportError("ERROR_SERVER", { response });
+ }
+
+ // We got an unexpected HTTP status code.
+ return rejectReportError("ERROR_UNKNOWN", { response });
+ }
+
+ /**
+ * Abort the report submission.
+ */
+ abort() {
+ const { abortController } = this[PRIVATE_REPORT_PROPS];
+ abortController.abort();
+ this[PRIVATE_REPORT_PROPS].aborted = true;
+ }
+
+ get addon() {
+ return this[PRIVATE_REPORT_PROPS].addon;
+ }
+
+ get reportEntryPoint() {
+ return this[PRIVATE_REPORT_PROPS].reportEntryPoint;
+ }
+
+ /**
+ * Set the open message (called from the panel when the user submit the report)
+ *
+ * @parm {string} message
+ * An optional string which contains a description for the reported issue.
+ */
+ setMessage(message) {
+ this[PRIVATE_REPORT_PROPS].message = message;
+ }
+
+ /**
+ * Set the report reason (called from the panel when the user submit the report)
+ *
+ * @parm {string} reason
+ * String identifier for the report reason.
+ */
+ setReason(reason) {
+ this[PRIVATE_REPORT_PROPS].reason = reason;
+ }
+}
diff --git a/toolkit/mozapps/extensions/AddonContentPolicy.cpp b/toolkit/mozapps/extensions/AddonContentPolicy.cpp
new file mode 100644
index 0000000000..bffe78a7ba
--- /dev/null
+++ b/toolkit/mozapps/extensions/AddonContentPolicy.cpp
@@ -0,0 +1,484 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "AddonContentPolicy.h"
+
+#include "mozilla/dom/nsCSPContext.h"
+#include "nsCOMPtr.h"
+#include "nsComponentManagerUtils.h"
+#include "nsContentPolicyUtils.h"
+#include "nsContentTypeParser.h"
+#include "nsContentUtils.h"
+#include "nsIConsoleService.h"
+#include "nsIContentSecurityPolicy.h"
+#include "nsIContent.h"
+#include "mozilla/Components.h"
+#include "mozilla/dom/Document.h"
+#include "mozilla/intl/Localization.h"
+#include "nsIEffectiveTLDService.h"
+#include "nsIScriptError.h"
+#include "nsIStringBundle.h"
+#include "nsIUUIDGenerator.h"
+#include "nsIURI.h"
+#include "nsNetCID.h"
+#include "nsNetUtil.h"
+
+using namespace mozilla;
+using namespace mozilla::intl;
+
+/* Enforces content policies for WebExtension scopes. Currently:
+ *
+ * - Prevents loading scripts with a non-default JavaScript version.
+ * - Checks custom content security policies for sufficiently stringent
+ * script-src and other script-related directives.
+ * - We also used to validate object-src similarly to script-src, but that was
+ * dropped because NPAPI plugins are no longer supported (see bug 1766881).
+ */
+
+#define VERSIONED_JS_BLOCKED_MESSAGE \
+ u"Versioned JavaScript is a non-standard, deprecated extension, and is " \
+ u"not supported in WebExtension code. For alternatives, please see: " \
+ u"https://developer.mozilla.org/Add-ons/WebExtensions/Tips"
+
+AddonContentPolicy::AddonContentPolicy() = default;
+
+AddonContentPolicy::~AddonContentPolicy() = default;
+
+NS_IMPL_ISUPPORTS(AddonContentPolicy, nsIContentPolicy, nsIAddonContentPolicy)
+
+static nsresult GetWindowIDFromContext(nsISupports* aContext,
+ uint64_t* aResult) {
+ NS_ENSURE_TRUE(aContext, NS_ERROR_FAILURE);
+
+ nsCOMPtr<nsIContent> content = do_QueryInterface(aContext);
+ NS_ENSURE_TRUE(content, NS_ERROR_FAILURE);
+
+ nsCOMPtr<nsPIDOMWindowInner> window = content->OwnerDoc()->GetInnerWindow();
+ NS_ENSURE_TRUE(window, NS_ERROR_FAILURE);
+
+ *aResult = window->WindowID();
+ return NS_OK;
+}
+
+static nsresult LogMessage(const nsAString& aMessage,
+ const nsAString& aSourceName,
+ const nsAString& aSourceSample,
+ nsISupports* aContext) {
+ nsCOMPtr<nsIScriptError> error = do_CreateInstance(NS_SCRIPTERROR_CONTRACTID);
+ NS_ENSURE_TRUE(error, NS_ERROR_OUT_OF_MEMORY);
+
+ uint64_t windowID = 0;
+ GetWindowIDFromContext(aContext, &windowID);
+
+ nsresult rv = error->InitWithSanitizedSource(
+ aMessage, aSourceName, aSourceSample, 0, 0, nsIScriptError::errorFlag,
+ "JavaScript", windowID);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIConsoleService> console =
+ do_GetService(NS_CONSOLESERVICE_CONTRACTID);
+ NS_ENSURE_TRUE(console, NS_ERROR_OUT_OF_MEMORY);
+
+ console->LogMessage(error);
+ return NS_OK;
+}
+
+// Content policy enforcement:
+
+NS_IMETHODIMP
+AddonContentPolicy::ShouldLoad(nsIURI* aContentLocation, nsILoadInfo* aLoadInfo,
+ const nsACString& aMimeTypeGuess,
+ int16_t* aShouldLoad) {
+ if (!aContentLocation || !aLoadInfo) {
+ NS_SetRequestBlockingReason(
+ aLoadInfo, nsILoadInfo::BLOCKING_REASON_CONTENT_POLICY_WEBEXT);
+ *aShouldLoad = REJECT_REQUEST;
+ return NS_ERROR_FAILURE;
+ }
+
+ ExtContentPolicyType contentType = aLoadInfo->GetExternalContentPolicyType();
+
+ *aShouldLoad = nsIContentPolicy::ACCEPT;
+ nsCOMPtr<nsIPrincipal> loadingPrincipal = aLoadInfo->GetLoadingPrincipal();
+ if (!loadingPrincipal) {
+ return NS_OK;
+ }
+
+ // Only apply this policy to requests from documents loaded from
+ // moz-extension URLs, or to resources being loaded from moz-extension URLs.
+ if (!(aContentLocation->SchemeIs("moz-extension") ||
+ loadingPrincipal->SchemeIs("moz-extension"))) {
+ return NS_OK;
+ }
+
+ if (contentType == ExtContentPolicy::TYPE_SCRIPT) {
+ NS_ConvertUTF8toUTF16 typeString(aMimeTypeGuess);
+ nsContentTypeParser mimeParser(typeString);
+
+ // Reject attempts to load JavaScript scripts with a non-default version.
+ nsAutoString mimeType, version;
+ if (NS_SUCCEEDED(mimeParser.GetType(mimeType)) &&
+ nsContentUtils::IsJavascriptMIMEType(mimeType) &&
+ NS_SUCCEEDED(mimeParser.GetParameter("version", version))) {
+ NS_SetRequestBlockingReason(
+ aLoadInfo, nsILoadInfo::BLOCKING_REASON_CONTENT_POLICY_WEBEXT);
+ *aShouldLoad = nsIContentPolicy::REJECT_REQUEST;
+
+ nsCString sourceName;
+ loadingPrincipal->GetExposableSpec(sourceName);
+ NS_ConvertUTF8toUTF16 nameString(sourceName);
+
+ nsCOMPtr<nsISupports> context = aLoadInfo->GetLoadingContext();
+ LogMessage(nsLiteralString(VERSIONED_JS_BLOCKED_MESSAGE), nameString,
+ typeString, context);
+ return NS_OK;
+ }
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+AddonContentPolicy::ShouldProcess(nsIURI* aContentLocation,
+ nsILoadInfo* aLoadInfo,
+ const nsACString& aMimeTypeGuess,
+ int16_t* aShouldProcess) {
+ *aShouldProcess = nsIContentPolicy::ACCEPT;
+ return NS_OK;
+}
+
+// CSP Validation:
+
+static const char* allowedSchemes[] = {"blob", "filesystem", nullptr};
+
+static const char* allowedHostSchemes[] = {"http", "https", "moz-extension",
+ nullptr};
+
+/**
+ * Validates a CSP directive to ensure that it is sufficiently stringent.
+ * In particular, ensures that:
+ *
+ * - No remote sources are allowed other than from https: schemes
+ *
+ * - No remote sources specify host wildcards for generic domains
+ * (*.blogspot.com, *.com, *)
+ *
+ * - All remote sources and local extension sources specify a host
+ *
+ * - No scheme sources are allowed other than blob:, filesystem:,
+ * moz-extension:, and https:
+ *
+ * - No keyword sources are allowed other than 'none', 'self', 'unsafe-eval',
+ * and hash sources.
+ *
+ * Manifest V3 limits CSP for extension_pages, the script-src and
+ * worker-src directives may only be the following:
+ * - self
+ * - none
+ */
+class CSPValidator final : public nsCSPSrcVisitor {
+ public:
+ CSPValidator(nsAString& aURL, CSPDirective aDirective,
+ bool aDirectiveRequired = true, uint32_t aPermittedPolicy = 0)
+ : mURL(aURL),
+ mDirective(CSP_CSPDirectiveToString(aDirective)),
+ mPermittedPolicy(aPermittedPolicy),
+ mDirectiveRequired(aDirectiveRequired),
+ mFoundSelf(false) {
+ // Start with the default error message for a missing directive, since no
+ // visitors will be called if the directive isn't present.
+ mError.SetIsVoid(true);
+ }
+
+ // Visitors
+
+ bool visitSchemeSrc(const nsCSPSchemeSrc& src) override {
+ nsAutoString scheme;
+ src.getScheme(scheme);
+
+ if (SchemeInList(scheme, allowedHostSchemes)) {
+ FormatError("csp-error-missing-host"_ns, "scheme"_ns, scheme);
+ return false;
+ }
+ if (!SchemeInList(scheme, allowedSchemes)) {
+ FormatError("csp-error-illegal-protocol"_ns, "scheme"_ns, scheme);
+ return false;
+ }
+ return true;
+ };
+
+ bool visitHostSrc(const nsCSPHostSrc& src) override {
+ nsAutoString scheme, host;
+
+ src.getScheme(scheme);
+ src.getHost(host);
+
+ if (scheme.LowerCaseEqualsLiteral("http")) {
+ // Allow localhost on http
+ if (mPermittedPolicy & nsIAddonContentPolicy::CSP_ALLOW_LOCALHOST &&
+ HostIsLocal(host)) {
+ return true;
+ }
+ FormatError("csp-error-illegal-protocol"_ns, "scheme"_ns, scheme);
+ return false;
+ }
+ if (scheme.LowerCaseEqualsLiteral("https")) {
+ if (mPermittedPolicy & nsIAddonContentPolicy::CSP_ALLOW_LOCALHOST &&
+ HostIsLocal(host)) {
+ return true;
+ }
+ if (!(mPermittedPolicy & nsIAddonContentPolicy::CSP_ALLOW_REMOTE)) {
+ FormatError("csp-error-illegal-protocol"_ns, "scheme"_ns, scheme);
+ return false;
+ }
+ if (!HostIsAllowed(host)) {
+ FormatError("csp-error-illegal-host-wildcard"_ns, "scheme"_ns, scheme);
+ return false;
+ }
+ } else if (scheme.LowerCaseEqualsLiteral("moz-extension")) {
+ // The CSP parser silently converts 'self' keywords to the origin
+ // URL, so we need to reconstruct the URL to see if it was present.
+ if (!mFoundSelf) {
+ nsAutoString url(u"moz-extension://");
+ url.Append(host);
+
+ mFoundSelf = url.Equals(mURL);
+ }
+
+ if (host.IsEmpty() || host.EqualsLiteral("*")) {
+ FormatError("csp-error-missing-host"_ns, "scheme"_ns, scheme);
+ return false;
+ }
+ } else if (!SchemeInList(scheme, allowedSchemes)) {
+ FormatError("csp-error-illegal-protocol"_ns, "scheme"_ns, scheme);
+ return false;
+ }
+
+ return true;
+ };
+
+ bool visitKeywordSrc(const nsCSPKeywordSrc& src) override {
+ switch (src.getKeyword()) {
+ case CSP_NONE:
+ case CSP_SELF:
+ return true;
+ case CSP_WASM_UNSAFE_EVAL:
+ if (mPermittedPolicy & nsIAddonContentPolicy::CSP_ALLOW_WASM) {
+ return true;
+ }
+ // fall through to also check CSP_ALLOW_EVAL
+ [[fallthrough]];
+ case CSP_UNSAFE_EVAL:
+ if (mPermittedPolicy & nsIAddonContentPolicy::CSP_ALLOW_EVAL) {
+ return true;
+ }
+ // fall through and produce an illegal-keyword error.
+ [[fallthrough]];
+ default:
+ FormatError(
+ "csp-error-illegal-keyword"_ns, "keyword"_ns,
+ nsDependentString(CSP_EnumToUTF16Keyword(src.getKeyword())));
+ return false;
+ }
+ };
+
+ bool visitNonceSrc(const nsCSPNonceSrc& src) override {
+ FormatError("csp-error-illegal-keyword"_ns, "keyword"_ns, u"'nonce-*'"_ns);
+ return false;
+ };
+
+ bool visitHashSrc(const nsCSPHashSrc& src) override { return true; };
+
+ // Accessors
+
+ inline nsAString& GetError() {
+ if (mError.IsVoid() && mDirectiveRequired) {
+ FormatError("csp-error-missing-directive"_ns);
+ }
+
+ return mError;
+ };
+
+ inline bool FoundSelf() { return mFoundSelf; };
+
+ // Formatters
+
+ inline void FormatError(const nsACString& l10nId,
+ const nsACString& aKey = ""_ns,
+ const nsAString& aValue = u""_ns) {
+ nsTArray<nsCString> resIds = {"toolkit/global/cspErrors.ftl"_ns};
+ RefPtr<intl::Localization> l10n = intl::Localization::Create(resIds, true);
+
+ auto l10nArgs = dom::Optional<intl::L10nArgs>();
+ l10nArgs.Construct();
+
+ auto dirArg = l10nArgs.Value().Entries().AppendElement();
+ dirArg->mKey = "directive";
+ dirArg->mValue.SetValue().SetAsUTF8String().Assign(
+ NS_ConvertUTF16toUTF8(mDirective));
+
+ if (!aKey.IsEmpty()) {
+ auto optArg = l10nArgs.Value().Entries().AppendElement();
+ optArg->mKey = aKey;
+ optArg->mValue.SetValue().SetAsUTF8String().Assign(
+ NS_ConvertUTF16toUTF8(aValue));
+ }
+
+ nsAutoCString translation;
+ IgnoredErrorResult rv;
+ l10n->FormatValueSync(l10nId, l10nArgs, translation, rv);
+ if (rv.Failed()) {
+ mError.AssignLiteral("An unexpected error occurred");
+ } else {
+ mError = NS_ConvertUTF8toUTF16(translation);
+ }
+ };
+
+ private:
+ // Validators
+ bool HostIsLocal(nsAString& host) {
+ return host.EqualsLiteral("localhost") || host.EqualsLiteral("127.0.0.1");
+ }
+
+ bool HostIsAllowed(nsAString& host) {
+ if (host.First() == '*') {
+ if (host.EqualsLiteral("*") || host[1] != '.') {
+ return false;
+ }
+
+ host.Cut(0, 2);
+
+ nsCOMPtr<nsIEffectiveTLDService> tldService =
+ do_GetService(NS_EFFECTIVETLDSERVICE_CONTRACTID);
+
+ if (!tldService) {
+ return false;
+ }
+
+ NS_ConvertUTF16toUTF8 cHost(host);
+ nsAutoCString publicSuffix;
+
+ nsresult rv = tldService->GetPublicSuffixFromHost(cHost, publicSuffix);
+
+ return NS_SUCCEEDED(rv) && !cHost.Equals(publicSuffix);
+ }
+
+ return true;
+ };
+
+ bool SchemeInList(nsAString& scheme, const char** schemes) {
+ for (; *schemes; schemes++) {
+ if (scheme.LowerCaseEqualsASCII(*schemes)) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ // Data members
+
+ nsAutoString mURL;
+ NS_ConvertASCIItoUTF16 mDirective;
+ nsString mError;
+
+ uint32_t mPermittedPolicy;
+ bool mDirectiveRequired;
+ bool mFoundSelf;
+};
+
+/**
+ * Validates a custom content security policy string for use by an add-on.
+ * In particular, ensures that:
+ *
+ * - That script-src (or default-src) directive is present, and meets
+ * the policies required by the CSPValidator class
+ *
+ * - The script-src directive includes the source 'self'
+ */
+NS_IMETHODIMP
+AddonContentPolicy::ValidateAddonCSP(const nsAString& aPolicyString,
+ uint32_t aPermittedPolicy,
+ nsAString& aResult) {
+ nsresult rv;
+
+ // Validate against a randomly-generated extension origin.
+ // There is no add-on-specific behavior in the CSP code, beyond the ability
+ // for add-ons to specify a custom policy, but the parser requires a valid
+ // origin in order to operate correctly.
+ nsAutoString url(u"moz-extension://");
+ {
+ nsCOMPtr<nsIUUIDGenerator> uuidgen = components::UUIDGenerator::Service();
+ NS_ENSURE_TRUE(uuidgen, NS_ERROR_FAILURE);
+
+ nsID id;
+ rv = uuidgen->GenerateUUIDInPlace(&id);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ char idString[NSID_LENGTH];
+ id.ToProvidedString(idString);
+
+ MOZ_RELEASE_ASSERT(idString[0] == '{' && idString[NSID_LENGTH - 2] == '}',
+ "UUID generator did not return a valid UUID");
+
+ url.AppendASCII(idString + 1, NSID_LENGTH - 3);
+ }
+
+ RefPtr<BasePrincipal> principal =
+ BasePrincipal::CreateContentPrincipal(NS_ConvertUTF16toUTF8(url));
+
+ nsCOMPtr<nsIURI> selfURI;
+ principal->GetURI(getter_AddRefs(selfURI));
+ RefPtr<nsCSPContext> csp = new nsCSPContext();
+ rv = csp->SetRequestContextWithPrincipal(principal, selfURI, u""_ns, 0);
+ NS_ENSURE_SUCCESS(rv, rv);
+ csp->AppendPolicy(aPolicyString, false, false);
+
+ const nsCSPPolicy* policy = csp->GetPolicy(0);
+ if (!policy) {
+ CSPValidator validator(url, nsIContentSecurityPolicy::SCRIPT_SRC_DIRECTIVE,
+ true, aPermittedPolicy);
+ aResult.Assign(validator.GetError());
+ return NS_OK;
+ }
+
+ bool haveValidDefaultSrc = false;
+ bool hasValidScriptSrc = false;
+ {
+ CSPDirective directive = nsIContentSecurityPolicy::DEFAULT_SRC_DIRECTIVE;
+ CSPValidator validator(url, directive);
+
+ haveValidDefaultSrc = policy->visitDirectiveSrcs(directive, &validator);
+ }
+
+ aResult.SetIsVoid(true);
+ {
+ CSPDirective directive = nsIContentSecurityPolicy::SCRIPT_SRC_DIRECTIVE;
+ CSPValidator validator(url, directive, !haveValidDefaultSrc,
+ aPermittedPolicy);
+
+ if (!policy->visitDirectiveSrcs(directive, &validator)) {
+ aResult.Assign(validator.GetError());
+ } else if (!validator.FoundSelf()) {
+ validator.FormatError("csp-error-missing-source"_ns, "source"_ns,
+ u"'self'"_ns);
+ aResult.Assign(validator.GetError());
+ }
+ hasValidScriptSrc = true;
+ }
+
+ if (aResult.IsVoid()) {
+ CSPDirective directive = nsIContentSecurityPolicy::WORKER_SRC_DIRECTIVE;
+ CSPValidator validator(url, directive,
+ !haveValidDefaultSrc && !hasValidScriptSrc,
+ aPermittedPolicy);
+
+ if (!policy->visitDirectiveSrcs(directive, &validator)) {
+ aResult.Assign(validator.GetError());
+ }
+ }
+
+ return NS_OK;
+}
diff --git a/toolkit/mozapps/extensions/AddonContentPolicy.h b/toolkit/mozapps/extensions/AddonContentPolicy.h
new file mode 100644
index 0000000000..db4c29db05
--- /dev/null
+++ b/toolkit/mozapps/extensions/AddonContentPolicy.h
@@ -0,0 +1,21 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsIContentPolicy.h"
+#include "nsIAddonPolicyService.h"
+
+class AddonContentPolicy : public nsIContentPolicy,
+ public nsIAddonContentPolicy {
+ protected:
+ virtual ~AddonContentPolicy();
+
+ public:
+ AddonContentPolicy();
+
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSICONTENTPOLICY
+ NS_DECL_NSIADDONCONTENTPOLICY
+};
diff --git a/toolkit/mozapps/extensions/AddonManager.sys.mjs b/toolkit/mozapps/extensions/AddonManager.sys.mjs
new file mode 100644
index 0000000000..2ae57f0b52
--- /dev/null
+++ b/toolkit/mozapps/extensions/AddonManager.sys.mjs
@@ -0,0 +1,5233 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Cannot use Services.appinfo here, or else xpcshell-tests will blow up, as
+// most tests later register different nsIAppInfo implementations, which
+// wouldn't be reflected in Services.appinfo anymore, as the lazy getter
+// underlying it would have been initialized if we used it here.
+if ("@mozilla.org/xre/app-info;1" in Cc) {
+ // eslint-disable-next-line mozilla/use-services
+ let runtime = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime);
+ if (runtime.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) {
+ // Refuse to run in child processes.
+ throw new Error("You cannot use the AddonManager in child processes!");
+ }
+}
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const MOZ_COMPATIBILITY_NIGHTLY = ![
+ "aurora",
+ "beta",
+ "release",
+ "esr",
+].includes(AppConstants.MOZ_UPDATE_CHANNEL);
+
+const INTL_LOCALES_CHANGED = "intl:app-locales-changed";
+
+const PREF_AMO_ABUSEREPORT = "extensions.abuseReport.amWebAPI.enabled";
+const PREF_BLOCKLIST_PINGCOUNTVERSION = "extensions.blocklist.pingCountVersion";
+const PREF_EM_UPDATE_ENABLED = "extensions.update.enabled";
+const PREF_EM_LAST_APP_VERSION = "extensions.lastAppVersion";
+const PREF_EM_LAST_PLATFORM_VERSION = "extensions.lastPlatformVersion";
+const PREF_EM_AUTOUPDATE_DEFAULT = "extensions.update.autoUpdateDefault";
+const PREF_EM_STRICT_COMPATIBILITY = "extensions.strictCompatibility";
+const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity";
+const PREF_SYS_ADDON_UPDATE_ENABLED = "extensions.systemAddon.update.enabled";
+const PREF_REMOTESETTINGS_DISABLED = "extensions.remoteSettings.disabled";
+
+const PREF_MIN_WEBEXT_PLATFORM_VERSION =
+ "extensions.webExtensionsMinPlatformVersion";
+const PREF_WEBAPI_TESTING = "extensions.webapi.testing";
+const PREF_EM_POSTDOWNLOAD_THIRD_PARTY =
+ "extensions.postDownloadThirdPartyPrompt";
+
+const UPDATE_REQUEST_VERSION = 2;
+
+const BRANCH_REGEXP = /^([^\.]+\.[0-9]+[a-z]*).*/gi;
+const PREF_EM_CHECK_COMPATIBILITY_BASE = "extensions.checkCompatibility";
+var PREF_EM_CHECK_COMPATIBILITY = MOZ_COMPATIBILITY_NIGHTLY
+ ? PREF_EM_CHECK_COMPATIBILITY_BASE + ".nightly"
+ : undefined;
+
+const WEBAPI_INSTALL_HOSTS =
+ AppConstants.MOZ_APP_NAME !== "thunderbird"
+ ? ["addons.mozilla.org"]
+ : ["addons.thunderbird.net"];
+const WEBAPI_TEST_INSTALL_HOSTS =
+ AppConstants.MOZ_APP_NAME !== "thunderbird"
+ ? ["addons.allizom.org", "addons-dev.allizom.org", "example.com"]
+ : ["addons-stage.thunderbird.net", "example.com"];
+
+const AMO_ATTRIBUTION_ALLOWED_SOURCES = ["amo", "disco", "rtamo"];
+const AMO_ATTRIBUTION_DATA_KEYS = [
+ "utm_campaign",
+ "utm_content",
+ "utm_medium",
+ "utm_source",
+];
+const AMO_ATTRIBUTION_DATA_MAX_LENGTH = 40;
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+// This global is overridden by xpcshell tests, and therefore cannot be
+// a const.
+import { AsyncShutdown as realAsyncShutdown } from "resource://gre/modules/AsyncShutdown.sys.mjs";
+
+var AsyncShutdown = realAsyncShutdown;
+
+import { PromiseUtils } from "resource://gre/modules/PromiseUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AbuseReporter: "resource://gre/modules/AbuseReporter.sys.mjs",
+ AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
+ Extension: "resource://gre/modules/Extension.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ TelemetryTimestamps: "resource://gre/modules/TelemetryTimestamps.sys.mjs",
+ isGatedPermissionType:
+ "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs",
+ isKnownPublicSuffix:
+ "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs",
+ isPrincipalInSitePermissionsBlocklist:
+ "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "WEBEXT_POSTDOWNLOAD_THIRD_PARTY",
+ PREF_EM_POSTDOWNLOAD_THIRD_PARTY,
+ false
+);
+
+// Initialize the WebExtension process script service as early as possible,
+// since it needs to be able to track things like new frameLoader globals that
+// are created before other framework code has been initialized.
+Services.ppmm.loadProcessScript(
+ "resource://gre/modules/extensionProcessScriptLoader.js",
+ true
+);
+
+const INTEGER = /^[1-9]\d*$/;
+
+const CATEGORY_PROVIDER_MODULE = "addon-provider-module";
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+// Configure a logger at the parent 'addons' level to format
+// messages for all the modules under addons.*
+const PARENT_LOGGER_ID = "addons";
+var parentLogger = Log.repository.getLogger(PARENT_LOGGER_ID);
+parentLogger.level = Log.Level.Warn;
+var formatter = new Log.BasicFormatter();
+// Set parent logger (and its children) to append to
+// the Javascript section of the Browser Console
+parentLogger.addAppender(new Log.ConsoleAppender(formatter));
+
+// Create a new logger (child of 'addons' logger)
+// for use by the Addons Manager
+const LOGGER_ID = "addons.manager";
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+// Provide the ability to enable/disable logging
+// messages at runtime.
+// If the "extensions.logging.enabled" preference is
+// missing or 'false', messages at the WARNING and higher
+// severity should be logged to the JS console and standard error.
+// If "extensions.logging.enabled" is set to 'true', messages
+// at DEBUG and higher should go to JS console and standard error.
+const PREF_LOGGING_ENABLED = "extensions.logging.enabled";
+const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed";
+
+const UNNAMED_PROVIDER = "<unnamed-provider>";
+function providerName(aProvider) {
+ return aProvider.name || UNNAMED_PROVIDER;
+}
+
+// A reference to XPIProvider. This should only be used to access properties or
+// methods that are independent of XPIProvider startup.
+var gXPIProvider;
+
+/**
+ * Preference listener which listens for a change in the
+ * "extensions.logging.enabled" preference and changes the logging level of the
+ * parent 'addons' level logger accordingly.
+ */
+var PrefObserver = {
+ init() {
+ Services.prefs.addObserver(PREF_LOGGING_ENABLED, this);
+ Services.obs.addObserver(this, "xpcom-shutdown");
+ this.observe(null, NS_PREFBRANCH_PREFCHANGE_TOPIC_ID, PREF_LOGGING_ENABLED);
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "xpcom-shutdown") {
+ Services.prefs.removeObserver(PREF_LOGGING_ENABLED, this);
+ Services.obs.removeObserver(this, "xpcom-shutdown");
+ } else if (aTopic == NS_PREFBRANCH_PREFCHANGE_TOPIC_ID) {
+ let debugLogEnabled = Services.prefs.getBoolPref(
+ PREF_LOGGING_ENABLED,
+ false
+ );
+ if (debugLogEnabled) {
+ parentLogger.level = Log.Level.Debug;
+ } else {
+ parentLogger.level = Log.Level.Warn;
+ }
+ }
+ },
+};
+
+PrefObserver.init();
+
+/**
+ * Calls a callback method consuming any thrown exception. Any parameters after
+ * the callback parameter will be passed to the callback.
+ *
+ * @param aCallback
+ * The callback method to call
+ */
+function safeCall(aCallback, ...aArgs) {
+ try {
+ aCallback.apply(null, aArgs);
+ } catch (e) {
+ logger.warn("Exception calling callback", e);
+ }
+}
+
+/**
+ * Report an exception thrown by a provider API method.
+ */
+function reportProviderError(aProvider, aMethod, aError) {
+ let method = `provider ${providerName(aProvider)}.${aMethod}`;
+ AddonManagerPrivate.recordException("AMI", method, aError);
+ logger.error("Exception calling " + method, aError);
+}
+
+/**
+ * Calls a method on a provider if it exists and consumes any thrown exception.
+ * Any parameters after the aDefault parameter are passed to the provider's method.
+ *
+ * @param aProvider
+ * The provider to call
+ * @param aMethod
+ * The method name to call
+ * @param aDefault
+ * A default return value if the provider does not implement the named
+ * method or throws an error.
+ * @return the return value from the provider, or aDefault if the provider does not
+ * implement method or throws an error
+ */
+function callProvider(aProvider, aMethod, aDefault, ...aArgs) {
+ if (!(aMethod in aProvider)) {
+ return aDefault;
+ }
+
+ try {
+ return aProvider[aMethod].apply(aProvider, aArgs);
+ } catch (e) {
+ reportProviderError(aProvider, aMethod, e);
+ return aDefault;
+ }
+}
+
+/**
+ * Calls a method on a provider if it exists and consumes any thrown exception.
+ * Parameters after aMethod are passed to aProvider.aMethod().
+ * If the provider does not implement the method, or the method throws, calls
+ * the callback with 'undefined'.
+ *
+ * @param aProvider
+ * The provider to call
+ * @param aMethod
+ * The method name to call
+ */
+async function promiseCallProvider(aProvider, aMethod, ...aArgs) {
+ if (!(aMethod in aProvider)) {
+ return undefined;
+ }
+ try {
+ return aProvider[aMethod].apply(aProvider, aArgs);
+ } catch (e) {
+ reportProviderError(aProvider, aMethod, e);
+ return undefined;
+ }
+}
+
+/**
+ * Gets the currently selected locale for display.
+ * @return the selected locale or "en-US" if none is selected
+ */
+function getLocale() {
+ return Services.locale.requestedLocale || "en-US";
+}
+
+const WEB_EXPOSED_ADDON_PROPERTIES = [
+ "id",
+ "version",
+ "type",
+ "name",
+ "description",
+ "isActive",
+];
+
+function webAPIForAddon(addon) {
+ if (!addon) {
+ return null;
+ }
+
+ // These web-exposed Addon properties (see AddonManager.webidl)
+ // just come directly from an Addon object.
+ let result = {};
+ for (let prop of WEB_EXPOSED_ADDON_PROPERTIES) {
+ result[prop] = addon[prop];
+ }
+
+ // These properties are computed.
+ result.isEnabled = !addon.userDisabled;
+ result.canUninstall = Boolean(
+ addon.permissions & AddonManager.PERM_CAN_UNINSTALL
+ );
+
+ return result;
+}
+
+/**
+ * Listens for a browser changing origin and cancels the installs that were
+ * started by it.
+ */
+function BrowserListener(aBrowser, aInstallingPrincipal, aInstall) {
+ this.browser = aBrowser;
+ this.messageManager = this.browser.messageManager;
+ this.principal = aInstallingPrincipal;
+ this.install = aInstall;
+
+ aBrowser.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION);
+ Services.obs.addObserver(this, "message-manager-close", true);
+
+ aInstall.addListener(this);
+
+ this.registered = true;
+}
+
+BrowserListener.prototype = {
+ browser: null,
+ install: null,
+ registered: false,
+
+ unregister() {
+ if (!this.registered) {
+ return;
+ }
+ this.registered = false;
+
+ Services.obs.removeObserver(this, "message-manager-close");
+ // The browser may have already been detached
+ if (this.browser.removeProgressListener) {
+ this.browser.removeProgressListener(this);
+ }
+
+ this.install.removeListener(this);
+ this.install = null;
+ },
+
+ cancelInstall() {
+ try {
+ this.install.cancel();
+ } catch (e) {
+ // install may have already failed or been cancelled, ignore these
+ }
+ },
+
+ observe(subject, topic, data) {
+ if (subject != this.messageManager) {
+ return;
+ }
+
+ // The browser's message manager has closed and so the browser is
+ // going away, cancel the install
+ this.cancelInstall();
+ },
+
+ onLocationChange(webProgress, request, location) {
+ if (
+ this.browser.contentPrincipal &&
+ this.principal.subsumes(this.browser.contentPrincipal)
+ ) {
+ return;
+ }
+
+ // The browser has navigated to a new origin so cancel the install
+ this.cancelInstall();
+ },
+
+ onDownloadCancelled(install) {
+ this.unregister();
+ },
+
+ onDownloadFailed(install) {
+ this.unregister();
+ },
+
+ onInstallFailed(install) {
+ this.unregister();
+ },
+
+ onInstallEnded(install) {
+ this.unregister();
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsISupportsWeakReference",
+ "nsIWebProgressListener",
+ "nsIObserver",
+ ]),
+};
+
+/**
+ * This represents an author of an add-on (e.g. creator or developer)
+ *
+ * @param aName
+ * The name of the author
+ * @param aURL
+ * The URL of the author's profile page
+ */
+function AddonAuthor(aName, aURL) {
+ this.name = aName;
+ this.url = aURL;
+}
+
+AddonAuthor.prototype = {
+ name: null,
+ url: null,
+
+ // Returns the author's name, defaulting to the empty string
+ toString() {
+ return this.name || "";
+ },
+};
+
+/**
+ * This represents an screenshot for an add-on
+ *
+ * @param aURL
+ * The URL to the full version of the screenshot
+ * @param aWidth
+ * The width in pixels of the screenshot
+ * @param aHeight
+ * The height in pixels of the screenshot
+ * @param aThumbnailURL
+ * The URL to the thumbnail version of the screenshot
+ * @param aThumbnailWidth
+ * The width in pixels of the thumbnail version of the screenshot
+ * @param aThumbnailHeight
+ * The height in pixels of the thumbnail version of the screenshot
+ * @param aCaption
+ * The caption of the screenshot
+ */
+function AddonScreenshot(
+ aURL,
+ aWidth,
+ aHeight,
+ aThumbnailURL,
+ aThumbnailWidth,
+ aThumbnailHeight,
+ aCaption
+) {
+ this.url = aURL;
+ if (aWidth) {
+ this.width = aWidth;
+ }
+ if (aHeight) {
+ this.height = aHeight;
+ }
+ if (aThumbnailURL) {
+ this.thumbnailURL = aThumbnailURL;
+ }
+ if (aThumbnailWidth) {
+ this.thumbnailWidth = aThumbnailWidth;
+ }
+ if (aThumbnailHeight) {
+ this.thumbnailHeight = aThumbnailHeight;
+ }
+ if (aCaption) {
+ this.caption = aCaption;
+ }
+}
+
+AddonScreenshot.prototype = {
+ url: null,
+ width: null,
+ height: null,
+ thumbnailURL: null,
+ thumbnailWidth: null,
+ thumbnailHeight: null,
+ caption: null,
+
+ // Returns the screenshot URL, defaulting to the empty string
+ toString() {
+ return this.url || "";
+ },
+};
+
+var gStarted = false;
+var gStartedPromise = PromiseUtils.defer();
+var gStartupComplete = false;
+var gCheckCompatibility = true;
+var gStrictCompatibility = true;
+var gCheckUpdateSecurityDefault = true;
+var gCheckUpdateSecurity = gCheckUpdateSecurityDefault;
+var gUpdateEnabled = true;
+var gAutoUpdateDefault = true;
+var gWebExtensionsMinPlatformVersion = "";
+var gFinalShutdownBarrier = null;
+var gBeforeShutdownBarrier = null;
+var gRepoShutdownState = "";
+var gShutdownInProgress = false;
+var gBrowserUpdated = null;
+
+export var AMTelemetry;
+export var AMRemoteSettings;
+
+/**
+ * This is the real manager, kept here rather than in AddonManager to keep its
+ * contents hidden from API users.
+ * @class
+ * @lends AddonManager
+ */
+var AddonManagerInternal = {
+ managerListeners: new Set(),
+ installListeners: new Set(),
+ addonListeners: new Set(),
+ pendingProviders: new Set(),
+ providers: new Set(),
+ providerShutdowns: new Map(),
+ typesByProvider: new Map(),
+ startupChanges: {},
+ // Store telemetry details per addon provider
+ telemetryDetails: {},
+ upgradeListeners: new Map(),
+ externalExtensionLoaders: new Map(),
+
+ recordTimestamp(name, value) {
+ lazy.TelemetryTimestamps.add(name, value);
+ },
+
+ /**
+ * Start up a provider, and register its shutdown hook if it has one
+ *
+ * @param {string} aProvider - An add-on provider.
+ * @param {boolean} aAppChanged - Whether or not the app version has changed since last session.
+ * @param {string} aOldAppVersion - Previous application version, if changed.
+ * @param {string} aOldPlatformVersion - Previous platform version, if changed.
+ *
+ * @private
+ */
+ _startProvider(aProvider, aAppChanged, aOldAppVersion, aOldPlatformVersion) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ logger.debug(`Starting provider: ${providerName(aProvider)}`);
+ callProvider(
+ aProvider,
+ "startup",
+ null,
+ aAppChanged,
+ aOldAppVersion,
+ aOldPlatformVersion
+ );
+ if ("shutdown" in aProvider) {
+ let name = providerName(aProvider);
+ let AMProviderShutdown = () => {
+ // If the provider has been unregistered, it will have been removed from
+ // this.providers. If it hasn't been unregistered, then this is a normal
+ // shutdown - and we move it to this.pendingProviders in case we're
+ // running in a test that will start AddonManager again.
+ if (this.providers.has(aProvider)) {
+ this.providers.delete(aProvider);
+ this.pendingProviders.add(aProvider);
+ }
+
+ return new Promise((resolve, reject) => {
+ logger.debug("Calling shutdown blocker for " + name);
+ resolve(aProvider.shutdown());
+ }).catch(err => {
+ logger.warn("Failure during shutdown of " + name, err);
+ AddonManagerPrivate.recordException(
+ "AMI",
+ "Async shutdown of " + name,
+ err
+ );
+ });
+ };
+ logger.debug("Registering shutdown blocker for " + name);
+ this.providerShutdowns.set(aProvider, AMProviderShutdown);
+ AddonManagerPrivate.finalShutdown.addBlocker(name, AMProviderShutdown);
+ }
+
+ this.pendingProviders.delete(aProvider);
+ this.providers.add(aProvider);
+ logger.debug(`Provider finished startup: ${providerName(aProvider)}`);
+ },
+
+ _getProviderByName(aName) {
+ for (let provider of this.providers) {
+ if (providerName(provider) == aName) {
+ return provider;
+ }
+ }
+ return undefined;
+ },
+
+ /**
+ * Initializes the AddonManager, loading any known providers and initializing
+ * them.
+ */
+ startup() {
+ try {
+ if (gStarted) {
+ return;
+ }
+
+ this.recordTimestamp("AMI_startup_begin");
+
+ // Enable the addonsManager telemetry event category.
+ AMTelemetry.init();
+
+ // Enable the AMRemoteSettings client.
+ AMRemoteSettings.init();
+
+ // clear this for xpcshell test restarts
+ for (let provider in this.telemetryDetails) {
+ delete this.telemetryDetails[provider];
+ }
+
+ let appChanged = undefined;
+
+ let oldAppVersion = null;
+ try {
+ oldAppVersion = Services.prefs.getCharPref(PREF_EM_LAST_APP_VERSION);
+ appChanged = Services.appinfo.version != oldAppVersion;
+ } catch (e) {}
+
+ gBrowserUpdated = appChanged;
+
+ let oldPlatformVersion = Services.prefs.getCharPref(
+ PREF_EM_LAST_PLATFORM_VERSION,
+ ""
+ );
+
+ if (appChanged !== false) {
+ logger.debug("Application has been upgraded");
+ Services.prefs.setCharPref(
+ PREF_EM_LAST_APP_VERSION,
+ Services.appinfo.version
+ );
+ Services.prefs.setCharPref(
+ PREF_EM_LAST_PLATFORM_VERSION,
+ Services.appinfo.platformVersion
+ );
+ Services.prefs.setIntPref(
+ PREF_BLOCKLIST_PINGCOUNTVERSION,
+ appChanged === undefined ? 0 : -1
+ );
+ }
+
+ if (!MOZ_COMPATIBILITY_NIGHTLY) {
+ PREF_EM_CHECK_COMPATIBILITY =
+ PREF_EM_CHECK_COMPATIBILITY_BASE +
+ "." +
+ Services.appinfo.version.replace(BRANCH_REGEXP, "$1");
+ }
+
+ gCheckCompatibility = Services.prefs.getBoolPref(
+ PREF_EM_CHECK_COMPATIBILITY,
+ gCheckCompatibility
+ );
+ Services.prefs.addObserver(PREF_EM_CHECK_COMPATIBILITY, this);
+
+ gStrictCompatibility = Services.prefs.getBoolPref(
+ PREF_EM_STRICT_COMPATIBILITY,
+ gStrictCompatibility
+ );
+ Services.prefs.addObserver(PREF_EM_STRICT_COMPATIBILITY, this);
+
+ let defaultBranch = Services.prefs.getDefaultBranch("");
+ gCheckUpdateSecurityDefault = defaultBranch.getBoolPref(
+ PREF_EM_CHECK_UPDATE_SECURITY,
+ gCheckUpdateSecurityDefault
+ );
+
+ gCheckUpdateSecurity = Services.prefs.getBoolPref(
+ PREF_EM_CHECK_UPDATE_SECURITY,
+ gCheckUpdateSecurity
+ );
+ Services.prefs.addObserver(PREF_EM_CHECK_UPDATE_SECURITY, this);
+
+ gUpdateEnabled = Services.prefs.getBoolPref(
+ PREF_EM_UPDATE_ENABLED,
+ gUpdateEnabled
+ );
+ Services.prefs.addObserver(PREF_EM_UPDATE_ENABLED, this);
+
+ gAutoUpdateDefault = Services.prefs.getBoolPref(
+ PREF_EM_AUTOUPDATE_DEFAULT,
+ gAutoUpdateDefault
+ );
+ Services.prefs.addObserver(PREF_EM_AUTOUPDATE_DEFAULT, this);
+
+ gWebExtensionsMinPlatformVersion = Services.prefs.getCharPref(
+ PREF_MIN_WEBEXT_PLATFORM_VERSION,
+ gWebExtensionsMinPlatformVersion
+ );
+ Services.prefs.addObserver(PREF_MIN_WEBEXT_PLATFORM_VERSION, this);
+
+ // Watch for changes to PREF_REMOTESETTINGS_DISABLED.
+ Services.prefs.addObserver(PREF_REMOTESETTINGS_DISABLED, this);
+
+ // Watch for language changes, refresh the addon cache when it changes.
+ Services.obs.addObserver(this, INTL_LOCALES_CHANGED);
+
+ // Ensure all default providers have had a chance to register themselves
+ ({ XPIProvider: gXPIProvider } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIProvider.jsm"
+ ));
+
+ // Load any providers registered in the category manager
+ for (let { entry, value: url } of Services.catMan.enumerateCategory(
+ CATEGORY_PROVIDER_MODULE
+ )) {
+ try {
+ ChromeUtils.importESModule(url);
+ logger.debug(`Loaded provider scope for ${url}`);
+ } catch (e) {
+ AddonManagerPrivate.recordException(
+ "AMI",
+ "provider " + url + " load failed",
+ e
+ );
+ logger.error(
+ "Exception loading provider " +
+ entry +
+ ' from category "' +
+ url +
+ '"',
+ e
+ );
+ }
+ }
+
+ // Register our shutdown handler with the AsyncShutdown manager
+ gBeforeShutdownBarrier = new AsyncShutdown.Barrier(
+ "AddonManager: Waiting to start provider shutdown."
+ );
+ gFinalShutdownBarrier = new AsyncShutdown.Barrier(
+ "AddonManager: Waiting for providers to shut down."
+ );
+ AsyncShutdown.profileBeforeChange.addBlocker(
+ "AddonManager: shutting down.",
+ this.shutdownManager.bind(this),
+ { fetchState: this.shutdownState.bind(this) }
+ );
+
+ // Once we start calling providers we must allow all normal methods to work.
+ gStarted = true;
+
+ for (let provider of this.pendingProviders) {
+ this._startProvider(
+ provider,
+ appChanged,
+ oldAppVersion,
+ oldPlatformVersion
+ );
+ }
+
+ // If this is a new profile just pretend that there were no changes
+ if (appChanged === undefined) {
+ for (let type in this.startupChanges) {
+ delete this.startupChanges[type];
+ }
+ }
+
+ gStartupComplete = true;
+ gStartedPromise.resolve();
+ this.recordTimestamp("AMI_startup_end");
+ } catch (e) {
+ logger.error("startup failed", e);
+ AddonManagerPrivate.recordException("AMI", "startup failed", e);
+ gStartedPromise.reject("startup failed");
+ }
+
+ // Disable the quarantined domains feature if the system add-on has been
+ // disabled in a previous version.
+ if (
+ Services.prefs.getBoolPref(
+ "extensions.webextensions.addons-restricted-domains@mozilla.com.disabled",
+ false
+ )
+ ) {
+ Services.prefs.setBoolPref(
+ "extensions.quarantinedDomains.enabled",
+ false
+ );
+ logger.debug(
+ "Disabled quarantined domains because the system add-on was disabled"
+ );
+ }
+
+ logger.debug("Completed startup sequence");
+ this.callManagerListeners("onStartup");
+ },
+
+ /**
+ * Registers a new AddonProvider.
+ *
+ * @param {string} aProvider -The provider to register
+ * @param {string[]} [aTypes] - An optional array of add-on types
+ */
+ registerProvider(aProvider, aTypes) {
+ if (!aProvider || typeof aProvider != "object") {
+ throw Components.Exception(
+ "aProvider must be specified",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aTypes && !Array.isArray(aTypes)) {
+ throw Components.Exception(
+ "aTypes must be an array or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ this.pendingProviders.add(aProvider);
+
+ if (aTypes) {
+ this.typesByProvider.set(aProvider, new Set(aTypes));
+ }
+
+ // If we're registering after startup call this provider's startup.
+ if (gStarted) {
+ this._startProvider(aProvider);
+ }
+ },
+
+ /**
+ * Unregisters an AddonProvider.
+ *
+ * @param aProvider
+ * The provider to unregister
+ * @return Whatever the provider's 'shutdown' method returns (if anything).
+ * For providers that have async shutdown methods returning Promises,
+ * the caller should wait for that Promise to resolve.
+ */
+ unregisterProvider(aProvider) {
+ if (!aProvider || typeof aProvider != "object") {
+ throw Components.Exception(
+ "aProvider must be specified",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ this.providers.delete(aProvider);
+ // The test harness will unregister XPIProvider *after* shutdown, which is
+ // after the provider will have been moved from providers to
+ // pendingProviders.
+ this.pendingProviders.delete(aProvider);
+
+ this.typesByProvider.delete(aProvider);
+
+ // If we're unregistering after startup but before shutting down,
+ // remove the blocker for this provider's shutdown and call it.
+ // If we're already shutting down, just let gFinalShutdownBarrier
+ // call it to avoid races.
+ if (gStarted && !gShutdownInProgress) {
+ logger.debug(
+ "Unregistering shutdown blocker for " + providerName(aProvider)
+ );
+ let shutter = this.providerShutdowns.get(aProvider);
+ if (shutter) {
+ this.providerShutdowns.delete(aProvider);
+ gFinalShutdownBarrier.client.removeBlocker(shutter);
+ return shutter();
+ }
+ }
+ return undefined;
+ },
+
+ /**
+ * Mark a provider as safe to access via AddonManager APIs, before its
+ * startup has completed.
+ *
+ * Normally a provider isn't marked as safe until after its (synchronous)
+ * startup() method has returned. Until a provider has been marked safe,
+ * it won't be used by any of the AddonManager APIs. markProviderSafe()
+ * allows a provider to mark itself as safe during its startup; this can be
+ * useful if the provider wants to perform tasks that block startup, which
+ * happen after its required initialization tasks and therefore when the
+ * provider is in a safe state.
+ *
+ * @param aProvider Provider object to mark safe
+ */
+ markProviderSafe(aProvider) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aProvider || typeof aProvider != "object") {
+ throw Components.Exception(
+ "aProvider must be specified",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (!this.pendingProviders.has(aProvider)) {
+ return;
+ }
+
+ this.pendingProviders.delete(aProvider);
+ this.providers.add(aProvider);
+ },
+
+ /**
+ * Calls a method on all registered providers if it exists and consumes any
+ * thrown exception. Return values are ignored. Any parameters after the
+ * method parameter are passed to the provider's method.
+ * WARNING: Do not use for asynchronous calls; callProviders() does not
+ * invoke callbacks if provider methods throw synchronous exceptions.
+ *
+ * @param aMethod
+ * The method name to call
+ */
+ callProviders(aMethod, ...aArgs) {
+ if (!aMethod || typeof aMethod != "string") {
+ throw Components.Exception(
+ "aMethod must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let providers = [...this.providers];
+ for (let provider of providers) {
+ try {
+ if (aMethod in provider) {
+ provider[aMethod].apply(provider, aArgs);
+ }
+ } catch (e) {
+ reportProviderError(provider, aMethod, e);
+ }
+ }
+ },
+
+ /**
+ * Report the current state of asynchronous shutdown
+ */
+ shutdownState() {
+ let state = [];
+ for (let barrier of [gBeforeShutdownBarrier, gFinalShutdownBarrier]) {
+ if (barrier) {
+ state.push({ name: barrier.client.name, state: barrier.state });
+ }
+ }
+ state.push({
+ name: "AddonRepository: async shutdown",
+ state: gRepoShutdownState,
+ });
+ return state;
+ },
+
+ /**
+ * Shuts down the addon manager and all registered providers, this must clean
+ * up everything in order for automated tests to fake restarts.
+ * @return Promise{null} that resolves when all providers and dependent modules
+ * have finished shutting down
+ */
+ async shutdownManager() {
+ logger.debug("before shutdown");
+ try {
+ await gBeforeShutdownBarrier.wait();
+ } catch (e) {
+ Cu.reportError(e);
+ }
+
+ logger.debug("shutdown");
+ this.callManagerListeners("onShutdown");
+
+ if (!gStartupComplete) {
+ gStartedPromise.reject("shutting down");
+ }
+
+ gRepoShutdownState = "pending";
+ gShutdownInProgress = true;
+
+ // Clean up listeners
+ Services.prefs.removeObserver(PREF_EM_CHECK_COMPATIBILITY, this);
+ Services.prefs.removeObserver(PREF_EM_STRICT_COMPATIBILITY, this);
+ Services.prefs.removeObserver(PREF_EM_CHECK_UPDATE_SECURITY, this);
+ Services.prefs.removeObserver(PREF_EM_UPDATE_ENABLED, this);
+ Services.prefs.removeObserver(PREF_EM_AUTOUPDATE_DEFAULT, this);
+ Services.prefs.removeObserver(PREF_REMOTESETTINGS_DISABLED, this);
+
+ Services.obs.removeObserver(this, INTL_LOCALES_CHANGED);
+
+ AMRemoteSettings.shutdown();
+
+ let savedError = null;
+ // Only shut down providers if they've been started.
+ if (gStarted) {
+ try {
+ await gFinalShutdownBarrier.wait();
+ } catch (err) {
+ savedError = err;
+ logger.error("Failure during wait for shutdown barrier", err);
+ AddonManagerPrivate.recordException(
+ "AMI",
+ "Async shutdown of AddonManager providers",
+ err
+ );
+ }
+ }
+ gXPIProvider = null;
+
+ // Shut down AddonRepository after providers (if any).
+ try {
+ gRepoShutdownState = "in progress";
+ await lazy.AddonRepository.shutdown();
+ gRepoShutdownState = "done";
+ } catch (err) {
+ savedError = err;
+ logger.error("Failure during AddonRepository shutdown", err);
+ AddonManagerPrivate.recordException(
+ "AMI",
+ "Async shutdown of AddonRepository",
+ err
+ );
+ }
+
+ logger.debug("Async provider shutdown done");
+ this.managerListeners.clear();
+ this.installListeners.clear();
+ this.addonListeners.clear();
+ this.providerShutdowns.clear();
+ for (let type in this.startupChanges) {
+ delete this.startupChanges[type];
+ }
+ gStarted = false;
+ gStartedPromise = PromiseUtils.defer();
+ gStartupComplete = false;
+ gFinalShutdownBarrier = null;
+ gBeforeShutdownBarrier = null;
+ gShutdownInProgress = false;
+ if (savedError) {
+ throw savedError;
+ }
+ },
+
+ /**
+ * Notified when a preference we're interested in has changed.
+ */
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case INTL_LOCALES_CHANGED: {
+ // Asynchronously fetch and update the addons cache.
+ lazy.AddonRepository.backgroundUpdateCheck();
+ return;
+ }
+ }
+
+ switch (aData) {
+ case PREF_EM_CHECK_COMPATIBILITY: {
+ let oldValue = gCheckCompatibility;
+ gCheckCompatibility = Services.prefs.getBoolPref(
+ PREF_EM_CHECK_COMPATIBILITY,
+ true
+ );
+
+ this.callManagerListeners("onCompatibilityModeChanged");
+
+ if (gCheckCompatibility != oldValue) {
+ this.updateAddonAppDisabledStates();
+ }
+
+ break;
+ }
+ case PREF_EM_STRICT_COMPATIBILITY: {
+ let oldValue = gStrictCompatibility;
+ gStrictCompatibility = Services.prefs.getBoolPref(
+ PREF_EM_STRICT_COMPATIBILITY,
+ true
+ );
+
+ this.callManagerListeners("onCompatibilityModeChanged");
+
+ if (gStrictCompatibility != oldValue) {
+ this.updateAddonAppDisabledStates();
+ }
+
+ break;
+ }
+ case PREF_EM_CHECK_UPDATE_SECURITY: {
+ let oldValue = gCheckUpdateSecurity;
+ gCheckUpdateSecurity = Services.prefs.getBoolPref(
+ PREF_EM_CHECK_UPDATE_SECURITY,
+ true
+ );
+
+ this.callManagerListeners("onCheckUpdateSecurityChanged");
+
+ if (gCheckUpdateSecurity != oldValue) {
+ this.updateAddonAppDisabledStates();
+ }
+
+ break;
+ }
+ case PREF_EM_UPDATE_ENABLED: {
+ gUpdateEnabled = Services.prefs.getBoolPref(
+ PREF_EM_UPDATE_ENABLED,
+ true
+ );
+
+ this.callManagerListeners("onUpdateModeChanged");
+ break;
+ }
+ case PREF_EM_AUTOUPDATE_DEFAULT: {
+ gAutoUpdateDefault = Services.prefs.getBoolPref(
+ PREF_EM_AUTOUPDATE_DEFAULT,
+ true
+ );
+
+ this.callManagerListeners("onUpdateModeChanged");
+ break;
+ }
+ case PREF_MIN_WEBEXT_PLATFORM_VERSION: {
+ gWebExtensionsMinPlatformVersion = Services.prefs.getCharPref(
+ PREF_MIN_WEBEXT_PLATFORM_VERSION
+ );
+ break;
+ }
+ case PREF_REMOTESETTINGS_DISABLED: {
+ if (Services.prefs.getBoolPref(PREF_REMOTESETTINGS_DISABLED, false)) {
+ AMRemoteSettings.shutdown();
+ } else {
+ AMRemoteSettings.init();
+ }
+ break;
+ }
+ }
+ },
+
+ /**
+ * Replaces %...% strings in an addon url (update and updateInfo) with
+ * appropriate values.
+ *
+ * @param aAddon
+ * The Addon representing the add-on
+ * @param aUri
+ * The string representation of the URI to escape
+ * @param aAppVersion
+ * The optional application version to use for %APP_VERSION%
+ * @return The appropriately escaped URI.
+ */
+ escapeAddonURI(aAddon, aUri, aAppVersion) {
+ if (!aAddon || typeof aAddon != "object") {
+ throw Components.Exception(
+ "aAddon must be an Addon object",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (!aUri || typeof aUri != "string") {
+ throw Components.Exception(
+ "aUri must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aAppVersion && typeof aAppVersion != "string") {
+ throw Components.Exception(
+ "aAppVersion must be a string or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ var addonStatus =
+ aAddon.userDisabled || aAddon.softDisabled
+ ? "userDisabled"
+ : "userEnabled";
+
+ if (!aAddon.isCompatible) {
+ addonStatus += ",incompatible";
+ }
+
+ let { blocklistState } = aAddon;
+ if (blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED) {
+ addonStatus += ",blocklisted";
+ }
+ if (blocklistState == Ci.nsIBlocklistService.STATE_SOFTBLOCKED) {
+ addonStatus += ",softblocked";
+ }
+
+ let params = new Map(
+ Object.entries({
+ ITEM_ID: aAddon.id,
+ ITEM_VERSION: aAddon.version,
+ ITEM_STATUS: addonStatus,
+ APP_ID: Services.appinfo.ID,
+ APP_VERSION: aAppVersion ? aAppVersion : Services.appinfo.version,
+ REQ_VERSION: UPDATE_REQUEST_VERSION,
+ APP_OS: Services.appinfo.OS,
+ APP_ABI: Services.appinfo.XPCOMABI,
+ APP_LOCALE: getLocale(),
+ CURRENT_APP_VERSION: Services.appinfo.version,
+ })
+ );
+
+ let uri = aUri.replace(/%([A-Z_]+)%/g, (m0, m1) => params.get(m1) || m0);
+
+ // escape() does not properly encode + symbols in any embedded FVF strings.
+ return uri.replace(/\+/g, "%2B");
+ },
+
+ _updatePromptHandler(info) {
+ let oldPerms = info.existingAddon.userPermissions;
+ if (!oldPerms) {
+ // Updating from a legacy add-on, just let it proceed
+ return Promise.resolve();
+ }
+
+ let newPerms = info.addon.userPermissions;
+
+ let difference = lazy.Extension.comparePermissions(oldPerms, newPerms);
+
+ // If there are no new permissions, just go ahead with the update
+ if (!difference.origins.length && !difference.permissions.length) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve, reject) => {
+ let subject = {
+ wrappedJSObject: {
+ addon: info.addon,
+ permissions: difference,
+ resolve,
+ reject,
+ // Reference to the related AddonInstall object (used in AMTelemetry to
+ // link the recorded event to the other events from the same install flow).
+ install: info.install,
+ },
+ };
+ Services.obs.notifyObservers(subject, "webextension-update-permissions");
+ });
+ },
+
+ // Returns true if System Addons should be updated
+ systemUpdateEnabled() {
+ if (!Services.prefs.getBoolPref(PREF_SYS_ADDON_UPDATE_ENABLED)) {
+ return false;
+ }
+ if (Services.policies && !Services.policies.isAllowed("SysAddonUpdate")) {
+ return false;
+ }
+ return true;
+ },
+
+ /**
+ * Performs a background update check by starting an update for all add-ons
+ * that can be updated.
+ * @return Promise{null} Resolves when the background update check is complete
+ * (the resulting addon installations may still be in progress).
+ */
+ backgroundUpdateCheck() {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ let buPromise = (async () => {
+ logger.debug("Background update check beginning");
+
+ Services.obs.notifyObservers(null, "addons-background-update-start");
+
+ if (this.updateEnabled) {
+ // Keep track of all the async add-on updates happening in parallel
+ let updates = [];
+
+ let allAddons = await this.getAllAddons();
+
+ // Repopulate repository cache first, to ensure compatibility overrides
+ // are up to date before checking for addon updates.
+ await lazy.AddonRepository.backgroundUpdateCheck();
+
+ for (let addon of allAddons) {
+ // Check all add-ons for updates so that any compatibility updates will
+ // be applied
+
+ if (!(addon.permissions & AddonManager.PERM_CAN_UPGRADE)) {
+ continue;
+ }
+
+ updates.push(
+ new Promise((resolve, reject) => {
+ addon.findUpdates(
+ {
+ onUpdateAvailable(aAddon, aInstall) {
+ // Start installing updates when the add-on can be updated and
+ // background updates should be applied.
+ logger.debug("Found update for add-on ${id}", aAddon);
+ if (AddonManager.shouldAutoUpdate(aAddon)) {
+ // XXX we really should resolve when this install is done,
+ // not when update-available check completes, no?
+ logger.debug(`Starting upgrade install of ${aAddon.id}`);
+ aInstall.promptHandler = (...args) =>
+ AddonManagerInternal._updatePromptHandler(...args);
+ aInstall.install();
+ }
+ },
+
+ onUpdateFinished: aAddon => {
+ logger.debug("onUpdateFinished for ${id}", aAddon);
+ resolve();
+ },
+ },
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ })
+ );
+ }
+ Services.obs.notifyObservers(
+ null,
+ "addons-background-updates-found",
+ updates.length
+ );
+ await Promise.all(updates);
+ }
+
+ if (AddonManagerInternal.systemUpdateEnabled()) {
+ try {
+ await AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).updateSystemAddons();
+ } catch (e) {
+ logger.warn("Failed to update system addons", e);
+ }
+ }
+
+ logger.debug("Background update check complete");
+ Services.obs.notifyObservers(null, "addons-background-update-complete");
+ })();
+ // Fork the promise chain so we can log the error and let our caller see it too.
+ buPromise.catch(e => logger.warn("Error in background update", e));
+ return buPromise;
+ },
+
+ /**
+ * Adds a add-on to the list of detected changes for this startup. If
+ * addStartupChange is called multiple times for the same add-on in the same
+ * startup then only the most recent change will be remembered.
+ *
+ * @param aType
+ * The type of change as a string. Providers can define their own
+ * types of changes or use the existing defined STARTUP_CHANGE_*
+ * constants
+ * @param aID
+ * The ID of the add-on
+ */
+ addStartupChange(aType, aID) {
+ if (!aType || typeof aType != "string") {
+ throw Components.Exception(
+ "aType must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (!aID || typeof aID != "string") {
+ throw Components.Exception(
+ "aID must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (gStartupComplete) {
+ return;
+ }
+ logger.debug("Registering startup change '" + aType + "' for " + aID);
+
+ // Ensure that an ID is only listed in one type of change
+ for (let type in this.startupChanges) {
+ this.removeStartupChange(type, aID);
+ }
+
+ if (!(aType in this.startupChanges)) {
+ this.startupChanges[aType] = [];
+ }
+ this.startupChanges[aType].push(aID);
+ },
+
+ /**
+ * Removes a startup change for an add-on.
+ *
+ * @param aType
+ * The type of change
+ * @param aID
+ * The ID of the add-on
+ */
+ removeStartupChange(aType, aID) {
+ if (!aType || typeof aType != "string") {
+ throw Components.Exception(
+ "aType must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (!aID || typeof aID != "string") {
+ throw Components.Exception(
+ "aID must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (gStartupComplete) {
+ return;
+ }
+
+ if (!(aType in this.startupChanges)) {
+ return;
+ }
+
+ this.startupChanges[aType] = this.startupChanges[aType].filter(
+ aItem => aItem != aID
+ );
+ },
+
+ /**
+ * Calls all registered AddonManagerListeners with an event. Any parameters
+ * after the method parameter are passed to the listener.
+ *
+ * @param aMethod
+ * The method on the listeners to call
+ */
+ callManagerListeners(aMethod, ...aArgs) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aMethod || typeof aMethod != "string") {
+ throw Components.Exception(
+ "aMethod must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let managerListeners = new Set(this.managerListeners);
+ for (let listener of managerListeners) {
+ try {
+ if (aMethod in listener) {
+ listener[aMethod].apply(listener, aArgs);
+ }
+ } catch (e) {
+ logger.warn(
+ "AddonManagerListener threw exception when calling " + aMethod,
+ e
+ );
+ }
+ }
+ },
+
+ /**
+ * Calls all registered InstallListeners with an event. Any parameters after
+ * the extraListeners parameter are passed to the listener.
+ *
+ * @param aMethod
+ * The method on the listeners to call
+ * @param aExtraListeners
+ * An optional array of extra InstallListeners to also call
+ * @return false if any of the listeners returned false, true otherwise
+ */
+ callInstallListeners(aMethod, aExtraListeners, ...aArgs) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aMethod || typeof aMethod != "string") {
+ throw Components.Exception(
+ "aMethod must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aExtraListeners && !Array.isArray(aExtraListeners)) {
+ throw Components.Exception(
+ "aExtraListeners must be an array or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let result = true;
+ let listeners;
+ if (aExtraListeners) {
+ listeners = new Set(
+ aExtraListeners.concat(Array.from(this.installListeners))
+ );
+ } else {
+ listeners = new Set(this.installListeners);
+ }
+
+ for (let listener of listeners) {
+ try {
+ if (aMethod in listener) {
+ if (listener[aMethod].apply(listener, aArgs) === false) {
+ result = false;
+ }
+ }
+ } catch (e) {
+ logger.warn(
+ "InstallListener threw exception when calling " + aMethod,
+ e
+ );
+ }
+ }
+ return result;
+ },
+
+ /**
+ * Calls all registered AddonListeners with an event. Any parameters after
+ * the method parameter are passed to the listener.
+ *
+ * @param aMethod
+ * The method on the listeners to call
+ */
+ callAddonListeners(aMethod, ...aArgs) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aMethod || typeof aMethod != "string") {
+ throw Components.Exception(
+ "aMethod must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let addonListeners = new Set(this.addonListeners);
+ for (let listener of addonListeners) {
+ try {
+ if (aMethod in listener) {
+ listener[aMethod].apply(listener, aArgs);
+ }
+ } catch (e) {
+ logger.warn("AddonListener threw exception when calling " + aMethod, e);
+ }
+ }
+ },
+
+ /**
+ * Notifies all providers that an add-on has been enabled when that type of
+ * add-on only supports a single add-on being enabled at a time. This allows
+ * the providers to disable theirs if necessary.
+ *
+ * @param aID
+ * The ID of the enabled add-on
+ * @param aType
+ * The type of the enabled add-on
+ * @param aPendingRestart
+ * A boolean indicating if the change will only take place the next
+ * time the application is restarted
+ */
+ async notifyAddonChanged(aID, aType, aPendingRestart) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (aID && typeof aID != "string") {
+ throw Components.Exception(
+ "aID must be a string or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (!aType || typeof aType != "string") {
+ throw Components.Exception(
+ "aType must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ // Temporary hack until bug 520124 lands.
+ // We can get here during synchronous startup, at which point it's
+ // considered unsafe (and therefore disallowed by AddonManager.jsm) to
+ // access providers that haven't been initialized yet. Since this is when
+ // XPIProvider is starting up, XPIProvider can't access itself via APIs
+ // going through AddonManager.jsm. Thankfully, this is the only use
+ // of this API, and we know it's safe to use this API with both
+ // providers; so we have this hack to allow bypassing the normal
+ // safetey guard.
+ // The notifyAddonChanged/addonChanged API will be unneeded and therefore
+ // removed by bug 520124, so this is a temporary quick'n'dirty hack.
+ let providers = [...this.providers, ...this.pendingProviders];
+ for (let provider of providers) {
+ let result = callProvider(
+ provider,
+ "addonChanged",
+ null,
+ aID,
+ aType,
+ aPendingRestart
+ );
+ if (result) {
+ await result;
+ }
+ }
+ },
+
+ /**
+ * Notifies all providers they need to update the appDisabled property for
+ * their add-ons in response to an application change such as a blocklist
+ * update.
+ */
+ updateAddonAppDisabledStates() {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ this.callProviders("updateAddonAppDisabledStates");
+ },
+
+ /**
+ * Notifies all providers that the repository has updated its data for
+ * installed add-ons.
+ */
+ updateAddonRepositoryData() {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ return (async () => {
+ for (let provider of this.providers) {
+ await promiseCallProvider(provider, "updateAddonRepositoryData");
+ }
+
+ // only tests should care about this
+ Services.obs.notifyObservers(null, "TEST:addon-repository-data-updated");
+ })();
+ },
+
+ /**
+ * Asynchronously gets an AddonInstall for a URL.
+ *
+ * @param aUrl
+ * The string represenation of the URL where the add-on is located
+ * @param {Object} [aOptions = {}]
+ * Additional options for this install
+ * @param {string} [aOptions.hash]
+ * An optional hash of the add-on
+ * @param {string} [aOptions.name]
+ * An optional placeholder name while the add-on is being downloaded
+ * @param {string|Object} [aOptions.icons]
+ * Optional placeholder icons while the add-on is being downloaded
+ * @param {string} [aOptions.version]
+ * An optional placeholder version while the add-on is being downloaded
+ * @param {XULElement} [aOptions.browser]
+ * An optional <browser> element for download permissions prompts.
+ * @param {nsIPrincipal} [aOptions.triggeringPrincipal]
+ * The principal which is attempting to install the add-on.
+ * @param {Object} [aOptions.telemetryInfo]
+ * An optional object which provides details about the installation source
+ * included in the addon manager telemetry events.
+ * @throws if aUrl is not specified or if an optional argument of
+ * an improper type is passed.
+ */
+ async getInstallForURL(aUrl, aOptions = {}) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aUrl || typeof aUrl != "string") {
+ throw Components.Exception(
+ "aURL must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aOptions.hash && typeof aOptions.hash != "string") {
+ throw Components.Exception(
+ "hash must be a string or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aOptions.name && typeof aOptions.name != "string") {
+ throw Components.Exception(
+ "name must be a string or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aOptions.icons) {
+ if (typeof aOptions.icons == "string") {
+ aOptions.icons = { 32: aOptions.icons };
+ } else if (typeof aOptions.icons != "object") {
+ throw Components.Exception(
+ "icons must be a string, an object or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+ } else {
+ aOptions.icons = {};
+ }
+
+ if (aOptions.version && typeof aOptions.version != "string") {
+ throw Components.Exception(
+ "version must be a string or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aOptions.browser && !Element.isInstance(aOptions.browser)) {
+ throw Components.Exception(
+ "aOptions.browser must be an Element or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ for (let provider of this.providers) {
+ let install = await promiseCallProvider(
+ provider,
+ "getInstallForURL",
+ aUrl,
+ aOptions
+ );
+ if (install) {
+ return install;
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Asynchronously gets an AddonInstall for an nsIFile.
+ *
+ * @param aFile
+ * The nsIFile where the add-on is located
+ * @param aMimetype
+ * An optional mimetype hint for the add-on
+ * @param aTelemetryInfo
+ * An optional object which provides details about the installation source
+ * included in the addon manager telemetry events.
+ * @param aUseSystemLocation
+ * If true the addon is installed into the system profile location.
+ * @throws if the aFile or aCallback arguments are not specified
+ */
+ getInstallForFile(aFile, aMimetype, aTelemetryInfo, aUseSystemLocation) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!(aFile instanceof Ci.nsIFile)) {
+ throw Components.Exception(
+ "aFile must be a nsIFile",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aMimetype && typeof aMimetype != "string") {
+ throw Components.Exception(
+ "aMimetype must be a string or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ return (async () => {
+ for (let provider of this.providers) {
+ let install = await promiseCallProvider(
+ provider,
+ "getInstallForFile",
+ aFile,
+ aTelemetryInfo,
+ aUseSystemLocation
+ );
+
+ if (install) {
+ return install;
+ }
+ }
+
+ return null;
+ })();
+ },
+
+ /**
+ * Get a SitePermsAddonInstall instance.
+ *
+ * @param {Element} aBrowser: The optional browser element that started the install
+ * @param {nsIPrincipal} aInstallingPrincipal
+ * @param {String} aSitePerm
+ * @returns {Promise<SitePermsAddonInstall|null>} The promise will resolve with null if there
+ * are no provider with a getSitePermsAddonInstallForWebpage method. In practice,
+ * this should only be the case when SitePermsAddonProvider is not enabled,
+ * i.e. when dom.sitepermsaddon-provider.enabled is false.
+ * @throws {Components.Exception} Will throw an error if:
+ * - the AddonManager is not initialized
+ * - `aInstallingPrincipal` is not a nsIPrincipal
+ * - `aInstallingPrincipal` scheme is not https
+ * - `aInstallingPrincipal` is a public etld
+ * - `aInstallingPrincipal` is a plain ip address
+ * - `aInstallingPrincipal` is in the blocklist
+ * - `aSitePerm` is not a gated permission
+ * - `aBrowser` is not null and not an element
+ */
+ async getSitePermsAddonInstallForWebpage(
+ aBrowser,
+ aInstallingPrincipal,
+ aSitePerm
+ ) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (
+ !aInstallingPrincipal ||
+ !(aInstallingPrincipal instanceof Ci.nsIPrincipal)
+ ) {
+ throw Components.Exception(
+ "aInstallingPrincipal must be a nsIPrincipal",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aBrowser && !Element.isInstance(aBrowser)) {
+ throw Components.Exception(
+ "aBrowser must be an Element, or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (lazy.isPrincipalInSitePermissionsBlocklist(aInstallingPrincipal)) {
+ throw Components.Exception(
+ `SitePermsAddons can't be installed`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ // Block install from null principal.
+ // /!\ We need to do this check before checking if this is a remote origin iframe,
+ // otherwise isThirdPartyPrincipal might throw.
+ if (aInstallingPrincipal.isNullPrincipal) {
+ throw Components.Exception(
+ `SitePermsAddons can't be installed from sandboxed subframes`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ // Block install from remote origin iframe
+ if (
+ aBrowser &&
+ aBrowser.contentPrincipal.isThirdPartyPrincipal(aInstallingPrincipal)
+ ) {
+ throw Components.Exception(
+ `SitePermsAddons can't be installed from cross origin subframes`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aInstallingPrincipal.isIpAddress) {
+ throw Components.Exception(
+ `SitePermsAddons install disallowed when the host is an IP address`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ // Gated APIs should probably not be available on non-secure origins,
+ // but let's double check here.
+ if (aInstallingPrincipal.scheme !== "https") {
+ throw Components.Exception(
+ `SitePermsAddons can only be installed from secure origins`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ // Install origin cannot be on a known etld (e.g. github.io).
+ if (lazy.isKnownPublicSuffix(aInstallingPrincipal.siteOriginNoSuffix)) {
+ throw Components.Exception(
+ `SitePermsAddon can't be installed from public eTLDs ${aInstallingPrincipal.siteOriginNoSuffix}`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (!lazy.isGatedPermissionType(aSitePerm)) {
+ throw Components.Exception(
+ `"${aSitePerm}" is not a gated permission`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ for (let provider of this.providers) {
+ let install = await promiseCallProvider(
+ provider,
+ "getSitePermsAddonInstallForWebpage",
+ aInstallingPrincipal,
+ aSitePerm
+ );
+ if (install) {
+ return install;
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Uninstall an addon from the system profile location.
+ *
+ * @param {string} aID
+ * The ID of the addon to remove.
+ * @returns A promise that resolves when the addon is uninstalled.
+ */
+ uninstallSystemProfileAddon(aID) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+ return AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).uninstallSystemProfileAddon(aID);
+ },
+
+ /**
+ * Asynchronously gets all current AddonInstalls optionally limiting to a list
+ * of types.
+ *
+ * @param aTypes
+ * An optional array of types to retrieve. Each type is a string name
+ * @throws If the aCallback argument is not specified
+ */
+ getInstallsByTypes(aTypes) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (aTypes && !Array.isArray(aTypes)) {
+ throw Components.Exception(
+ "aTypes must be an array or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ return (async () => {
+ let installs = [];
+
+ for (let provider of this.providers) {
+ let providerInstalls = await promiseCallProvider(
+ provider,
+ "getInstallsByTypes",
+ aTypes
+ );
+
+ if (providerInstalls) {
+ installs.push(...providerInstalls);
+ }
+ }
+
+ return installs;
+ })();
+ },
+
+ /**
+ * Asynchronously gets all current AddonInstalls.
+ */
+ getAllInstalls() {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ return this.getInstallsByTypes(null);
+ },
+
+ /**
+ * Checks whether installation is enabled for a particular mimetype.
+ *
+ * @param aMimetype
+ * The mimetype to check
+ * @return true if installation is enabled for the mimetype
+ */
+ isInstallEnabled(aMimetype) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aMimetype || typeof aMimetype != "string") {
+ throw Components.Exception(
+ "aMimetype must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let providers = [...this.providers];
+ for (let provider of providers) {
+ if (
+ callProvider(provider, "supportsMimetype", false, aMimetype) &&
+ callProvider(provider, "isInstallEnabled")
+ ) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Checks whether a particular source is allowed to install add-ons of a
+ * given mimetype.
+ *
+ * @param aMimetype
+ * The mimetype of the add-on
+ * @param aInstallingPrincipal
+ * The nsIPrincipal that initiated the install
+ * @return true if the source is allowed to install this mimetype
+ */
+ isInstallAllowed(aMimetype, aInstallingPrincipal) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aMimetype || typeof aMimetype != "string") {
+ throw Components.Exception(
+ "aMimetype must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (
+ !aInstallingPrincipal ||
+ !(aInstallingPrincipal instanceof Ci.nsIPrincipal)
+ ) {
+ throw Components.Exception(
+ "aInstallingPrincipal must be a nsIPrincipal",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (
+ this.isInstallAllowedByPolicy(
+ aInstallingPrincipal,
+ null,
+ true /* explicit */
+ )
+ ) {
+ return true;
+ }
+
+ let providers = [...this.providers];
+ for (let provider of providers) {
+ if (
+ callProvider(provider, "supportsMimetype", false, aMimetype) &&
+ callProvider(provider, "isInstallAllowed", null, aInstallingPrincipal)
+ ) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Checks whether a particular source is allowed to install add-ons based
+ * on policy.
+ *
+ * @param aInstallingPrincipal
+ * The nsIPrincipal that initiated the install
+ * @param aInstall
+ * The AddonInstall to be installed
+ * @param explicit
+ * If this is set, we only return true if the source is explicitly
+ * blocked via policy.
+ *
+ * @return boolean
+ * By default, returns true if the source is blocked by policy
+ * or there is no policy.
+ * If explicit is set, only returns true of the source is
+ * blocked by policy, false otherwise. This is needed for
+ * handling inverse cases.
+ */
+ isInstallAllowedByPolicy(aInstallingPrincipal, aInstall, explicit) {
+ if (Services.policies) {
+ let extensionSettings = Services.policies.getExtensionSettings("*");
+ if (extensionSettings && extensionSettings.install_sources) {
+ if (
+ (!aInstall ||
+ Services.policies.allowedInstallSource(aInstall.sourceURI)) &&
+ (!aInstallingPrincipal ||
+ !aInstallingPrincipal.URI ||
+ Services.policies.allowedInstallSource(aInstallingPrincipal.URI))
+ ) {
+ return true;
+ }
+ return false;
+ }
+ }
+ return !explicit;
+ },
+
+ installNotifyObservers(
+ aTopic,
+ aBrowser,
+ aUri,
+ aInstall,
+ aInstallFn,
+ aCancelFn
+ ) {
+ let info = {
+ wrappedJSObject: {
+ browser: aBrowser,
+ originatingURI: aUri,
+ installs: [aInstall],
+ install: aInstallFn,
+ cancel: aCancelFn,
+ },
+ };
+ Services.obs.notifyObservers(info, aTopic);
+ },
+
+ startInstall(browser, url, install) {
+ this.installNotifyObservers("addon-install-started", browser, url, install);
+
+ // Local installs may already be in a failed state in which case
+ // we won't get any further events, detect those cases now.
+ if (
+ install.state == AddonManager.STATE_DOWNLOADED &&
+ install.addon.appDisabled
+ ) {
+ install.cancel();
+ this.installNotifyObservers(
+ "addon-install-failed",
+ browser,
+ url,
+ install
+ );
+ return;
+ }
+
+ let self = this;
+ let listener = {
+ onDownloadCancelled() {
+ install.removeListener(listener);
+ },
+
+ onDownloadFailed() {
+ install.removeListener(listener);
+ self.installNotifyObservers(
+ "addon-install-failed",
+ browser,
+ url,
+ install
+ );
+ },
+
+ onDownloadEnded() {
+ if (install.addon.appDisabled) {
+ // App disabled items are not compatible and so fail to install.
+ install.removeListener(listener);
+ install.cancel();
+ self.installNotifyObservers(
+ "addon-install-failed",
+ browser,
+ url,
+ install
+ );
+ }
+ },
+
+ onInstallCancelled() {
+ install.removeListener(listener);
+ },
+
+ onInstallFailed() {
+ install.removeListener(listener);
+ self.installNotifyObservers(
+ "addon-install-failed",
+ browser,
+ url,
+ install
+ );
+ },
+
+ onInstallEnded() {
+ install.removeListener(listener);
+
+ // If installing a theme that is disabled and can be enabled
+ // then enable it
+ if (
+ install.addon.type == "theme" &&
+ !!install.addon.userDisabled &&
+ !install.addon.appDisabled
+ ) {
+ install.addon.enable();
+ }
+
+ let subject = {
+ wrappedJSObject: { target: browser, addon: install.addon },
+ };
+ Services.obs.notifyObservers(subject, "webextension-install-notify");
+ },
+ };
+
+ install.addListener(listener);
+
+ // Start downloading if it hasn't already begun
+ install.install();
+ },
+
+ /**
+ * Starts installation of a SitePermsAddonInstall notifying the registered
+ * web install listener of a blocked or started install.
+ *
+ * @param aBrowser
+ * The optional browser element that started the install
+ * @param aInstallingPrincipal
+ * The nsIPrincipal that initiated the install
+ * @param aPermission
+ * The permission to install
+ * @returns {Promise} A promise that will resolve when the user installs the addon.
+ * The promise will reject if the user blocked the install, or if the addon
+ * can't be installed (e.g. the principal isn't supported).
+ * @throws {Components.Exception} Will throw an error if the AddonManager is not initialized
+ * or if `aInstallingPrincipal` is not a nsIPrincipal.
+ */
+ async installSitePermsAddonFromWebpage(
+ aBrowser,
+ aInstallingPrincipal,
+ aPermission
+ ) {
+ const synthAddonInstall =
+ await AddonManagerInternal.getSitePermsAddonInstallForWebpage(
+ aBrowser,
+ aInstallingPrincipal,
+ aPermission
+ );
+ const promiseInstall = new Promise((resolve, reject) => {
+ const installListener = {
+ onInstallFailed() {
+ synthAddonInstall.removeListener(installListener);
+ reject(new Error("Install Failed"));
+ },
+
+ onInstallCancelled() {
+ synthAddonInstall.removeListener(installListener);
+ reject(new Error("Install Cancelled"));
+ },
+
+ onInstallEnded() {
+ synthAddonInstall.removeListener(installListener);
+ resolve();
+ },
+ };
+ synthAddonInstall.addListener(installListener);
+ });
+
+ let startInstall = () => {
+ AddonManagerInternal.setupPromptHandler(
+ aBrowser,
+ aInstallingPrincipal.URI,
+ synthAddonInstall,
+ true,
+ "SitePermissionAddonPrompt"
+ );
+
+ AddonManagerInternal.startInstall(
+ aBrowser,
+ aInstallingPrincipal.URI,
+ synthAddonInstall
+ );
+ };
+
+ startInstall();
+
+ return promiseInstall;
+ },
+
+ /**
+ * Starts installation of an AddonInstall notifying the registered
+ * web install listener of a blocked or started install.
+ *
+ * @param aMimetype
+ * The mimetype of the add-on being installed
+ * @param aBrowser
+ * The optional browser element that started the install
+ * @param aInstallingPrincipal
+ * The nsIPrincipal that initiated the install
+ * @param aInstall
+ * The AddonInstall to be installed
+ * @param [aDetails]
+ * Additional optional details
+ * @param [aDetails.hasCrossOriginAncestor]
+ * Boolean value set to true if any of cross-origin ancestors of the triggering frame
+ * (if set to true the installation will be denied).
+ */
+ installAddonFromWebpage(
+ aMimetype,
+ aBrowser,
+ aInstallingPrincipal,
+ aInstall,
+ aDetails
+ ) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aMimetype || typeof aMimetype != "string") {
+ throw Components.Exception(
+ "aMimetype must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aBrowser && !Element.isInstance(aBrowser)) {
+ throw Components.Exception(
+ "aSource must be an Element, or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (
+ !aInstallingPrincipal ||
+ !(aInstallingPrincipal instanceof Ci.nsIPrincipal)
+ ) {
+ throw Components.Exception(
+ "aInstallingPrincipal must be a nsIPrincipal",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ // When a chrome in-content UI has loaded a <browser> inside to host a
+ // website we want to do our security checks on the inner-browser but
+ // notify front-end that install events came from the outer-browser (the
+ // main tab's browser). Check this by seeing if the browser we've been
+ // passed is in a content type docshell and if so get the outer-browser.
+ let topBrowser = aBrowser;
+ // GeckoView does not pass a browser.
+ if (aBrowser) {
+ let docShell = aBrowser.ownerGlobal.docShell;
+ if (docShell.itemType == Ci.nsIDocShellTreeItem.typeContent) {
+ topBrowser = docShell.chromeEventHandler;
+ }
+ }
+
+ try {
+ // Use fullscreenElement to check for DOM fullscreen, while still allowing
+ // macOS fullscreen, which still has a browser chrome.
+ if (topBrowser && topBrowser.ownerDocument.fullscreenElement) {
+ // Addon installation and the resulting notifications should be
+ // blocked in DOM fullscreen for security and usability reasons.
+ // Installation prompts in fullscreen can trick the user into
+ // installing unwanted addons.
+ // In fullscreen the notification box does not have a clear
+ // visual association with its parent anymore.
+ aInstall.cancel();
+
+ this.installNotifyObservers(
+ "addon-install-fullscreen-blocked",
+ topBrowser,
+ aInstallingPrincipal.URI,
+ aInstall
+ );
+ return;
+ } else if (!this.isInstallEnabled(aMimetype)) {
+ aInstall.cancel();
+
+ this.installNotifyObservers(
+ "addon-install-disabled",
+ topBrowser,
+ aInstallingPrincipal.URI,
+ aInstall
+ );
+ return;
+ } else if (
+ !this.isInstallAllowedByPolicy(
+ aInstallingPrincipal,
+ aInstall,
+ false /* explicit */
+ )
+ ) {
+ aInstall.cancel();
+
+ this.installNotifyObservers(
+ "addon-install-policy-blocked",
+ topBrowser,
+ aInstallingPrincipal.URI,
+ aInstall
+ );
+ return;
+ } else if (
+ // Block the install request if the triggering frame does have any cross-origin
+ // ancestor.
+ aDetails?.hasCrossOriginAncestor ||
+ // Block the install if triggered by a null principal.
+ aInstallingPrincipal.isNullPrincipal ||
+ (aBrowser &&
+ (!aBrowser.contentPrincipal ||
+ // When we attempt to handle an XPI load immediately after a
+ // process switch, the DocShell it's being loaded into will have
+ // a null principal, since it won't have been initialized yet.
+ // Allowing installs in this case is relatively safe, since
+ // there isn't much to gain by spoofing an install request from
+ // a null principal in any case. This exception can be removed
+ // once content handlers are triggered by DocumentChannel in the
+ // parent process.
+ !(
+ aBrowser.contentPrincipal.isNullPrincipal ||
+ aInstallingPrincipal.subsumes(aBrowser.contentPrincipal)
+ )))
+ ) {
+ aInstall.cancel();
+
+ this.installNotifyObservers(
+ "addon-install-origin-blocked",
+ topBrowser,
+ aInstallingPrincipal.URI,
+ aInstall
+ );
+ return;
+ }
+
+ if (aBrowser) {
+ // The install may start now depending on the web install listener,
+ // listen for the browser navigating to a new origin and cancel the
+ // install in that case.
+ new BrowserListener(aBrowser, aInstallingPrincipal, aInstall);
+ }
+
+ let startInstall = source => {
+ AddonManagerInternal.setupPromptHandler(
+ aBrowser,
+ aInstallingPrincipal.URI,
+ aInstall,
+ true,
+ source
+ );
+
+ AddonManagerInternal.startInstall(
+ aBrowser,
+ aInstallingPrincipal.URI,
+ aInstall
+ );
+ };
+
+ let installAllowed = this.isInstallAllowed(
+ aMimetype,
+ aInstallingPrincipal
+ );
+ let installPerm = Services.perms.testPermissionFromPrincipal(
+ aInstallingPrincipal,
+ "install"
+ );
+
+ if (installAllowed) {
+ startInstall("AMO");
+ } else if (installPerm === Ci.nsIPermissionManager.DENY_ACTION) {
+ // Block without prompt
+ aInstall.cancel();
+ this.installNotifyObservers(
+ "addon-install-blocked-silent",
+ topBrowser,
+ aInstallingPrincipal.URI,
+ aInstall
+ );
+ } else if (!lazy.WEBEXT_POSTDOWNLOAD_THIRD_PARTY) {
+ // Block with prompt
+ this.installNotifyObservers(
+ "addon-install-blocked",
+ topBrowser,
+ aInstallingPrincipal.URI,
+ aInstall,
+ () => startInstall("other"),
+ () => aInstall.cancel()
+ );
+ } else {
+ // We download the addon and validate whether a 3rd party
+ // install prompt should be shown using e.g. recommended
+ // state and install_origins.
+ logger.info(`Addon download before validation.`);
+ startInstall("other");
+ }
+ } catch (e) {
+ // In the event that the weblistener throws during instantiation or when
+ // calling onWebInstallBlocked or onWebInstallRequested the
+ // install should get cancelled.
+ logger.warn("Failure calling web installer", e);
+ aInstall.cancel();
+ }
+ },
+
+ /**
+ * Starts installation of an AddonInstall created from add-ons manager
+ * front-end code (e.g., drag-and-drop of xpis or "Install Add-on from File"
+ *
+ * @param browser
+ * The browser element where the installation was initiated
+ * @param uri
+ * The URI of the page where the installation was initiated
+ * @param install
+ * The AddonInstall to be installed
+ */
+ installAddonFromAOM(browser, uri, install) {
+ if (!this.isInstallAllowedByPolicy(null, install)) {
+ install.cancel();
+
+ this.installNotifyObservers(
+ "addon-install-policy-blocked",
+ browser,
+ install.sourceURI,
+ install
+ );
+ return;
+ }
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ AddonManagerInternal.setupPromptHandler(
+ browser,
+ uri,
+ install,
+ true,
+ "local"
+ );
+ AddonManagerInternal.startInstall(browser, uri, install);
+ },
+
+ /**
+ * Adds a new InstallListener if the listener is not already registered.
+ *
+ * @param aListener
+ * The InstallListener to add
+ */
+ addInstallListener(aListener) {
+ if (!aListener || typeof aListener != "object") {
+ throw Components.Exception(
+ "aListener must be a InstallListener object",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ this.installListeners.add(aListener);
+ },
+
+ /**
+ * Removes an InstallListener if the listener is registered.
+ *
+ * @param aListener
+ * The InstallListener to remove
+ */
+ removeInstallListener(aListener) {
+ if (!aListener || typeof aListener != "object") {
+ throw Components.Exception(
+ "aListener must be a InstallListener object",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ this.installListeners.delete(aListener);
+ },
+ /**
+ * Adds new or overrides existing UpgradeListener.
+ *
+ * @param aInstanceID
+ * The instance ID of an addon to register a listener for.
+ * @param aCallback
+ * The callback to invoke when updates are available for this addon.
+ * @throws if there is no addon matching the instanceID
+ */
+ addUpgradeListener(aInstanceID, aCallback) {
+ if (!aInstanceID || typeof aInstanceID != "symbol") {
+ throw Components.Exception(
+ "aInstanceID must be a symbol",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (!aCallback || typeof aCallback != "function") {
+ throw Components.Exception(
+ "aCallback must be a function",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let addonId = this.syncGetAddonIDByInstanceID(aInstanceID);
+ if (!addonId) {
+ throw Error(`No addon matching instanceID: ${String(aInstanceID)}`);
+ }
+ logger.debug(`Registering upgrade listener for ${addonId}`);
+ this.upgradeListeners.set(addonId, aCallback);
+ },
+
+ /**
+ * Removes an UpgradeListener if the listener is registered.
+ *
+ * @param aInstanceID
+ * The instance ID of the addon to remove
+ */
+ removeUpgradeListener(aInstanceID) {
+ if (!aInstanceID || typeof aInstanceID != "symbol") {
+ throw Components.Exception(
+ "aInstanceID must be a symbol",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let addonId = this.syncGetAddonIDByInstanceID(aInstanceID);
+ if (!addonId) {
+ throw Error(`No addon for instanceID: ${aInstanceID}`);
+ }
+ if (this.upgradeListeners.has(addonId)) {
+ this.upgradeListeners.delete(addonId);
+ } else {
+ throw Error(`No upgrade listener registered for addon ID: ${addonId}`);
+ }
+ },
+
+ addExternalExtensionLoader(loader) {
+ this.externalExtensionLoaders.set(loader.name, loader);
+ },
+
+ /**
+ * Installs a temporary add-on from a local file or directory.
+ *
+ * @param aFile
+ * An nsIFile for the file or directory of the add-on to be
+ * temporarily installed.
+ * @returns a Promise that rejects if the add-on is not a valid restartless
+ * add-on or if the same ID is already temporarily installed.
+ */
+ installTemporaryAddon(aFile) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!(aFile instanceof Ci.nsIFile)) {
+ throw Components.Exception(
+ "aFile must be a nsIFile",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ return AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).installTemporaryAddon(aFile);
+ },
+
+ /**
+ * Installs an add-on from a built-in location
+ * (ie a resource: url referencing assets shipped with the application)
+ *
+ * @param aBase
+ * A string containing the base URL. Must be a resource: URL.
+ * @returns a Promise that resolves when the addon is installed.
+ */
+ installBuiltinAddon(aBase) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ return AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).installBuiltinAddon(aBase);
+ },
+
+ /**
+ * Like `installBuiltinAddon`, but only installs the addon at `aBase`
+ * if an existing built-in addon with the ID `aID` and version doesn't
+ * already exist.
+ *
+ * @param {string} aID
+ * The ID of the add-on being registered.
+ * @param {string} aVersion
+ * The version of the add-on being registered.
+ * @param {string} aBase
+ * A string containing the base URL. Must be a resource: URL.
+ * @returns a Promise that resolves when the addon is installed.
+ */
+ maybeInstallBuiltinAddon(aID, aVersion, aBase) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ return AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).maybeInstallBuiltinAddon(aID, aVersion, aBase);
+ },
+
+ syncGetAddonIDByInstanceID(aInstanceID) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aInstanceID || typeof aInstanceID != "symbol") {
+ throw Components.Exception(
+ "aInstanceID must be a Symbol()",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ return AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).getAddonIDByInstanceID(aInstanceID);
+ },
+
+ /**
+ * Gets an icon from the icon set provided by the add-on
+ * that is closest to the specified size.
+ *
+ * The optional window parameter will be used to determine
+ * the screen resolution and select a more appropriate icon.
+ * Calling this method with 48px on retina screens will try to
+ * match an icon of size 96px.
+ *
+ * @param aAddon
+ * An addon object, meaning:
+ * An object with either an icons property that is a key-value list
+ * of icon size and icon URL, or an object having an iconURL property.
+ * @param aSize
+ * Ideal icon size in pixels
+ * @param aWindow
+ * Optional window object for determining the correct scale.
+ * @return {String} The absolute URL of the icon or null if the addon doesn't have icons
+ */
+ getPreferredIconURL(aAddon, aSize, aWindow = undefined) {
+ if (aWindow && aWindow.devicePixelRatio) {
+ aSize *= aWindow.devicePixelRatio;
+ }
+
+ let icons = aAddon.icons;
+
+ // certain addon-types only have iconURLs
+ if (!icons) {
+ icons = {};
+ if (aAddon.iconURL) {
+ icons[32] = aAddon.iconURL;
+ icons[48] = aAddon.iconURL;
+ }
+ }
+
+ // quick return if the exact size was found
+ if (icons[aSize]) {
+ return icons[aSize];
+ }
+
+ let bestSize = null;
+
+ for (let size of Object.keys(icons)) {
+ if (!INTEGER.test(size)) {
+ throw Components.Exception(
+ "Invalid icon size, must be an integer",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+
+ size = parseInt(size, 10);
+
+ if (!bestSize) {
+ bestSize = size;
+ continue;
+ }
+
+ if (size > aSize && bestSize > aSize) {
+ // If both best size and current size are larger than the wanted size then choose
+ // the one closest to the wanted size
+ bestSize = Math.min(bestSize, size);
+ } else {
+ // Otherwise choose the largest of the two so we'll prefer sizes as close to below aSize
+ // or above aSize
+ bestSize = Math.max(bestSize, size);
+ }
+ }
+
+ return icons[bestSize] || null;
+ },
+
+ /**
+ * Asynchronously gets an add-on with a specific ID.
+ *
+ * @type {function}
+ * @param {string} aID
+ * The ID of the add-on to retrieve
+ * @returns {Promise} resolves with the found Addon or null if no such add-on exists. Never rejects.
+ * @throws if the aID argument is not specified
+ */
+ getAddonByID(aID) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aID || typeof aID != "string") {
+ throw Components.Exception(
+ "aID must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let promises = Array.from(this.providers, p =>
+ promiseCallProvider(p, "getAddonByID", aID)
+ );
+ return Promise.all(promises).then(aAddons => {
+ return aAddons.find(a => !!a) || null;
+ });
+ },
+
+ /**
+ * Asynchronously get an add-on with a specific Sync GUID.
+ *
+ * @param aGUID
+ * String GUID of add-on to retrieve
+ * @throws if the aGUID argument is not specified
+ */
+ getAddonBySyncGUID(aGUID) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aGUID || typeof aGUID != "string") {
+ throw Components.Exception(
+ "aGUID must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ return (async () => {
+ for (let provider of this.providers) {
+ let addon = await promiseCallProvider(
+ provider,
+ "getAddonBySyncGUID",
+ aGUID
+ );
+
+ if (addon) {
+ return addon;
+ }
+ }
+
+ return null;
+ })();
+ },
+
+ /**
+ * Asynchronously gets an array of add-ons.
+ *
+ * @param aIDs
+ * The array of IDs to retrieve
+ * @return {Promise}
+ * @resolves The array of found add-ons.
+ * @rejects Never
+ * @throws if the aIDs argument is not specified
+ */
+ getAddonsByIDs(aIDs) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!Array.isArray(aIDs)) {
+ throw Components.Exception(
+ "aIDs must be an array",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let promises = aIDs.map(a => AddonManagerInternal.getAddonByID(a));
+ return Promise.all(promises);
+ },
+
+ /**
+ * Asynchronously gets add-ons of specific types.
+ *
+ * @param aTypes
+ * An optional array of types to retrieve. Each type is a string name
+ */
+ getAddonsByTypes(aTypes) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (aTypes && !Array.isArray(aTypes)) {
+ throw Components.Exception(
+ "aTypes must be an array or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ return (async () => {
+ let addons = [];
+
+ for (let provider of this.providers) {
+ let providerAddons = await promiseCallProvider(
+ provider,
+ "getAddonsByTypes",
+ aTypes
+ );
+
+ if (providerAddons) {
+ addons.push(...providerAddons);
+ }
+ }
+
+ return addons;
+ })();
+ },
+
+ /**
+ * Gets active add-ons of specific types.
+ *
+ * This is similar to getAddonsByTypes() but it may return a limited
+ * amount of information about only active addons. Consequently, it
+ * can be implemented by providers using only immediately available
+ * data as opposed to getAddonsByTypes which may require I/O).
+ *
+ * @param aTypes
+ * An optional array of types to retrieve. Each type is a string name
+ *
+ * @resolve {addons: Array, fullData: bool}
+ * fullData is true if addons contains all the data we have on those
+ * addons. It is false if addons only contains partial data.
+ */
+ async getActiveAddons(aTypes) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (aTypes && !Array.isArray(aTypes)) {
+ throw Components.Exception(
+ "aTypes must be an array or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let addons = [],
+ fullData = true;
+
+ for (let provider of this.providers) {
+ let providerAddons, providerFullData;
+ if ("getActiveAddons" in provider) {
+ ({ addons: providerAddons, fullData: providerFullData } =
+ await callProvider(provider, "getActiveAddons", null, aTypes));
+ } else {
+ providerAddons = await promiseCallProvider(
+ provider,
+ "getAddonsByTypes",
+ aTypes
+ );
+ providerAddons = providerAddons.filter(a => a.isActive);
+ providerFullData = true;
+ }
+
+ if (providerAddons) {
+ addons.push(...providerAddons);
+ fullData = fullData && providerFullData;
+ }
+ }
+
+ return { addons, fullData };
+ },
+
+ /**
+ * Asynchronously gets all installed add-ons.
+ */
+ getAllAddons() {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ return this.getAddonsByTypes(null);
+ },
+
+ /**
+ * Adds a new AddonManagerListener if the listener is not already registered.
+ *
+ * @param {AddonManagerListener} aListener
+ * The listener to add
+ */
+ addManagerListener(aListener) {
+ if (!aListener || typeof aListener != "object") {
+ throw Components.Exception(
+ "aListener must be an AddonManagerListener object",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ this.managerListeners.add(aListener);
+ },
+
+ /**
+ * Removes an AddonManagerListener if the listener is registered.
+ *
+ * @param {AddonManagerListener} aListener
+ * The listener to remove
+ */
+ removeManagerListener(aListener) {
+ if (!aListener || typeof aListener != "object") {
+ throw Components.Exception(
+ "aListener must be an AddonManagerListener object",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ this.managerListeners.delete(aListener);
+ },
+
+ /**
+ * Adds a new AddonListener if the listener is not already registered.
+ *
+ * @param {AddonManagerListener} aListener
+ * The AddonListener to add.
+ */
+ addAddonListener(aListener) {
+ if (!aListener || typeof aListener != "object") {
+ throw Components.Exception(
+ "aListener must be an AddonListener object",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ this.addonListeners.add(aListener);
+ },
+
+ /**
+ * Removes an AddonListener if the listener is registered.
+ *
+ * @param {object} aListener
+ * The AddonListener to remove
+ */
+ removeAddonListener(aListener) {
+ if (!aListener || typeof aListener != "object") {
+ throw Components.Exception(
+ "aListener must be an AddonListener object",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ this.addonListeners.delete(aListener);
+ },
+
+ /**
+ * @param {string} addonType
+ * @returns {boolean}
+ * Whether there is a provider that provides the given addon type.
+ */
+ hasAddonType(addonType) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ for (let addonTypes of this.typesByProvider.values()) {
+ if (addonTypes.has(addonType)) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ get autoUpdateDefault() {
+ return gAutoUpdateDefault;
+ },
+
+ set autoUpdateDefault(aValue) {
+ aValue = !!aValue;
+ if (aValue != gAutoUpdateDefault) {
+ Services.prefs.setBoolPref(PREF_EM_AUTOUPDATE_DEFAULT, aValue);
+ }
+ },
+
+ get checkCompatibility() {
+ return gCheckCompatibility;
+ },
+
+ set checkCompatibility(aValue) {
+ aValue = !!aValue;
+ if (aValue != gCheckCompatibility) {
+ if (!aValue) {
+ Services.prefs.setBoolPref(PREF_EM_CHECK_COMPATIBILITY, false);
+ } else {
+ Services.prefs.clearUserPref(PREF_EM_CHECK_COMPATIBILITY);
+ }
+ }
+ },
+
+ get strictCompatibility() {
+ return gStrictCompatibility;
+ },
+
+ set strictCompatibility(aValue) {
+ aValue = !!aValue;
+ if (aValue != gStrictCompatibility) {
+ Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, aValue);
+ }
+ },
+
+ get checkUpdateSecurityDefault() {
+ return gCheckUpdateSecurityDefault;
+ },
+
+ get checkUpdateSecurity() {
+ return gCheckUpdateSecurity;
+ },
+
+ set checkUpdateSecurity(aValue) {
+ aValue = !!aValue;
+ if (aValue != gCheckUpdateSecurity) {
+ if (aValue != gCheckUpdateSecurityDefault) {
+ Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, aValue);
+ } else {
+ Services.prefs.clearUserPref(PREF_EM_CHECK_UPDATE_SECURITY);
+ }
+ }
+ },
+
+ get updateEnabled() {
+ return gUpdateEnabled;
+ },
+
+ set updateEnabled(aValue) {
+ aValue = !!aValue;
+ if (aValue != gUpdateEnabled) {
+ Services.prefs.setBoolPref(PREF_EM_UPDATE_ENABLED, aValue);
+ }
+ },
+
+ /**
+ * Verify whether we need to show the 3rd party install prompt.
+ *
+ * Bypass the third party install prompt if this is an install:
+ * - is an install from a recognized source
+ * - is a an addon that can bypass the panel, such as a recommended addon
+ *
+ * @param {browser} browser browser user is installing from
+ * @param {nsIURI} url URI for the principal of the installing source
+ * @param {AddonInstallWrapper} install
+ * @param {Object} info information such as addon wrapper
+ * @param {AddonWrapper} info.addon
+ * @param {string} source simplified string describing source of install and is
+ * generated based on the installing principal and checking
+ * against site permissions and enterprise policy.
+ * It may be one of "AMO", "local" or "other".
+ * @returns {Promise} Rejected when the installation should not proceed.
+ */
+ _verifyThirdPartyInstall(browser, url, install, info, source) {
+ // If we are not post-download processing, this panel was already shown.
+ // Otherwise, if this is from AMO or local, bypass the prompt.
+ if (
+ !lazy.WEBEXT_POSTDOWNLOAD_THIRD_PARTY ||
+ ["AMO", "local"].includes(source)
+ ) {
+ return Promise.resolve();
+ }
+
+ // verify both the installing source and the xpi url are allowed.
+ if (
+ !info.addon.validInstallOrigins({
+ installFrom: url,
+ source: install.sourceURI,
+ })
+ ) {
+ install.error = AddonManager.ERROR_INVALID_DOMAIN;
+ return Promise.reject();
+ }
+
+ // Some addons such as recommended addons do not result in this prompt.
+ if (info.addon.canBypassThirdParyInstallPrompt) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve, reject) => {
+ this.installNotifyObservers(
+ "addon-install-blocked",
+ browser,
+ url,
+ install,
+ resolve,
+ reject
+ );
+ });
+ },
+
+ setupPromptHandler(browser, url, install, requireConfirm, source) {
+ install.promptHandler = info =>
+ new Promise((resolve, reject) => {
+ this._verifyThirdPartyInstall(browser, url, install, info, source)
+ .then(() => {
+ // All installs end up in this callback when the add-on is available
+ // for installation. There are numerous different things that can
+ // happen from here though. For webextensions, if the application
+ // implements webextension permission prompts, those always take
+ // precedence.
+ // If this add-on is not a webextension or if the application does not
+ // implement permission prompts, no confirmation is displayed for
+ // installs created from about:addons (in which case requireConfirm
+ // is false).
+ // In the remaining cases, a confirmation prompt is displayed but the
+ // application may override it either by implementing the
+ // "@mozilla.org/addons/web-install-prompt;1" contract or by setting
+ // the customConfirmationUI preference and responding to the
+ // "addon-install-confirmation" notification. If the application
+ // does not implement its own prompt, use the built-in xul dialog.
+ if (info.addon.userPermissions) {
+ let subject = {
+ wrappedJSObject: {
+ target: browser,
+ info: Object.assign({ resolve, reject, source }, info),
+ },
+ };
+ subject.wrappedJSObject.info.permissions =
+ info.addon.userPermissions;
+ Services.obs.notifyObservers(
+ subject,
+ "webextension-permission-prompt"
+ );
+ } else if (info.addon.sitePermissions) {
+ // Handle prompting for DOM permissions in SitePermission addons.
+ let { sitePermissions, siteOrigin } = info.addon;
+ let subject = {
+ wrappedJSObject: {
+ target: browser,
+ info: Object.assign(
+ { resolve, reject, source, sitePermissions, siteOrigin },
+ info
+ ),
+ },
+ };
+ Services.obs.notifyObservers(
+ subject,
+ "webextension-permission-prompt"
+ );
+ } else if (requireConfirm) {
+ // The methods below all want to call the install() or cancel()
+ // method on the provided AddonInstall object to either accept
+ // or reject the confirmation. Fit that into our promise-based
+ // control flow by wrapping the install object. However,
+ // xpInstallConfirm.xul matches the install object it is passed
+ // with the argument passed to an InstallListener, so give it
+ // access to the underlying object through the .wrapped property.
+ let proxy = new Proxy(install, {
+ get(target, property) {
+ if (property == "install") {
+ return resolve;
+ } else if (property == "cancel") {
+ return reject;
+ } else if (property == "wrapped") {
+ return target;
+ }
+ let result = target[property];
+ return typeof result == "function"
+ ? result.bind(target)
+ : result;
+ },
+ });
+
+ // Check for a custom installation prompt that may be provided by the
+ // applicaton
+ if ("@mozilla.org/addons/web-install-prompt;1" in Cc) {
+ try {
+ let prompt = Cc[
+ "@mozilla.org/addons/web-install-prompt;1"
+ ].getService(Ci.amIWebInstallPrompt);
+ prompt.confirm(browser, url, [proxy]);
+ return;
+ } catch (e) {}
+ }
+
+ this.installNotifyObservers(
+ "addon-install-confirmation",
+ browser,
+ url,
+ proxy
+ );
+ } else {
+ resolve();
+ }
+ })
+ .catch(e => {
+ // Error is undefined if the promise was rejected.
+ if (e) {
+ Cu.reportError(`Install prompt handler error: ${e}`);
+ }
+ reject();
+ });
+ });
+ },
+
+ webAPI: {
+ // installs maps integer ids to AddonInstall instances.
+ installs: new Map(),
+ nextInstall: 0,
+
+ sendEvent: null,
+ setEventHandler(fn) {
+ this.sendEvent = fn;
+ },
+
+ async getAddonByID(target, id) {
+ return webAPIForAddon(await AddonManager.getAddonByID(id));
+ },
+
+ // helper to copy (and convert) the properties we care about
+ copyProps(install, obj) {
+ obj.state = AddonManager.stateToString(install.state);
+ obj.error = AddonManager.errorToString(install.error);
+ obj.progress = install.progress;
+ obj.maxProgress = install.maxProgress;
+ },
+
+ forgetInstall(id) {
+ let info = this.installs.get(id);
+ if (!info) {
+ throw new Error(`forgetInstall cannot find ${id}`);
+ }
+ info.install.removeListener(info.listener);
+ this.installs.delete(id);
+ },
+
+ createInstall(target, options) {
+ // Throw an appropriate error if the given URL is not valid
+ // as an installation source. Return silently if it is okay.
+ function checkInstallUri(uri) {
+ if (Services.policies && !Services.policies.allowedInstallSource(uri)) {
+ // eslint-disable-next-line no-throw-literal
+ return {
+ success: false,
+ code: "addon-install-policy-blocked",
+ message: `Install from ${uri.spec} not permitted by policy`,
+ };
+ }
+
+ if (WEBAPI_INSTALL_HOSTS.includes(uri.host)) {
+ return { success: true };
+ }
+ if (
+ Services.prefs.getBoolPref(PREF_WEBAPI_TESTING, false) &&
+ WEBAPI_TEST_INSTALL_HOSTS.includes(uri.host)
+ ) {
+ return { success: true };
+ }
+
+ // eslint-disable-next-line no-throw-literal
+ return {
+ success: false,
+ code: "addon-install-webapi-blocked",
+ message: `Install from ${uri.host} not permitted`,
+ };
+ }
+
+ const makeListener = (id, mm) => {
+ const events = [
+ "onDownloadStarted",
+ "onDownloadProgress",
+ "onDownloadEnded",
+ "onDownloadCancelled",
+ "onDownloadFailed",
+ "onInstallStarted",
+ "onInstallEnded",
+ "onInstallCancelled",
+ "onInstallFailed",
+ ];
+
+ let listener = {};
+ let installPromise = new Promise((resolve, reject) => {
+ events.forEach(event => {
+ listener[event] = (install, addon) => {
+ let data = { event, id };
+ AddonManager.webAPI.copyProps(install, data);
+ this.sendEvent(mm, data);
+ if (event == "onInstallEnded") {
+ resolve(addon);
+ } else if (
+ event == "onDownloadFailed" ||
+ event == "onInstallFailed"
+ ) {
+ reject({ message: "install failed" });
+ } else if (
+ event == "onDownloadCancelled" ||
+ event == "onInstallCancelled"
+ ) {
+ reject({ message: "install cancelled" });
+ } else if (event == "onDownloadEnded") {
+ if (install.addon.appDisabled) {
+ // App disabled items are not compatible and so fail to install
+ install.cancel();
+ AddonManagerInternal.installNotifyObservers(
+ "addon-install-failed",
+ target,
+ Services.io.newURI(options.url),
+ install
+ );
+ }
+ }
+ };
+ });
+ });
+
+ // We create the promise here since this is where we're setting
+ // up the InstallListener, but if the install is never started,
+ // no handlers will be attached so make sure we terminate errors.
+ installPromise.catch(() => {});
+
+ return { listener, installPromise };
+ };
+
+ let uri;
+ try {
+ uri = Services.io.newURI(options.url);
+ const { success, code, message } = checkInstallUri(uri);
+ if (!success) {
+ let info = {
+ wrappedJSObject: {
+ browser: target,
+ originatingURI: uri,
+ installs: [],
+ },
+ };
+ Cu.reportError(`${code}: ${message}`);
+ Services.obs.notifyObservers(info, code);
+ return Promise.reject({ code, message });
+ }
+ } catch (err) {
+ // Reject Components.Exception errors (e.g. NS_ERROR_MALFORMED_URI) as is.
+ if (err instanceof Components.Exception) {
+ return Promise.reject({ message: err.message });
+ }
+ return Promise.reject({
+ message: "Install Failed on unexpected error",
+ });
+ }
+
+ return AddonManagerInternal.getInstallForURL(options.url, {
+ browser: target,
+ triggeringPrincipal: options.triggeringPrincipal,
+ hash: options.hash,
+ telemetryInfo: {
+ source: AddonManager.getInstallSourceFromHost(options.sourceHost),
+ sourceURL: options.sourceURL,
+ method: "amWebAPI",
+ },
+ }).then(install => {
+ let requireConfirm = true;
+ if (
+ target.contentDocument &&
+ target.contentDocument.nodePrincipal.isSystemPrincipal
+ ) {
+ requireConfirm = false;
+ }
+ AddonManagerInternal.setupPromptHandler(
+ target,
+ null,
+ install,
+ requireConfirm,
+ "AMO"
+ );
+
+ let id = this.nextInstall++;
+ let { listener, installPromise } = makeListener(
+ id,
+ target.messageManager
+ );
+ install.addListener(listener);
+
+ this.installs.set(id, {
+ install,
+ target,
+ listener,
+ installPromise,
+ messageManager: target.messageManager,
+ });
+
+ let result = { id };
+ this.copyProps(install, result);
+ return result;
+ });
+ },
+
+ async addonUninstall(target, id) {
+ let addon = await AddonManager.getAddonByID(id);
+ if (!addon) {
+ return false;
+ }
+
+ if (!(addon.permissions & AddonManager.PERM_CAN_UNINSTALL)) {
+ return Promise.reject({ message: "Addon cannot be uninstalled" });
+ }
+
+ try {
+ addon.uninstall();
+ return true;
+ } catch (err) {
+ Cu.reportError(err);
+ return false;
+ }
+ },
+
+ async addonSetEnabled(target, id, value) {
+ let addon = await AddonManager.getAddonByID(id);
+ if (!addon) {
+ throw new Error(`No such addon ${id}`);
+ }
+
+ if (value) {
+ await addon.enable();
+ } else {
+ await addon.disable();
+ }
+ },
+
+ async addonInstallDoInstall(target, id) {
+ let state = this.installs.get(id);
+ if (!state) {
+ throw new Error(`invalid id ${id}`);
+ }
+
+ let addon = await state.install.install();
+
+ if (addon.type == "theme" && !addon.appDisabled) {
+ await addon.enable();
+ }
+
+ await new Promise(resolve => {
+ let subject = {
+ wrappedJSObject: { target, addon, callback: resolve },
+ };
+ Services.obs.notifyObservers(subject, "webextension-install-notify");
+ });
+ },
+
+ addonInstallCancel(target, id) {
+ let state = this.installs.get(id);
+ if (!state) {
+ return Promise.reject(`invalid id ${id}`);
+ }
+ return Promise.resolve(state.install.cancel());
+ },
+
+ clearInstalls(ids) {
+ for (let id of ids) {
+ this.forgetInstall(id);
+ }
+ },
+
+ clearInstallsFrom(mm) {
+ for (let [id, info] of this.installs) {
+ if (info.messageManager == mm) {
+ this.forgetInstall(id);
+ }
+ }
+ },
+
+ async addonReportAbuse(target, id) {
+ if (!Services.prefs.getBoolPref(PREF_AMO_ABUSEREPORT, false)) {
+ return Promise.reject({
+ message: "amWebAPI reportAbuse not supported",
+ });
+ }
+
+ let existingDialog = lazy.AbuseReporter.getOpenDialog();
+ if (existingDialog) {
+ existingDialog.close();
+ }
+
+ const dialog = await lazy.AbuseReporter.openDialog(
+ id,
+ "amo",
+ target
+ ).catch(err => {
+ Cu.reportError(err);
+ return Promise.reject({
+ message: "Error creating abuse report",
+ });
+ });
+
+ return dialog.promiseReport.then(
+ async report => {
+ if (!report) {
+ return false;
+ }
+
+ await report.submit().catch(err => {
+ Cu.reportError(err);
+ return Promise.reject({
+ message: "Error submitting abuse report",
+ });
+ });
+
+ return true;
+ },
+ err => {
+ Cu.reportError(err);
+ dialog.close();
+ return Promise.reject({
+ message: "Error creating abuse report",
+ });
+ }
+ );
+ },
+ },
+};
+
+/**
+ * Should not be used outside of core Mozilla code. This is a private API for
+ * the startup and platform integration code to use. Refer to the methods on
+ * AddonManagerInternal for documentation however note that these methods are
+ * subject to change at any time.
+ */
+export var AddonManagerPrivate = {
+ startup() {
+ AddonManagerInternal.startup();
+ },
+
+ addonIsActive(addonId) {
+ return AddonManagerInternal._getProviderByName("XPIProvider").addonIsActive(
+ addonId
+ );
+ },
+
+ /**
+ * Gets an array of add-ons which were side-loaded prior to the last
+ * startup, and are currently disabled.
+ *
+ * @returns {Promise<Array<Addon>>}
+ */
+ getNewSideloads() {
+ return AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).getNewSideloads();
+ },
+
+ get browserUpdated() {
+ return gBrowserUpdated;
+ },
+
+ registerProvider(aProvider, aTypes) {
+ AddonManagerInternal.registerProvider(aProvider, aTypes);
+ },
+
+ unregisterProvider(aProvider) {
+ AddonManagerInternal.unregisterProvider(aProvider);
+ },
+
+ /**
+ * Get a list of addon types that was passed to registerProvider for the
+ * provider with the given name.
+ *
+ * @param {string} aProviderName
+ * @returns {Array<string>}
+ */
+ getAddonTypesByProvider(aProviderName) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ for (let [provider, addonTypes] of AddonManagerInternal.typesByProvider) {
+ if (providerName(provider) === aProviderName) {
+ // Return an array because methods such as getAddonsByTypes expect
+ // aTypes to be an array.
+ return Array.from(addonTypes);
+ }
+ }
+ throw Components.Exception(
+ `No addonTypes found for provider: ${aProviderName}`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ },
+
+ markProviderSafe(aProvider) {
+ AddonManagerInternal.markProviderSafe(aProvider);
+ },
+
+ backgroundUpdateCheck() {
+ return AddonManagerInternal.backgroundUpdateCheck();
+ },
+
+ backgroundUpdateTimerHandler() {
+ // Don't return the promise here, since the caller doesn't care.
+ AddonManagerInternal.backgroundUpdateCheck();
+ },
+
+ addStartupChange(aType, aID) {
+ AddonManagerInternal.addStartupChange(aType, aID);
+ },
+
+ removeStartupChange(aType, aID) {
+ AddonManagerInternal.removeStartupChange(aType, aID);
+ },
+
+ notifyAddonChanged(aID, aType, aPendingRestart) {
+ return AddonManagerInternal.notifyAddonChanged(aID, aType, aPendingRestart);
+ },
+
+ updateAddonAppDisabledStates() {
+ AddonManagerInternal.updateAddonAppDisabledStates();
+ },
+
+ updateAddonRepositoryData() {
+ return AddonManagerInternal.updateAddonRepositoryData();
+ },
+
+ callInstallListeners(...aArgs) {
+ return AddonManagerInternal.callInstallListeners.apply(
+ AddonManagerInternal,
+ aArgs
+ );
+ },
+
+ callAddonListeners(...aArgs) {
+ AddonManagerInternal.callAddonListeners.apply(AddonManagerInternal, aArgs);
+ },
+
+ AddonAuthor,
+
+ AddonScreenshot,
+
+ get BOOTSTRAP_REASONS() {
+ // BOOTSTRAP_REASONS is a set of constants, and may be accessed before the
+ // provider has fully been started.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1760146#c1
+ return gXPIProvider.BOOTSTRAP_REASONS;
+ },
+
+ setAddonStartupData(addonId, startupData) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ // TODO bug 1761079: Ensure that XPIProvider is available before calling it.
+ gXPIProvider.setStartupData(addonId, startupData);
+ },
+
+ unregisterDictionaries(aDicts) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ // TODO bug 1761093: Use _getProviderByName instead of gXPIProvider.
+ gXPIProvider.unregisterDictionaries(aDicts);
+ },
+
+ recordTimestamp(name, value) {
+ AddonManagerInternal.recordTimestamp(name, value);
+ },
+
+ _simpleMeasures: {},
+ recordSimpleMeasure(name, value) {
+ this._simpleMeasures[name] = value;
+ },
+
+ recordException(aModule, aContext, aException) {
+ let report = {
+ module: aModule,
+ context: aContext,
+ };
+
+ if (typeof aException == "number") {
+ report.message = Components.Exception("", aException).name;
+ } else {
+ report.message = aException.toString();
+ if (aException.fileName) {
+ report.file = aException.fileName;
+ report.line = aException.lineNumber;
+ }
+ }
+
+ this._simpleMeasures.exception = report;
+ },
+
+ getSimpleMeasures() {
+ return this._simpleMeasures;
+ },
+
+ getTelemetryDetails() {
+ return AddonManagerInternal.telemetryDetails;
+ },
+
+ setTelemetryDetails(aProvider, aDetails) {
+ AddonManagerInternal.telemetryDetails[aProvider] = aDetails;
+ },
+
+ // Start a timer, record a simple measure of the time interval when
+ // timer.done() is called
+ simpleTimer(aName) {
+ let startTime = Cu.now();
+ return {
+ done: () =>
+ this.recordSimpleMeasure(aName, Math.round(Cu.now() - startTime)),
+ };
+ },
+
+ async recordTiming(name, task) {
+ let timer = this.simpleTimer(name);
+ try {
+ return await task();
+ } finally {
+ timer.done();
+ }
+ },
+
+ /**
+ * Helper to call update listeners when no update is available.
+ *
+ * This can be used as an implementation for Addon.findUpdates() when
+ * no update mechanism is available.
+ */
+ callNoUpdateListeners(addon, listener, reason, appVersion, platformVersion) {
+ if ("onNoCompatibilityUpdateAvailable" in listener) {
+ safeCall(listener.onNoCompatibilityUpdateAvailable.bind(listener), addon);
+ }
+ if ("onNoUpdateAvailable" in listener) {
+ safeCall(listener.onNoUpdateAvailable.bind(listener), addon);
+ }
+ if ("onUpdateFinished" in listener) {
+ safeCall(listener.onUpdateFinished.bind(listener), addon);
+ }
+ },
+
+ get webExtensionsMinPlatformVersion() {
+ return gWebExtensionsMinPlatformVersion;
+ },
+
+ hasUpgradeListener(aId) {
+ return AddonManagerInternal.upgradeListeners.has(aId);
+ },
+
+ getUpgradeListener(aId) {
+ return AddonManagerInternal.upgradeListeners.get(aId);
+ },
+
+ get externalExtensionLoaders() {
+ return AddonManagerInternal.externalExtensionLoaders;
+ },
+
+ /**
+ * Predicate that returns true if we think the given extension ID
+ * might have been generated by XPIProvider.
+ */
+ isTemporaryInstallID(extensionId) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!extensionId || typeof extensionId != "string") {
+ throw Components.Exception(
+ "extensionId must be a string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ return AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).isTemporaryInstallID(extensionId);
+ },
+
+ isDBLoaded() {
+ let provider = AddonManagerInternal._getProviderByName("XPIProvider");
+ return provider ? provider.isDBLoaded : false;
+ },
+
+ get databaseReady() {
+ let provider = AddonManagerInternal._getProviderByName("XPIProvider");
+ return provider ? provider.databaseReady : new Promise(() => {});
+ },
+
+ /**
+ * Async shutdown barrier which blocks the completion of add-on
+ * manager shutdown. This should generally only be used by add-on
+ * providers (i.e., XPIProvider) to complete their final shutdown
+ * tasks.
+ */
+ get finalShutdown() {
+ return gFinalShutdownBarrier.client;
+ },
+
+ // Used by tests to call repo shutdown.
+ overrideAddonRepository(mockRepo) {
+ lazy.AddonRepository = mockRepo;
+ },
+
+ // Used by tests to shut down AddonManager.
+ overrideAsyncShutdown(mockAsyncShutdown) {
+ AsyncShutdown = mockAsyncShutdown;
+ },
+};
+
+/**
+ * This is the public API that UI and developers should be calling. All methods
+ * just forward to AddonManagerInternal.
+ * @class
+ */
+export var AddonManager = {
+ // Map used to convert the known install source hostnames into the value to set into the
+ // telemetry events.
+ _installHostSource: new Map([
+ ["addons.mozilla.org", "amo"],
+ ["discovery.addons.mozilla.org", "disco"],
+ ]),
+
+ // Constants for the AddonInstall.state property
+ // These will show up as AddonManager.STATE_* (eg, STATE_AVAILABLE)
+ _states: new Map([
+ // The install is available for download.
+ ["STATE_AVAILABLE", 0],
+ // The install is being downloaded.
+ ["STATE_DOWNLOADING", 1],
+ // The install is checking the update for compatibility information.
+ ["STATE_CHECKING_UPDATE", 2],
+ // The install is downloaded and ready to install.
+ ["STATE_DOWNLOADED", 3],
+ // The download failed.
+ ["STATE_DOWNLOAD_FAILED", 4],
+ // The install may not proceed until the user accepts a prompt
+ ["STATE_AWAITING_PROMPT", 5],
+ // Any prompts are done
+ ["STATE_PROMPTS_DONE", 6],
+ // The install has been postponed.
+ ["STATE_POSTPONED", 7],
+ // The install is ready to be applied.
+ ["STATE_READY", 8],
+ // The add-on is being installed.
+ ["STATE_INSTALLING", 9],
+ // The add-on has been installed.
+ ["STATE_INSTALLED", 10],
+ // The install failed.
+ ["STATE_INSTALL_FAILED", 11],
+ // The install has been cancelled.
+ ["STATE_CANCELLED", 12],
+ ]),
+
+ // Constants representing different types of errors while downloading an
+ // add-on as a preparation for installation.
+ // These will show up as AddonManager.ERROR_* (eg, ERROR_NETWORK_FAILURE)
+ // The _errors codes are translated to text for a panel in browser-addons.js.
+ // The localized messages are located in extensionsUI.ftl.
+ // Errors with the "Updates only:" prefix are not translated
+ // because the error is dumped to the console instead of a panel.
+ _errors: new Map([
+ // The download failed due to network problems.
+ ["ERROR_NETWORK_FAILURE", -1],
+ // The downloaded file did not match the provided hash.
+ ["ERROR_INCORRECT_HASH", -2],
+ // The downloaded file seems to be corrupted in some way.
+ ["ERROR_CORRUPT_FILE", -3],
+ // An error occurred trying to write to the filesystem.
+ ["ERROR_FILE_ACCESS", -4],
+ // The add-on must be signed and isn't.
+ ["ERROR_SIGNEDSTATE_REQUIRED", -5],
+ // Updates only: The downloaded add-on had a different type than expected.
+ ["ERROR_UNEXPECTED_ADDON_TYPE", -6],
+ // Updates only: The addon did not have the expected ID.
+ ["ERROR_INCORRECT_ID", -7],
+ // The addon install_origins does not list the 3rd party domain.
+ ["ERROR_INVALID_DOMAIN", -8],
+ // Updates only: The downloaded add-on had a different version than expected.
+ ["ERROR_UNEXPECTED_ADDON_VERSION", -9],
+ ]),
+ // The update check timed out
+ ERROR_TIMEOUT: -1,
+ // There was an error while downloading the update information.
+ ERROR_DOWNLOAD_ERROR: -2,
+ // The update information was malformed in some way.
+ ERROR_PARSE_ERROR: -3,
+ // The update information was not in any known format.
+ ERROR_UNKNOWN_FORMAT: -4,
+ // The update information was not correctly signed or there was an SSL error.
+ ERROR_SECURITY_ERROR: -5,
+ // The update was cancelled
+ ERROR_CANCELLED: -6,
+ // These must be kept in sync with AddonUpdateChecker.
+ // No error was encountered.
+ UPDATE_STATUS_NO_ERROR: 0,
+ // The update check timed out
+ UPDATE_STATUS_TIMEOUT: -1,
+ // There was an error while downloading the update information.
+ UPDATE_STATUS_DOWNLOAD_ERROR: -2,
+ // The update information was malformed in some way.
+ UPDATE_STATUS_PARSE_ERROR: -3,
+ // The update information was not in any known format.
+ UPDATE_STATUS_UNKNOWN_FORMAT: -4,
+ // The update information was not correctly signed or there was an SSL error.
+ UPDATE_STATUS_SECURITY_ERROR: -5,
+ // The update was cancelled.
+ UPDATE_STATUS_CANCELLED: -6,
+ // Constants to indicate why an update check is being performed
+ // Update check has been requested by the user.
+ UPDATE_WHEN_USER_REQUESTED: 1,
+ // Update check is necessary to see if the Addon is compatibile with a new
+ // version of the application.
+ UPDATE_WHEN_NEW_APP_DETECTED: 2,
+ // Update check is necessary because a new application has been installed.
+ UPDATE_WHEN_NEW_APP_INSTALLED: 3,
+ // Update check is a regular background update check.
+ UPDATE_WHEN_PERIODIC_UPDATE: 16,
+ // Update check is needed to check an Addon that is being installed.
+ UPDATE_WHEN_ADDON_INSTALLED: 17,
+
+ // Constants for operations in Addon.pendingOperations
+ // Indicates that the Addon has no pending operations.
+ PENDING_NONE: 0,
+ // Indicates that the Addon will be enabled after the application restarts.
+ PENDING_ENABLE: 1,
+ // Indicates that the Addon will be disabled after the application restarts.
+ PENDING_DISABLE: 2,
+ // Indicates that the Addon will be uninstalled after the application restarts.
+ PENDING_UNINSTALL: 4,
+ // Indicates that the Addon will be installed after the application restarts.
+ PENDING_INSTALL: 8,
+ PENDING_UPGRADE: 16,
+
+ // Constants for operations in Addon.operationsRequiringRestart
+ // Indicates that restart isn't required for any operation.
+ OP_NEEDS_RESTART_NONE: 0,
+ // Indicates that restart is required for enabling the addon.
+ OP_NEEDS_RESTART_ENABLE: 1,
+ // Indicates that restart is required for disabling the addon.
+ OP_NEEDS_RESTART_DISABLE: 2,
+ // Indicates that restart is required for uninstalling the addon.
+ OP_NEEDS_RESTART_UNINSTALL: 4,
+ // Indicates that restart is required for installing the addon.
+ OP_NEEDS_RESTART_INSTALL: 8,
+
+ // Constants for permissions in Addon.permissions.
+ // Indicates that the Addon can be uninstalled.
+ PERM_CAN_UNINSTALL: 1,
+ // Indicates that the Addon can be enabled by the user.
+ PERM_CAN_ENABLE: 2,
+ // Indicates that the Addon can be disabled by the user.
+ PERM_CAN_DISABLE: 4,
+ // Indicates that the Addon can be upgraded.
+ PERM_CAN_UPGRADE: 8,
+ // Indicates that the Addon can be set to be allowed/disallowed
+ // in private browsing windows.
+ PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS: 32,
+ // Indicates that internal APIs can uninstall the add-on, even if the
+ // front-end cannot.
+ PERM_API_CAN_UNINSTALL: 64,
+
+ // General descriptions of where items are installed.
+ // Installed in this profile.
+ SCOPE_PROFILE: 1,
+ // Installed for all of this user's profiles.
+ SCOPE_USER: 2,
+ // Installed and owned by the application.
+ SCOPE_APPLICATION: 4,
+ // Installed for all users of the computer.
+ SCOPE_SYSTEM: 8,
+ // Installed temporarily
+ SCOPE_TEMPORARY: 16,
+ // The combination of all scopes.
+ SCOPE_ALL: 31,
+
+ // Constants for Addon.applyBackgroundUpdates.
+ // Indicates that the Addon should not update automatically.
+ AUTOUPDATE_DISABLE: 0,
+ // Indicates that the Addon should update automatically only if
+ // that's the global default.
+ AUTOUPDATE_DEFAULT: 1,
+ // Indicates that the Addon should update automatically.
+ AUTOUPDATE_ENABLE: 2,
+
+ // Constants for how Addon options should be shown.
+ // Options will be displayed in a new tab, if possible
+ OPTIONS_TYPE_TAB: 3,
+ // Similar to OPTIONS_TYPE_INLINE, but rather than generating inline
+ // options from a specially-formatted XUL file, the contents of the
+ // file are simply displayed in an inline <browser> element.
+ OPTIONS_TYPE_INLINE_BROWSER: 5,
+
+ // Constants for displayed or hidden options notifications
+ // Options notification will be displayed
+ OPTIONS_NOTIFICATION_DISPLAYED: "addon-options-displayed",
+ // Options notification will be hidden
+ OPTIONS_NOTIFICATION_HIDDEN: "addon-options-hidden",
+
+ // Constants for getStartupChanges, addStartupChange and removeStartupChange
+ // Add-ons that were detected as installed during startup. Doesn't include
+ // add-ons that were pending installation the last time the application ran.
+ STARTUP_CHANGE_INSTALLED: "installed",
+ // Add-ons that were detected as changed during startup. This includes an
+ // add-on moving to a different location, changing version or just having
+ // been detected as possibly changed.
+ STARTUP_CHANGE_CHANGED: "changed",
+ // Add-ons that were detected as uninstalled during startup. Doesn't include
+ // add-ons that were pending uninstallation the last time the application ran.
+ STARTUP_CHANGE_UNINSTALLED: "uninstalled",
+ // Add-ons that were detected as disabled during startup, normally because of
+ // an application change making an add-on incompatible. Doesn't include
+ // add-ons that were pending being disabled the last time the application ran.
+ STARTUP_CHANGE_DISABLED: "disabled",
+ // Add-ons that were detected as enabled during startup, normally because of
+ // an application change making an add-on compatible. Doesn't include
+ // add-ons that were pending being enabled the last time the application ran.
+ STARTUP_CHANGE_ENABLED: "enabled",
+
+ // Constants for Addon.signedState. Any states that should cause an add-on
+ // to be unusable in builds that require signing should have negative values.
+ // Add-on signing is not required, e.g. because the pref is disabled.
+ SIGNEDSTATE_NOT_REQUIRED: undefined,
+ // Add-on is signed but signature verification has failed.
+ SIGNEDSTATE_BROKEN: -2,
+ // Add-on may be signed but by an certificate that doesn't chain to our
+ // our trusted certificate.
+ SIGNEDSTATE_UNKNOWN: -1,
+ // Add-on is unsigned.
+ SIGNEDSTATE_MISSING: 0,
+ // Add-on is preliminarily reviewed.
+ SIGNEDSTATE_PRELIMINARY: 1,
+ // Add-on is fully reviewed.
+ SIGNEDSTATE_SIGNED: 2,
+ // Add-on is system add-on.
+ SIGNEDSTATE_SYSTEM: 3,
+ // Add-on is signed with a "Mozilla Extensions" certificate
+ SIGNEDSTATE_PRIVILEGED: 4,
+
+ get __AddonManagerInternal__() {
+ return AppConstants.DEBUG ? AddonManagerInternal : undefined;
+ },
+
+ /** Boolean indicating whether AddonManager startup has completed. */
+ get isReady() {
+ return gStartupComplete && !gShutdownInProgress;
+ },
+
+ /**
+ * A promise that is resolved when the AddonManager startup has completed.
+ * This may be rejected if startup of the AddonManager is not successful, or
+ * if shutdown is started before the AddonManager has finished starting.
+ */
+ get readyPromise() {
+ return gStartedPromise.promise;
+ },
+
+ /** @constructor */
+ init() {
+ this._stateToString = new Map();
+ for (let [name, value] of this._states) {
+ this[name] = value;
+ this._stateToString.set(value, name);
+ }
+ this._errorToString = new Map();
+ for (let [name, value] of this._errors) {
+ this[name] = value;
+ this._errorToString.set(value, name);
+ }
+ },
+
+ stateToString(state) {
+ return this._stateToString.get(state);
+ },
+
+ errorToString(err) {
+ return err ? this._errorToString.get(err) : null;
+ },
+
+ getInstallSourceFromHost(host) {
+ if (this._installHostSource.has(host)) {
+ return this._installHostSource.get(host);
+ }
+
+ if (WEBAPI_TEST_INSTALL_HOSTS.includes(host)) {
+ return "test-host";
+ }
+
+ return "unknown";
+ },
+
+ getInstallForURL(aUrl, aOptions) {
+ return AddonManagerInternal.getInstallForURL(aUrl, aOptions);
+ },
+
+ getInstallForFile(
+ aFile,
+ aMimetype,
+ aTelemetryInfo,
+ aUseSystemLocation = false
+ ) {
+ return AddonManagerInternal.getInstallForFile(
+ aFile,
+ aMimetype,
+ aTelemetryInfo,
+ aUseSystemLocation
+ );
+ },
+
+ uninstallSystemProfileAddon(aID) {
+ return AddonManagerInternal.uninstallSystemProfileAddon(aID);
+ },
+
+ stageLangpacksForAppUpdate(appVersion, platformVersion) {
+ return AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).stageLangpacksForAppUpdate(appVersion, platformVersion);
+ },
+
+ /**
+ * Gets an array of add-on IDs that changed during the most recent startup.
+ *
+ * @param aType
+ * The type of startup change to get
+ * @return An array of add-on IDs
+ */
+ getStartupChanges(aType) {
+ if (!(aType in AddonManagerInternal.startupChanges)) {
+ return [];
+ }
+ return AddonManagerInternal.startupChanges[aType].slice(0);
+ },
+
+ getAddonByID(aID) {
+ return AddonManagerInternal.getAddonByID(aID);
+ },
+
+ getAddonBySyncGUID(aGUID) {
+ return AddonManagerInternal.getAddonBySyncGUID(aGUID);
+ },
+
+ getAddonsByIDs(aIDs) {
+ return AddonManagerInternal.getAddonsByIDs(aIDs);
+ },
+
+ getAddonsByTypes(aTypes) {
+ return AddonManagerInternal.getAddonsByTypes(aTypes);
+ },
+
+ getActiveAddons(aTypes) {
+ return AddonManagerInternal.getActiveAddons(aTypes);
+ },
+
+ getAllAddons() {
+ return AddonManagerInternal.getAllAddons();
+ },
+
+ getInstallsByTypes(aTypes) {
+ return AddonManagerInternal.getInstallsByTypes(aTypes);
+ },
+
+ getAllInstalls() {
+ return AddonManagerInternal.getAllInstalls();
+ },
+
+ isInstallEnabled(aType) {
+ return AddonManagerInternal.isInstallEnabled(aType);
+ },
+
+ isInstallAllowed(aType, aInstallingPrincipal) {
+ return AddonManagerInternal.isInstallAllowed(aType, aInstallingPrincipal);
+ },
+
+ installSitePermsAddonFromWebpage(
+ aBrowser,
+ aInstallingPrincipal,
+ aPermission
+ ) {
+ return AddonManagerInternal.installSitePermsAddonFromWebpage(
+ aBrowser,
+ aInstallingPrincipal,
+ aPermission
+ );
+ },
+
+ installAddonFromWebpage(
+ aType,
+ aBrowser,
+ aInstallingPrincipal,
+ aInstall,
+ details
+ ) {
+ AddonManagerInternal.installAddonFromWebpage(
+ aType,
+ aBrowser,
+ aInstallingPrincipal,
+ aInstall,
+ details
+ );
+ },
+
+ installAddonFromAOM(aBrowser, aUri, aInstall) {
+ AddonManagerInternal.installAddonFromAOM(aBrowser, aUri, aInstall);
+ },
+
+ installTemporaryAddon(aDirectory) {
+ return AddonManagerInternal.installTemporaryAddon(aDirectory);
+ },
+
+ installBuiltinAddon(aBase) {
+ return AddonManagerInternal.installBuiltinAddon(aBase);
+ },
+
+ maybeInstallBuiltinAddon(aID, aVersion, aBase) {
+ return AddonManagerInternal.maybeInstallBuiltinAddon(aID, aVersion, aBase);
+ },
+
+ addManagerListener(aListener) {
+ AddonManagerInternal.addManagerListener(aListener);
+ },
+
+ removeManagerListener(aListener) {
+ AddonManagerInternal.removeManagerListener(aListener);
+ },
+
+ addInstallListener(aListener) {
+ AddonManagerInternal.addInstallListener(aListener);
+ },
+
+ removeInstallListener(aListener) {
+ AddonManagerInternal.removeInstallListener(aListener);
+ },
+
+ getUpgradeListener(aId) {
+ return AddonManagerInternal.upgradeListeners.get(aId);
+ },
+
+ addUpgradeListener(aInstanceID, aCallback) {
+ AddonManagerInternal.addUpgradeListener(aInstanceID, aCallback);
+ },
+
+ removeUpgradeListener(aInstanceID) {
+ return AddonManagerInternal.removeUpgradeListener(aInstanceID);
+ },
+
+ addExternalExtensionLoader(loader) {
+ return AddonManagerInternal.addExternalExtensionLoader(loader);
+ },
+
+ addAddonListener(aListener) {
+ AddonManagerInternal.addAddonListener(aListener);
+ },
+
+ removeAddonListener(aListener) {
+ AddonManagerInternal.removeAddonListener(aListener);
+ },
+
+ hasAddonType(addonType) {
+ return AddonManagerInternal.hasAddonType(addonType);
+ },
+
+ hasProvider(name) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+ return !!AddonManagerInternal._getProviderByName(name);
+ },
+
+ /**
+ * Determines whether an Addon should auto-update or not.
+ *
+ * @param aAddon
+ * The Addon representing the add-on
+ * @return true if the addon should auto-update, false otherwise.
+ */
+ shouldAutoUpdate(aAddon) {
+ if (!aAddon || typeof aAddon != "object") {
+ throw Components.Exception(
+ "aAddon must be specified",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ // Special case colorway built-in themes being migrated to an AMO installed theme
+ // when an update was found and:
+ //
+ // - `extensions.update.enable` is set to true (and so add-on updates are still
+ // being checked automatically on the background)
+ // - `extensions.update.autoUpdateDefault` is set to false (likely because the
+ // user has disabled auto-applying add-ons updates in about:addons to review
+ // extensions changelogs before accepting an update, e.g. to avoid unexpected
+ // issues that a new version of an extension may be introducing in the update)
+ //
+ // TODO(Bug 1815898): remove this special case along with other AOM/XPIProvider
+ // special cases introduced for colorways themes or colorways migration.
+ if (aAddon.isBuiltinColorwayTheme) {
+ return true;
+ }
+
+ if (!("applyBackgroundUpdates" in aAddon)) {
+ return false;
+ }
+ if (!(aAddon.permissions & AddonManager.PERM_CAN_UPGRADE)) {
+ return false;
+ }
+ if (aAddon.applyBackgroundUpdates == AddonManager.AUTOUPDATE_ENABLE) {
+ return true;
+ }
+ if (aAddon.applyBackgroundUpdates == AddonManager.AUTOUPDATE_DISABLE) {
+ return false;
+ }
+ return this.autoUpdateDefault;
+ },
+
+ get checkCompatibility() {
+ return AddonManagerInternal.checkCompatibility;
+ },
+
+ set checkCompatibility(aValue) {
+ AddonManagerInternal.checkCompatibility = aValue;
+ },
+
+ get strictCompatibility() {
+ return AddonManagerInternal.strictCompatibility;
+ },
+
+ set strictCompatibility(aValue) {
+ AddonManagerInternal.strictCompatibility = aValue;
+ },
+
+ get checkUpdateSecurityDefault() {
+ return AddonManagerInternal.checkUpdateSecurityDefault;
+ },
+
+ get checkUpdateSecurity() {
+ return AddonManagerInternal.checkUpdateSecurity;
+ },
+
+ set checkUpdateSecurity(aValue) {
+ AddonManagerInternal.checkUpdateSecurity = aValue;
+ },
+
+ get updateEnabled() {
+ return AddonManagerInternal.updateEnabled;
+ },
+
+ set updateEnabled(aValue) {
+ AddonManagerInternal.updateEnabled = aValue;
+ },
+
+ get autoUpdateDefault() {
+ return AddonManagerInternal.autoUpdateDefault;
+ },
+
+ set autoUpdateDefault(aValue) {
+ AddonManagerInternal.autoUpdateDefault = aValue;
+ },
+
+ escapeAddonURI(aAddon, aUri, aAppVersion) {
+ return AddonManagerInternal.escapeAddonURI(aAddon, aUri, aAppVersion);
+ },
+
+ getPreferredIconURL(aAddon, aSize, aWindow = undefined) {
+ return AddonManagerInternal.getPreferredIconURL(aAddon, aSize, aWindow);
+ },
+
+ get webAPI() {
+ return AddonManagerInternal.webAPI;
+ },
+
+ /**
+ * Async shutdown barrier which blocks the start of AddonManager
+ * shutdown. Callers should add blockers to this barrier if they need
+ * to complete add-on manager operations before it shuts down.
+ */
+ get beforeShutdown() {
+ return gBeforeShutdownBarrier.client;
+ },
+};
+
+/**
+ * Manage AddonManager settings propagated over RemoteSettings synced data.
+ *
+ * See :doc:`AMRemoteSettings Overview <AMRemoteSettings-overview>`.
+ *
+ * .. warning::
+ * Before landing any change to ``AMRemoteSettings`` or the format expected for the
+ * remotely controlled settings (on the service or Firefos side), please read the
+ * documentation page linked above and make sure to keep the JSON Schema described
+ * and controlled settings groups included in that documentation page in sync with
+ * the one actually set on the RemoteSettings service side.
+ */
+AMRemoteSettings = {
+ RS_COLLECTION: "addons-manager-settings",
+
+ /**
+ * RemoteSettings settings group map.
+ *
+ * .. note::
+ * Please keep in sync the "Controlled Settings Groups" documentation from
+ * :doc:`AMRemoteSettings Overview <AMRemoteSettings-overview>` in sync with
+ * the settings groups defined here.
+ */
+ RS_ENTRIES_MAP: {
+ installTriggerDeprecation: [
+ "extensions.InstallTriggerImpl.enabled",
+ "extensions.InstallTrigger.enabled",
+ ],
+ quarantinedDomains: ["extensions.quarantinedDomains.list"],
+ },
+
+ client: null,
+ onSync: null,
+ promiseStartup: null,
+
+ init() {
+ try {
+ if (!this.promiseStartup) {
+ // Creating a promise to resolved when the browser startup was completed,
+ // used to process the existing entries (if any) after the startup is completed
+ // and to only to it ones.
+ this.promiseStartup = new Promise(resolve => {
+ function observer() {
+ resolve();
+ Services.obs.removeObserver(
+ observer,
+ "browser-delayed-startup-finished"
+ );
+ }
+ Services.obs.addObserver(
+ observer,
+ "browser-delayed-startup-finished"
+ );
+ });
+ }
+
+ if (Services.prefs.getBoolPref(PREF_REMOTESETTINGS_DISABLED, false)) {
+ return;
+ }
+
+ if (!this.client) {
+ this.client = lazy.RemoteSettings(this.RS_COLLECTION);
+ this.onSync = this.processEntries.bind(this);
+ this.client.on("sync", this.onSync);
+ // Process existing entries if any, once the browser has been fully initialized.
+ this.promiseStartup.then(() => this.processEntries());
+ }
+ } catch (err) {
+ logger.error("Failure to initialize AddonManager RemoteSettings", err);
+ }
+ },
+
+ shutdown() {
+ try {
+ if (this.client) {
+ this.client.off("sync", this.onSync);
+ this.client = null;
+ this.onSync = null;
+ }
+ this.promiseStartup = null;
+ } catch (err) {
+ logger.error("Failure on shutdown AddonManager RemoteSettings", err);
+ }
+ },
+
+ /**
+ * Process all the settings groups that are included in the collection entry with ``"id"`` set to ``"AddonManagerSettings"``
+ * (if any).
+ *
+ * .. note::
+ * This method may need to be updated if the preference value type is not yet expected by this method
+ * (which means that it would be ignored until handled explicitly).
+ */
+ async processEntries() {
+ const entries = await this.client.get({ syncIfEmpty: false }).catch(err => {
+ logger.error("Failure to process AddonManager RemoteSettings", err);
+ return [];
+ });
+
+ const processEntryPref = (entryId, groupName, prefName, prefValue) => {
+ try {
+ logger.debug(
+ `Process AddonManager RemoteSettings "${entryId}" - "${groupName}": ${prefName}`
+ );
+
+ // Support for controlling boolean and string AddonManager settings.
+ switch (typeof prefValue) {
+ case "boolean":
+ Services.prefs.setBoolPref(prefName, prefValue);
+ break;
+ case "string":
+ Services.prefs.setStringPref(prefName, prefValue);
+ break;
+ default:
+ throw new Error(`Unexpected type ${typeof prefValue}`);
+ }
+ } catch (e) {
+ logger.error(
+ `Failed to process AddonManager RemoteSettings "${entryId}" - "${groupName}": ${prefName}`,
+ e
+ );
+ }
+ };
+
+ for (const entry of entries) {
+ logger.debug(`Processing AddonManager RemoteSettings "${entry.id}"`);
+
+ for (const [groupName, prefs] of Object.entries(this.RS_ENTRIES_MAP)) {
+ const data = entry[groupName];
+ if (!data) {
+ continue;
+ }
+
+ for (const pref of prefs) {
+ // Skip the pref if it is not included in the remote settings data.
+ if (!(pref in data)) {
+ continue;
+ }
+
+ processEntryPref(entry.id, groupName, pref, data[pref]);
+ }
+ }
+ }
+ },
+};
+
+/**
+ * Listens to the AddonManager install and addon events and send telemetry events.
+ */
+AMTelemetry = {
+ telemetrySetupDone: false,
+
+ init() {
+ // Enable the addonsManager telemetry event category before the AddonManager
+ // has completed its startup, otherwise telemetry events recorded during the
+ // AddonManager/XPIProvider startup will not be recorded.
+ Services.telemetry.setEventRecordingEnabled("addonsManager", true);
+ },
+
+ // This method is called by the AddonManager, once it has been started, so that we can
+ // init the telemetry event category and start listening for the events related to the
+ // addons installation and management.
+ onStartup() {
+ if (this.telemetrySetupDone) {
+ return;
+ }
+
+ this.telemetrySetupDone = true;
+
+ Services.obs.addObserver(this, "addon-install-origin-blocked");
+ Services.obs.addObserver(this, "addon-install-disabled");
+ Services.obs.addObserver(this, "addon-install-blocked");
+
+ AddonManager.addInstallListener(this);
+ AddonManager.addAddonListener(this);
+ },
+
+ // Observer Service notification callback.
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "addon-install-blocked": {
+ const { installs } = subject.wrappedJSObject;
+ this.recordInstallEvent(installs[0], { step: "site_warning" });
+ break;
+ }
+ case "addon-install-origin-blocked": {
+ const { installs } = subject.wrappedJSObject;
+ this.recordInstallEvent(installs[0], { step: "site_blocked" });
+ break;
+ }
+ case "addon-install-disabled": {
+ const { installs } = subject.wrappedJSObject;
+ this.recordInstallEvent(installs[0], {
+ step: "install_disabled_warning",
+ });
+ break;
+ }
+ }
+ },
+
+ // AddonManager install listener callbacks.
+
+ onNewInstall(install) {
+ this.recordInstallEvent(install, { step: "started" });
+ },
+
+ onInstallCancelled(install) {
+ this.recordInstallEvent(install, { step: "cancelled" });
+ },
+
+ onInstallPostponed(install) {
+ this.recordInstallEvent(install, { step: "postponed" });
+ },
+
+ onInstallFailed(install) {
+ this.recordInstallEvent(install, { step: "failed" });
+ },
+
+ onInstallEnded(install) {
+ this.recordInstallEvent(install, { step: "completed" });
+ // Skip install_stats events for install objects related to.
+ // add-on updates.
+ if (!install.existingAddon) {
+ this.recordInstallStatsEvent(install);
+ }
+ },
+
+ onDownloadStarted(install) {
+ this.recordInstallEvent(install, { step: "download_started" });
+ },
+
+ onDownloadCancelled(install) {
+ this.recordInstallEvent(install, { step: "cancelled" });
+ },
+
+ onDownloadEnded(install) {
+ let download_time = Math.round(Cu.now() - install.downloadStartedAt);
+ this.recordInstallEvent(install, {
+ step: "download_completed",
+ download_time,
+ });
+ },
+
+ onDownloadFailed(install) {
+ let download_time = Math.round(Cu.now() - install.downloadStartedAt);
+ this.recordInstallEvent(install, {
+ step: "download_failed",
+ download_time,
+ });
+ },
+
+ // Addon listeners callbacks.
+
+ onUninstalled(addon) {
+ this.recordManageEvent(addon, "uninstall");
+ },
+
+ onEnabled(addon) {
+ this.recordManageEvent(addon, "enable");
+ },
+
+ onDisabled(addon) {
+ this.recordManageEvent(addon, "disable");
+ },
+
+ // Internal helpers methods.
+
+ /**
+ * Get a trimmed version of the given string if it is longer than 80 chars.
+ *
+ * @param {string} str
+ * The original string content.
+ *
+ * @returns {string}
+ * The trimmed version of the string when longer than 80 chars, or the given string
+ * unmodified otherwise.
+ */
+ getTrimmedString(str) {
+ if (str.length <= 80) {
+ return str;
+ }
+
+ const length = str.length;
+
+ // Trim the string to prevent a flood of warnings messages logged internally by recordEvent,
+ // the trimmed version is going to be composed by the first 40 chars and the last 37 and 3 dots
+ // that joins the two parts, to visually indicate that the string has been trimmed.
+ return `${str.slice(0, 40)}...${str.slice(length - 37, length)}`;
+ },
+
+ /**
+ * Retrieve the addonId for the given AddonInstall instance.
+ *
+ * @param {AddonInstall} install
+ * The AddonInstall instance to retrieve the addonId from.
+ *
+ * @returns {string | null}
+ * The addonId for the given AddonInstall instance (if any).
+ */
+ getAddonIdFromInstall(install) {
+ // Returns the id of the extension that is being installed, as soon as the
+ // addon is available in the AddonInstall instance (after being downloaded
+ // and validated successfully).
+ if (install.addon) {
+ return install.addon.id;
+ }
+
+ // While updating an addon, the existing addon can be
+ // used to retrieve the addon id since the first update event.
+ if (install.existingAddon) {
+ return install.existingAddon.id;
+ }
+
+ return null;
+ },
+
+ /**
+ * Retrieve the telemetry event's object property value for the given
+ * AddonInstall instance.
+ *
+ * @param {AddonInstall} install
+ * The AddonInstall instance to retrieve the event object from.
+ *
+ * @returns {string}
+ * The object for the given AddonInstall instance.
+ */
+ getEventObjectFromInstall(install) {
+ let addonType;
+
+ if (install.type) {
+ // The AddonInstall wrapper already provides a type (if it was known when the
+ // install object has been created).
+ addonType = install.type;
+ } else if (install.addon) {
+ // The install flow has reached a step that has an addon instance which we can
+ // check to know the extension type (e.g. after download for the DownloadAddonInstall).
+ addonType = install.addon.type;
+ } else if (install.existingAddon) {
+ // The install flow is an update and we can look the existingAddon to check which was
+ // the add-on type that is being installed.
+ addonType = install.existingAddon.type;
+ }
+
+ return this.getEventObjectFromAddonType(addonType);
+ },
+
+ /**
+ * Retrieve the telemetry event source for the given AddonInstall instance.
+ *
+ * @param {AddonInstall} install
+ * The AddonInstall instance to retrieve the source from.
+ *
+ * @returns {Object | null}
+ * The telemetry infor ({source, method}) from the given AddonInstall instance.
+ */
+ getInstallTelemetryInfo(install) {
+ if (install.installTelemetryInfo) {
+ return install.installTelemetryInfo;
+ } else if (
+ install.existingAddon &&
+ install.existingAddon.installTelemetryInfo
+ ) {
+ // Get the install source from the existing addon (e.g. for an extension update).
+ return install.existingAddon.installTelemetryInfo;
+ }
+
+ return null;
+ },
+
+ /**
+ * Get the telemetry event's object property for the given addon type
+ *
+ * @param {string} addonType
+ * The addon type to convert into the related telemetry event object.
+ *
+ * @returns {string}
+ * The object for the given addon type.
+ */
+ getEventObjectFromAddonType(addonType) {
+ switch (addonType) {
+ case undefined:
+ return "unknown";
+ case "extension":
+ case "theme":
+ case "locale":
+ case "dictionary":
+ case "sitepermission":
+ return addonType;
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ case "sitepermission-deprecated":
+ // Telemetry events' object maximum length is 20 chars (See https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/collection/events.html#limits)
+ // and the value needs to matching the "^[a-zA-Z][a-zA-Z0-9_.]*[a-zA-Z0-9]$" pattern.
+ return "siteperm_deprecated";
+ default:
+ // Currently this should only include gmp-plugins ("plugin").
+ return "other";
+ }
+ },
+
+ convertToString(value) {
+ if (value == null) {
+ // Convert null and undefined to empty strings.
+ return "";
+ }
+ switch (typeof value) {
+ case "string":
+ return value;
+ case "boolean":
+ return value ? "1" : "0";
+ }
+ return String(value);
+ },
+
+ /**
+ * Return the UTM parameters found in `sourceURL` for AMO attribution data.
+ *
+ * @param {string} sourceURL
+ * The source URL from where the add-on has been installed.
+ *
+ * @returns {object}
+ * An object containing the attribution data for AMO if any. Keys
+ * are defined in `AMO_ATTRIBUTION_DATA_KEYS`. Values are strings.
+ */
+ parseAttributionDataForAMO(sourceURL) {
+ let searchParams;
+
+ try {
+ searchParams = new URL(sourceURL).searchParams;
+ } catch {
+ return {};
+ }
+
+ const utmKeys = [...searchParams.keys()].filter(key =>
+ AMO_ATTRIBUTION_DATA_KEYS.includes(key)
+ );
+
+ return utmKeys.reduce((params, key) => {
+ let value = searchParams.get(key);
+ if (typeof value === "string") {
+ value = value.slice(0, AMO_ATTRIBUTION_DATA_MAX_LENGTH);
+ }
+
+ return { ...params, [key]: value };
+ }, {});
+ },
+
+ /**
+ * Record an "install stats" event when the source is included in
+ * `AMO_ATTRIBUTION_ALLOWED_SOURCES`.
+ *
+ * @param {AddonInstall} install
+ * The AddonInstall instance to record an install_stats event for.
+ */
+ recordInstallStatsEvent(install) {
+ const telemetryInfo = this.getInstallTelemetryInfo(install);
+
+ if (!AMO_ATTRIBUTION_ALLOWED_SOURCES.includes(telemetryInfo?.source)) {
+ return;
+ }
+
+ const method = "install_stats";
+ const object = this.getEventObjectFromInstall(install);
+ const addonId = this.getAddonIdFromInstall(install);
+
+ if (!addonId) {
+ Cu.reportError(
+ "Missing addonId when trying to record an install_stats event"
+ );
+ return;
+ }
+
+ let extra = {
+ addon_id: this.getTrimmedString(addonId),
+ };
+
+ if (
+ telemetryInfo?.source === "amo" &&
+ typeof telemetryInfo?.sourceURL === "string"
+ ) {
+ extra = {
+ ...extra,
+ ...this.parseAttributionDataForAMO(telemetryInfo.sourceURL),
+ };
+ }
+
+ if (
+ telemetryInfo?.source === "disco" &&
+ typeof telemetryInfo?.taarRecommended === "boolean"
+ ) {
+ extra = {
+ ...extra,
+ taar_based: this.convertToString(telemetryInfo.taarRecommended),
+ };
+ }
+
+ this.recordEvent({ method, object, value: install.hashedAddonId, extra });
+ },
+
+ /**
+ * Convert all the telemetry event's extra_vars into strings, if needed.
+ *
+ * @param {object} extraVars
+ * @returns {object} The formatted extra vars.
+ */
+ formatExtraVars({ addon, ...extraVars }) {
+ if (addon) {
+ extraVars.addonId = addon.id;
+ extraVars.type = addon.type;
+ }
+
+ // All the extra_vars in a telemetry event have to be strings.
+ for (var [key, value] of Object.entries(extraVars)) {
+ if (value == undefined) {
+ delete extraVars[key];
+ } else {
+ extraVars[key] = this.convertToString(value);
+ }
+ }
+
+ if (extraVars.addonId) {
+ extraVars.addonId = this.getTrimmedString(extraVars.addonId);
+ }
+
+ return extraVars;
+ },
+
+ /**
+ * Record an install or update event for the given AddonInstall instance.
+ *
+ * @param {AddonInstall} install
+ * The AddonInstall instance to record an install or update event for.
+ * @param {object} extraVars
+ * The additional extra_vars to include in the recorded event.
+ * @param {string} extraVars.step
+ * The current step in the install or update flow.
+ * @param {string} extraVars.download_time
+ * The number of ms needed to download the extension.
+ * @param {string} extraVars.num_strings
+ * The number of permission description string for the extension
+ * permission doorhanger.
+ */
+ recordInstallEvent(install, extraVars) {
+ // Early exit if AMTelemetry's telemetry setup has not been done yet.
+ if (!this.telemetrySetupDone) {
+ return;
+ }
+
+ let extra = {};
+
+ let telemetryInfo = this.getInstallTelemetryInfo(install);
+ if (telemetryInfo && typeof telemetryInfo.source === "string") {
+ extra.source = telemetryInfo.source;
+ }
+
+ if (extra.source === "internal") {
+ // Do not record the telemetry event for installation sources
+ // that are marked as "internal".
+ return;
+ }
+
+ // Also include the install source's method when applicable (e.g. install events with
+ // source "about:addons" may have "install-from-file" or "url" as their source method).
+ if (telemetryInfo && typeof telemetryInfo.method === "string") {
+ extra.method = telemetryInfo.method;
+ }
+
+ let addonId = this.getAddonIdFromInstall(install);
+ let object = this.getEventObjectFromInstall(install);
+
+ let installId = String(install.installId);
+ let eventMethod = install.existingAddon ? "update" : "install";
+
+ if (addonId) {
+ extra.addon_id = this.getTrimmedString(addonId);
+ }
+
+ if (install.error) {
+ extra.error = AddonManager.errorToString(install.error);
+ }
+
+ if (
+ eventMethod === "install" &&
+ Services.prefs.getBoolPref("extensions.install_origins.enabled", true)
+ ) {
+ // This is converted to "1" / "0".
+ extra.install_origins = Array.isArray(install.addon?.installOrigins);
+ }
+
+ if (eventMethod === "update") {
+ // For "update" telemetry events, also include an extra var which determine
+ // if the update has been requested by the user.
+ extra.updated_from = install.isUserRequestedUpdate ? "user" : "app";
+ }
+
+ // All the extra vars in a telemetry event have to be strings.
+ extra = this.formatExtraVars({ ...extraVars, ...extra });
+
+ this.recordEvent({ method: eventMethod, object, value: installId, extra });
+ },
+
+ /**
+ * Record a manage event for the given addon.
+ *
+ * @param {AddonWrapper} addon
+ * The AddonWrapper instance.
+ * @param {object} extraVars
+ * The additional extra_vars to include in the recorded event.
+ * @param {string} extraVars.num_strings
+ * The number of permission description string for the extension
+ * permission doorhanger.
+ */
+ recordManageEvent(addon, method, extraVars) {
+ // Early exit if AMTelemetry's telemetry setup has not been done yet.
+ if (!this.telemetrySetupDone) {
+ return;
+ }
+
+ let extra = {};
+
+ if (addon.installTelemetryInfo) {
+ if ("source" in addon.installTelemetryInfo) {
+ extra.source = addon.installTelemetryInfo.source;
+ }
+
+ // Also include the install source's method when applicable (e.g. install events with
+ // source "about:addons" may have "install-from-file" or "url" as their source method).
+ if ("method" in addon.installTelemetryInfo) {
+ extra.method = addon.installTelemetryInfo.method;
+ }
+ }
+
+ if (extra.source === "internal") {
+ // Do not record the telemetry event for installation sources
+ // that are marked as "internal".
+ return;
+ }
+
+ let object = this.getEventObjectFromAddonType(addon.type);
+ let value = this.getTrimmedString(addon.id);
+
+ extra = { ...extraVars, ...extra };
+
+ let hasExtraVars = !!Object.keys(extra).length;
+ extra = this.formatExtraVars(extra);
+
+ this.recordEvent({
+ method,
+ object,
+ value,
+ extra: hasExtraVars ? extra : null,
+ });
+ },
+
+ /**
+ * Record an event on abuse report submissions.
+ *
+ * @params {object} opts
+ * @params {string} opts.addonId
+ * The id of the addon being reported.
+ * @params {string} [opts.addonType]
+ * The type of the addon being reported (only present for an existing
+ * addonId).
+ * @params {string} [opts.errorType]
+ * The AbuseReport errorType for a submission failure.
+ * @params {string} opts.reportEntryPoint
+ * The entry point of the abuse report.
+ */
+ recordReportEvent({ addonId, addonType, errorType, reportEntryPoint }) {
+ this.recordEvent({
+ method: "report",
+ object: reportEntryPoint,
+ value: addonId,
+ extra: this.formatExtraVars({
+ addon_type: addonType,
+ error_type: errorType,
+ }),
+ });
+ },
+
+ recordEvent({ method, object, value, extra }) {
+ if (typeof value != "string") {
+ // The value must be a string or null, make sure it's valid so sending
+ // the event doesn't fail.
+ value = null;
+ }
+ try {
+ Services.telemetry.recordEvent(
+ "addonsManager",
+ method,
+ object,
+ value,
+ extra
+ );
+ } catch (err) {
+ // If the telemetry throws just log the error so it doesn't break any
+ // functionality.
+ Cu.reportError(err);
+ }
+ },
+};
+
+AddonManager.init();
+
+// Setup the AMTelemetry once the AddonManager has been started.
+AddonManager.addManagerListener(AMTelemetry);
+Object.freeze(AddonManagerInternal);
+Object.freeze(AddonManagerPrivate);
+Object.freeze(AddonManager);
diff --git a/toolkit/mozapps/extensions/AddonManagerStartup-inlines.h b/toolkit/mozapps/extensions/AddonManagerStartup-inlines.h
new file mode 100644
index 0000000000..64211d98e7
--- /dev/null
+++ b/toolkit/mozapps/extensions/AddonManagerStartup-inlines.h
@@ -0,0 +1,229 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef AddonManagerStartup_inlines_h
+#define AddonManagerStartup_inlines_h
+
+#include <utility>
+
+#include "js/Array.h" // JS::GetArrayLength, JS::IsArrayObject
+#include "js/Exception.h"
+#include "js/PropertyAndElement.h" // JS_Enumerate, JS_GetElement, JS_GetProperty, JS_GetPropertyById
+#include "jsapi.h"
+#include "mozilla/Maybe.h"
+#include "nsJSUtils.h"
+
+namespace mozilla {
+
+class ArrayIterElem;
+class PropertyIterElem;
+
+/*****************************************************************************
+ * Object iterator base classes
+ *****************************************************************************/
+
+template <class T, class PropertyType>
+class MOZ_STACK_CLASS BaseIter {
+ public:
+ typedef T SelfType;
+
+ PropertyType begin() const { return PropertyType(Self()); }
+
+ PropertyType end() const {
+ PropertyType elem(Self());
+ return elem.End();
+ }
+
+ void* Context() const { return mContext; }
+
+ protected:
+ BaseIter(JSContext* cx, JS::Handle<JSObject*> object, void* context = nullptr)
+ : mCx(cx), mObject(object), mContext(context) {}
+
+ const SelfType& Self() const { return *static_cast<const SelfType*>(this); }
+ SelfType& Self() { return *static_cast<SelfType*>(this); }
+
+ JSContext* mCx;
+
+ JS::Handle<JSObject*> mObject;
+
+ void* mContext;
+};
+
+template <class T, class IterType>
+class MOZ_STACK_CLASS BaseIterElem {
+ public:
+ typedef T SelfType;
+
+ explicit BaseIterElem(const IterType& iter, uint32_t index = 0)
+ : mIter(iter), mIndex(index) {}
+
+ uint32_t Length() const { return mIter.Length(); }
+
+ JS::Value Value() {
+ JS::Rooted<JS::Value> value(mIter.mCx, JS::UndefinedValue());
+
+ auto& self = Self();
+ if (!self.GetValue(&value)) {
+ JS_ClearPendingException(mIter.mCx);
+ }
+
+ return value;
+ }
+
+ SelfType& operator*() { return Self(); }
+
+ SelfType& operator++() {
+ MOZ_ASSERT(mIndex < Length());
+ mIndex++;
+ return Self();
+ }
+
+ bool operator!=(const SelfType& other) const {
+ return &mIter != &other.mIter || mIndex != other.mIndex;
+ }
+
+ SelfType End() const {
+ SelfType end(mIter);
+ end.mIndex = Length();
+ return end;
+ }
+
+ void* Context() const { return mIter.Context(); }
+
+ protected:
+ const SelfType& Self() const { return *static_cast<const SelfType*>(this); }
+ SelfType& Self() { return *static_cast<SelfType*>(this); }
+
+ const IterType& mIter;
+
+ uint32_t mIndex;
+};
+
+/*****************************************************************************
+ * Property iteration
+ *****************************************************************************/
+
+class MOZ_STACK_CLASS PropertyIter
+ : public BaseIter<PropertyIter, PropertyIterElem> {
+ friend class PropertyIterElem;
+ friend class BaseIterElem<PropertyIterElem, PropertyIter>;
+
+ public:
+ PropertyIter(JSContext* cx, JS::Handle<JSObject*> object,
+ void* context = nullptr)
+ : BaseIter(cx, object, context), mIds(cx, JS::IdVector(cx)) {
+ if (!JS_Enumerate(cx, object, &mIds)) {
+ JS_ClearPendingException(cx);
+ }
+ }
+
+ PropertyIter(const PropertyIter& other)
+ : PropertyIter(other.mCx, other.mObject, other.mContext) {}
+
+ PropertyIter& operator=(const PropertyIter& other) {
+ MOZ_ASSERT(other.mObject == mObject);
+ mCx = other.mCx;
+ mContext = other.mContext;
+
+ mIds.clear();
+ if (!JS_Enumerate(mCx, mObject, &mIds)) {
+ JS_ClearPendingException(mCx);
+ }
+ return *this;
+ }
+
+ int32_t Length() const { return mIds.length(); }
+
+ protected:
+ JS::Rooted<JS::IdVector> mIds;
+};
+
+class MOZ_STACK_CLASS PropertyIterElem
+ : public BaseIterElem<PropertyIterElem, PropertyIter> {
+ friend class BaseIterElem<PropertyIterElem, PropertyIter>;
+
+ public:
+ using BaseIterElem::BaseIterElem;
+
+ PropertyIterElem(const PropertyIterElem& other)
+ : BaseIterElem(other.mIter, other.mIndex) {}
+
+ jsid Id() {
+ MOZ_ASSERT(mIndex < mIter.mIds.length());
+
+ return mIter.mIds[mIndex];
+ }
+
+ const nsAString& Name() {
+ if (mName.isNothing()) {
+ mName.emplace();
+ mName.ref().init(mIter.mCx, Id());
+ }
+ return mName.ref();
+ }
+
+ JSContext* Cx() { return mIter.mCx; }
+
+ protected:
+ bool GetValue(JS::MutableHandle<JS::Value> value) {
+ MOZ_ASSERT(mIndex < Length());
+ JS::Rooted<jsid> id(mIter.mCx, Id());
+
+ return JS_GetPropertyById(mIter.mCx, mIter.mObject, id, value);
+ }
+
+ private:
+ Maybe<nsAutoJSString> mName;
+};
+
+/*****************************************************************************
+ * Array iteration
+ *****************************************************************************/
+
+class MOZ_STACK_CLASS ArrayIter : public BaseIter<ArrayIter, ArrayIterElem> {
+ friend class ArrayIterElem;
+ friend class BaseIterElem<ArrayIterElem, ArrayIter>;
+
+ public:
+ ArrayIter(JSContext* cx, JS::Handle<JSObject*> object)
+ : BaseIter(cx, object), mLength(0) {
+ bool isArray;
+ if (!JS::IsArrayObject(cx, object, &isArray) || !isArray) {
+ JS_ClearPendingException(cx);
+ return;
+ }
+
+ if (!JS::GetArrayLength(cx, object, &mLength)) {
+ JS_ClearPendingException(cx);
+ }
+ }
+
+ uint32_t Length() const { return mLength; }
+
+ private:
+ uint32_t mLength;
+};
+
+class MOZ_STACK_CLASS ArrayIterElem
+ : public BaseIterElem<ArrayIterElem, ArrayIter> {
+ friend class BaseIterElem<ArrayIterElem, ArrayIter>;
+
+ public:
+ using BaseIterElem::BaseIterElem;
+
+ ArrayIterElem(const ArrayIterElem& other)
+ : BaseIterElem(other.mIter, other.mIndex) {}
+
+ protected:
+ bool GetValue(JS::MutableHandle<JS::Value> value) {
+ MOZ_ASSERT(mIndex < Length());
+ return JS_GetElement(mIter.mCx, mIter.mObject, mIndex, value);
+ }
+};
+
+} // namespace mozilla
+
+#endif // AddonManagerStartup_inlines_h
diff --git a/toolkit/mozapps/extensions/AddonManagerStartup.cpp b/toolkit/mozapps/extensions/AddonManagerStartup.cpp
new file mode 100644
index 0000000000..d39796ad69
--- /dev/null
+++ b/toolkit/mozapps/extensions/AddonManagerStartup.cpp
@@ -0,0 +1,882 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "AddonManagerStartup.h"
+#include "AddonManagerStartup-inlines.h"
+
+#include "jsapi.h"
+#include "jsfriendapi.h"
+#include "js/Array.h" // JS::IsArrayObject
+#include "js/ArrayBuffer.h"
+#include "js/Exception.h"
+#include "js/JSON.h"
+#include "js/PropertyAndElement.h" // JS_GetProperty, JS_SetProperty
+#include "js/TracingAPI.h"
+#include "xpcpublic.h"
+
+#include "mozilla/AppShutdown.h"
+#include "mozilla/ClearOnShutdown.h"
+#include "mozilla/EndianUtils.h"
+#include "mozilla/Components.h"
+#include "mozilla/Compression.h"
+#include "mozilla/LinkedList.h"
+#include "mozilla/Preferences.h"
+#include "mozilla/ResultExtensions.h"
+#include "mozilla/URLPreloader.h"
+#include "mozilla/Unused.h"
+#include "mozilla/ErrorResult.h"
+#include "mozilla/Services.h"
+#include "mozilla/dom/ipc/StructuredCloneData.h"
+
+#include "nsAppDirectoryServiceDefs.h"
+#include "nsAppRunner.h"
+#include "nsContentUtils.h"
+#include "nsChromeRegistry.h"
+#include "nsIDOMWindowUtils.h" // for nsIJSRAIIHelper
+#include "nsIFileURL.h"
+#include "nsIIOService.h"
+#include "nsIJARURI.h"
+#include "nsIStringEnumerator.h"
+#include "nsIZipReader.h"
+#include "nsJARProtocolHandler.h"
+#include "nsJSUtils.h"
+#include "nsIObserverService.h"
+#include "nsReadableUtils.h"
+#include "nsXULAppAPI.h"
+
+#include <stdlib.h>
+
+namespace mozilla {
+
+using Compression::LZ4;
+using dom::ipc::StructuredCloneData;
+
+AddonManagerStartup& AddonManagerStartup::GetSingleton() {
+ static RefPtr<AddonManagerStartup> singleton;
+ if (!singleton) {
+ singleton = new AddonManagerStartup();
+ ClearOnShutdown(&singleton);
+ }
+ return *singleton;
+}
+
+AddonManagerStartup::AddonManagerStartup() = default;
+
+nsIFile* AddonManagerStartup::ProfileDir() {
+ if (!mProfileDir) {
+ nsresult rv;
+
+ rv = NS_GetSpecialDirectory(NS_APP_USER_PROFILE_50_DIR,
+ getter_AddRefs(mProfileDir));
+ MOZ_RELEASE_ASSERT(NS_SUCCEEDED(rv));
+ }
+
+ return mProfileDir;
+}
+
+NS_IMPL_ISUPPORTS(AddonManagerStartup, amIAddonManagerStartup, nsIObserver)
+
+/*****************************************************************************
+ * URI utils
+ *****************************************************************************/
+
+static nsresult ParseJARURI(nsIJARURI* uri, nsIURI** jarFile,
+ nsCString& entry) {
+ MOZ_TRY(uri->GetJARFile(jarFile));
+ MOZ_TRY(uri->GetJAREntry(entry));
+
+ // The entry portion of a jar: URI is required to begin with a '/', but for
+ // nested JAR URIs, the leading / of the outer entry is currently stripped.
+ // This is a bug which should be fixed in the JAR URI code, but...
+ if (entry.IsEmpty() || entry[0] != '/') {
+ entry.Insert('/', 0);
+ }
+ return NS_OK;
+}
+
+static nsresult ParseJARURI(nsIURI* uri, nsIURI** jarFile, nsCString& entry) {
+ nsresult rv;
+ nsCOMPtr<nsIJARURI> jarURI = do_QueryInterface(uri, &rv);
+ MOZ_TRY(rv);
+
+ return ParseJARURI(jarURI, jarFile, entry);
+}
+
+static Result<nsCOMPtr<nsIFile>, nsresult> GetFile(nsIURI* uri) {
+ nsresult rv;
+ nsCOMPtr<nsIFileURL> fileURL = do_QueryInterface(uri, &rv);
+ MOZ_TRY(rv);
+
+ nsCOMPtr<nsIFile> file;
+ MOZ_TRY(fileURL->GetFile(getter_AddRefs(file)));
+ MOZ_ASSERT(file);
+
+ return std::move(file);
+}
+
+/*****************************************************************************
+ * File utils
+ *****************************************************************************/
+
+static already_AddRefed<nsIFile> CloneAndAppend(nsIFile* aFile,
+ const char* name) {
+ nsCOMPtr<nsIFile> file;
+ aFile->Clone(getter_AddRefs(file));
+ file->AppendNative(nsDependentCString(name));
+ return file.forget();
+}
+
+static bool IsNormalFile(nsIFile* file) {
+ bool result;
+ return NS_SUCCEEDED(file->IsFile(&result)) && result;
+}
+
+static const char STRUCTURED_CLONE_MAGIC[] = "mozJSSCLz40v001";
+
+template <typename T>
+static Result<nsCString, nsresult> DecodeLZ4(const nsACString& lz4,
+ const T& magicNumber) {
+ constexpr auto HEADER_SIZE = sizeof(magicNumber) + 4;
+
+ // Note: We want to include the null terminator here.
+ nsDependentCSubstring magic(magicNumber, sizeof(magicNumber));
+
+ if (lz4.Length() < HEADER_SIZE || StringHead(lz4, magic.Length()) != magic) {
+ return Err(NS_ERROR_UNEXPECTED);
+ }
+
+ auto data = lz4.BeginReading() + magic.Length();
+ auto size = LittleEndian::readUint32(data);
+ data += 4;
+
+ size_t dataLen = lz4.EndReading() - data;
+ size_t outputSize;
+
+ nsCString result;
+ if (!result.SetLength(size, fallible) ||
+ !LZ4::decompress(data, dataLen, result.BeginWriting(), size,
+ &outputSize)) {
+ return Err(NS_ERROR_UNEXPECTED);
+ }
+
+ MOZ_DIAGNOSTIC_ASSERT(size == outputSize);
+
+ return std::move(result);
+}
+
+// Our zlib headers redefine this to MOZ_Z_compress, which breaks LZ4::compress
+#undef compress
+
+template <typename T>
+static Result<nsCString, nsresult> EncodeLZ4(const nsACString& data,
+ const T& magicNumber) {
+ // Note: We want to include the null terminator here.
+ nsDependentCSubstring magic(magicNumber, sizeof(magicNumber));
+
+ nsAutoCString result;
+ result.Append(magic);
+
+ auto off = result.Length();
+ if (!result.SetLength(off + 4, fallible)) {
+ return Err(NS_ERROR_OUT_OF_MEMORY);
+ }
+
+ LittleEndian::writeUint32(result.BeginWriting() + off, data.Length());
+ off += 4;
+
+ auto size = LZ4::maxCompressedSize(data.Length());
+ if (!result.SetLength(off + size, fallible)) {
+ return Err(NS_ERROR_OUT_OF_MEMORY);
+ }
+
+ size = LZ4::compress(data.BeginReading(), data.Length(),
+ result.BeginWriting() + off);
+
+ if (!result.SetLength(off + size, fallible)) {
+ return Err(NS_ERROR_OUT_OF_MEMORY);
+ }
+ return std::move(result);
+}
+
+static_assert(sizeof STRUCTURED_CLONE_MAGIC % 8 == 0,
+ "Magic number should be an array of uint64_t");
+
+/**
+ * Reads the contents of a LZ4-compressed file, as stored by the IOUtils
+ * module, and returns the decompressed contents on success.
+ */
+static Result<nsCString, nsresult> ReadFileLZ4(nsIFile* file) {
+ static const char MAGIC_NUMBER[] = "mozLz40";
+
+ nsCString lz4;
+ MOZ_TRY_VAR(lz4, URLPreloader::ReadFile(file));
+
+ if (lz4.IsEmpty()) {
+ return lz4;
+ }
+
+ return DecodeLZ4(lz4, MAGIC_NUMBER);
+}
+
+static bool ParseJSON(JSContext* cx, nsACString& jsonData,
+ JS::MutableHandle<JS::Value> result) {
+ NS_ConvertUTF8toUTF16 str(jsonData);
+ jsonData.Truncate();
+
+ return JS_ParseJSON(cx, str.Data(), str.Length(), result);
+}
+
+static Result<nsCOMPtr<nsIZipReaderCache>, nsresult> GetJarCache() {
+ nsCOMPtr<nsIIOService> ios = components::IO::Service();
+ NS_ENSURE_TRUE(ios, Err(NS_ERROR_FAILURE));
+
+ nsCOMPtr<nsIProtocolHandler> jarProto;
+ MOZ_TRY(ios->GetProtocolHandler("jar", getter_AddRefs(jarProto)));
+
+ auto jar = static_cast<nsJARProtocolHandler*>(jarProto.get());
+ MOZ_ASSERT(jar);
+
+ nsCOMPtr<nsIZipReaderCache> zipCache = jar->JarCache();
+ return std::move(zipCache);
+}
+
+static Result<FileLocation, nsresult> GetFileLocation(nsIURI* uri) {
+ FileLocation location;
+
+ nsCOMPtr<nsIFileURL> fileURL = do_QueryInterface(uri);
+ nsCOMPtr<nsIFile> file;
+ if (fileURL) {
+ MOZ_TRY(fileURL->GetFile(getter_AddRefs(file)));
+ location.Init(file);
+ } else {
+ nsCOMPtr<nsIURI> fileURI;
+ nsCString entry;
+ MOZ_TRY(ParseJARURI(uri, getter_AddRefs(fileURI), entry));
+
+ MOZ_TRY_VAR(file, GetFile(fileURI));
+
+ location.Init(file, entry.get());
+ }
+
+ return std::move(location);
+}
+
+/*****************************************************************************
+ * JSON data handling
+ *****************************************************************************/
+
+class MOZ_STACK_CLASS WrapperBase {
+ protected:
+ WrapperBase(JSContext* cx, JSObject* object) : mCx(cx), mObject(cx, object) {}
+
+ WrapperBase(JSContext* cx, const JS::Value& value) : mCx(cx), mObject(cx) {
+ if (value.isObject()) {
+ mObject = &value.toObject();
+ } else {
+ mObject = JS_NewPlainObject(cx);
+ }
+ }
+
+ protected:
+ JSContext* mCx;
+ JS::Rooted<JSObject*> mObject;
+
+ bool GetBool(const char* name, bool defVal = false);
+
+ double GetNumber(const char* name, double defVal = 0);
+
+ nsString GetString(const char* name, const char* defVal = "");
+
+ JSObject* GetObject(const char* name);
+};
+
+bool WrapperBase::GetBool(const char* name, bool defVal) {
+ JS::Rooted<JSObject*> obj(mCx, mObject);
+
+ JS::Rooted<JS::Value> val(mCx, JS::UndefinedValue());
+ if (!JS_GetProperty(mCx, obj, name, &val)) {
+ JS_ClearPendingException(mCx);
+ }
+
+ if (val.isBoolean()) {
+ return val.toBoolean();
+ }
+ return defVal;
+}
+
+double WrapperBase::GetNumber(const char* name, double defVal) {
+ JS::Rooted<JSObject*> obj(mCx, mObject);
+
+ JS::Rooted<JS::Value> val(mCx, JS::UndefinedValue());
+ if (!JS_GetProperty(mCx, obj, name, &val)) {
+ JS_ClearPendingException(mCx);
+ }
+
+ if (val.isNumber()) {
+ return val.toNumber();
+ }
+ return defVal;
+}
+
+nsString WrapperBase::GetString(const char* name, const char* defVal) {
+ JS::Rooted<JSObject*> obj(mCx, mObject);
+
+ JS::Rooted<JS::Value> val(mCx, JS::UndefinedValue());
+ if (!JS_GetProperty(mCx, obj, name, &val)) {
+ JS_ClearPendingException(mCx);
+ }
+
+ nsString res;
+ if (val.isString()) {
+ AssignJSString(mCx, res, val.toString());
+ } else {
+ res.AppendASCII(defVal);
+ }
+ return res;
+}
+
+JSObject* WrapperBase::GetObject(const char* name) {
+ JS::Rooted<JSObject*> obj(mCx, mObject);
+
+ JS::Rooted<JS::Value> val(mCx, JS::UndefinedValue());
+ if (!JS_GetProperty(mCx, obj, name, &val)) {
+ JS_ClearPendingException(mCx);
+ }
+
+ if (val.isObject()) {
+ return &val.toObject();
+ }
+ return nullptr;
+}
+
+class MOZ_STACK_CLASS InstallLocation : public WrapperBase {
+ public:
+ InstallLocation(JSContext* cx, const JS::Value& value);
+
+ MOZ_IMPLICIT InstallLocation(PropertyIterElem& iter)
+ : InstallLocation(iter.Cx(), iter.Value()) {}
+
+ InstallLocation(const InstallLocation& other)
+ : InstallLocation(other.mCx, JS::ObjectValue(*other.mObject)) {}
+
+ void SetChanged(bool changed) {
+ JS::Rooted<JSObject*> obj(mCx, mObject);
+
+ JS::Rooted<JS::Value> val(mCx, JS::BooleanValue(changed));
+ if (!JS_SetProperty(mCx, obj, "changed", val)) {
+ JS_ClearPendingException(mCx);
+ }
+ }
+
+ PropertyIter& Addons() { return mAddonsIter.ref(); }
+
+ nsString Path() { return GetString("path"); }
+
+ bool ShouldCheckStartupModifications() {
+ return GetBool("checkStartupModifications");
+ }
+
+ private:
+ JS::Rooted<JSObject*> mAddonsObj;
+ Maybe<PropertyIter> mAddonsIter;
+};
+
+class MOZ_STACK_CLASS Addon : public WrapperBase {
+ public:
+ Addon(JSContext* cx, InstallLocation& location, const nsAString& id,
+ JSObject* object)
+ : WrapperBase(cx, object), mId(id), mLocation(location) {}
+
+ MOZ_IMPLICIT Addon(PropertyIterElem& iter)
+ : WrapperBase(iter.Cx(), iter.Value()),
+ mId(iter.Name()),
+ mLocation(*static_cast<InstallLocation*>(iter.Context())) {}
+
+ Addon(const Addon& other)
+ : WrapperBase(other.mCx, other.mObject),
+ mId(other.mId),
+ mLocation(other.mLocation) {}
+
+ const nsString& Id() { return mId; }
+
+ nsString Path() { return GetString("path"); }
+
+ nsString Type() { return GetString("type", "extension"); }
+
+ bool Enabled() { return GetBool("enabled"); }
+
+ double LastModifiedTime() { return GetNumber("lastModifiedTime"); }
+
+ bool ShouldCheckStartupModifications() {
+ return Type().EqualsLiteral("locale");
+ }
+
+ Result<nsCOMPtr<nsIFile>, nsresult> FullPath();
+
+ Result<bool, nsresult> UpdateLastModifiedTime();
+
+ private:
+ nsString mId;
+ InstallLocation& mLocation;
+};
+
+Result<nsCOMPtr<nsIFile>, nsresult> Addon::FullPath() {
+ nsString path = Path();
+
+ // First check for an absolute path, in case we have a proxy file.
+ nsCOMPtr<nsIFile> file;
+ if (NS_SUCCEEDED(NS_NewLocalFile(path, false, getter_AddRefs(file)))) {
+ return std::move(file);
+ }
+
+ // If not an absolute path, fall back to a relative path from the location.
+ MOZ_TRY(NS_NewLocalFile(mLocation.Path(), false, getter_AddRefs(file)));
+
+ MOZ_TRY(file->AppendRelativePath(path));
+ return std::move(file);
+}
+
+Result<bool, nsresult> Addon::UpdateLastModifiedTime() {
+ nsCOMPtr<nsIFile> file;
+ MOZ_TRY_VAR(file, FullPath());
+
+ JS::Rooted<JSObject*> obj(mCx, mObject);
+
+ bool result;
+ if (NS_FAILED(file->Exists(&result)) || !result) {
+ JS::Rooted<JS::Value> value(mCx, JS::NullValue());
+ if (!JS_SetProperty(mCx, obj, "currentModifiedTime", value)) {
+ JS_ClearPendingException(mCx);
+ }
+
+ return true;
+ }
+
+ PRTime time;
+
+ nsCOMPtr<nsIFile> manifest = file;
+ if (!IsNormalFile(manifest)) {
+ manifest = CloneAndAppend(file, "manifest.json");
+ if (!IsNormalFile(manifest)) {
+ return true;
+ }
+ }
+
+ if (NS_FAILED(manifest->GetLastModifiedTime(&time))) {
+ return true;
+ }
+
+ double lastModified = time;
+ JS::Rooted<JS::Value> value(mCx, JS::NumberValue(lastModified));
+ if (!JS_SetProperty(mCx, obj, "currentModifiedTime", value)) {
+ JS_ClearPendingException(mCx);
+ }
+
+ return lastModified != LastModifiedTime();
+}
+
+InstallLocation::InstallLocation(JSContext* cx, const JS::Value& value)
+ : WrapperBase(cx, value), mAddonsObj(cx), mAddonsIter() {
+ mAddonsObj = GetObject("addons");
+ if (!mAddonsObj) {
+ mAddonsObj = JS_NewPlainObject(cx);
+ }
+ mAddonsIter.emplace(cx, mAddonsObj, this);
+}
+
+/*****************************************************************************
+ * XPC interfacing
+ *****************************************************************************/
+
+nsresult AddonManagerStartup::ReadStartupData(
+ JSContext* cx, JS::MutableHandle<JS::Value> locations) {
+ locations.set(JS::UndefinedValue());
+
+ nsCOMPtr<nsIFile> file =
+ CloneAndAppend(ProfileDir(), "addonStartup.json.lz4");
+
+ nsCString data;
+ auto res = ReadFileLZ4(file);
+ if (res.isOk()) {
+ data = res.unwrap();
+ } else if (res.inspectErr() != NS_ERROR_FILE_NOT_FOUND) {
+ return res.unwrapErr();
+ }
+
+ if (data.IsEmpty() || !ParseJSON(cx, data, locations)) {
+ return NS_OK;
+ }
+
+ if (!locations.isObject()) {
+ return NS_ERROR_UNEXPECTED;
+ }
+
+ JS::Rooted<JSObject*> locs(cx, &locations.toObject());
+ for (auto e1 : PropertyIter(cx, locs)) {
+ InstallLocation loc(e1);
+
+ bool shouldCheck = loc.ShouldCheckStartupModifications();
+
+ for (auto e2 : loc.Addons()) {
+ Addon addon(e2);
+
+ if (addon.Enabled() &&
+ (shouldCheck || addon.ShouldCheckStartupModifications())) {
+ bool changed;
+ MOZ_TRY_VAR(changed, addon.UpdateLastModifiedTime());
+ if (changed) {
+ loc.SetChanged(true);
+ }
+ }
+ }
+ }
+
+ return NS_OK;
+}
+
+nsresult AddonManagerStartup::EncodeBlob(JS::Handle<JS::Value> value,
+ JSContext* cx,
+ JS::MutableHandle<JS::Value> result) {
+ StructuredCloneData holder;
+
+ ErrorResult rv;
+ holder.Write(cx, value, rv);
+ if (rv.Failed()) {
+ return rv.StealNSResult();
+ }
+
+ nsAutoCString scData;
+
+ holder.Data().ForEachDataChunk([&](const char* aData, size_t aSize) {
+ scData.Append(nsDependentCSubstring(aData, aSize));
+ return true;
+ });
+
+ nsCString lz4;
+ MOZ_TRY_VAR(lz4, EncodeLZ4(scData, STRUCTURED_CLONE_MAGIC));
+
+ JS::Rooted<JSObject*> obj(cx);
+ MOZ_TRY(nsContentUtils::CreateArrayBuffer(cx, lz4, &obj.get()));
+
+ result.set(JS::ObjectValue(*obj));
+ return NS_OK;
+}
+
+nsresult AddonManagerStartup::DecodeBlob(JS::Handle<JS::Value> value,
+ JSContext* cx,
+ JS::MutableHandle<JS::Value> result) {
+ NS_ENSURE_TRUE(value.isObject() &&
+ JS::IsArrayBufferObject(&value.toObject()) &&
+ JS::ArrayBufferHasData(&value.toObject()),
+ NS_ERROR_INVALID_ARG);
+
+ StructuredCloneData holder;
+
+ nsCString data;
+ {
+ JS::AutoCheckCannotGC nogc;
+
+ auto obj = &value.toObject();
+ bool isShared;
+
+ size_t len = JS::GetArrayBufferByteLength(obj);
+ NS_ENSURE_TRUE(len <= INT32_MAX, NS_ERROR_INVALID_ARG);
+ nsDependentCSubstring lz4(
+ reinterpret_cast<char*>(JS::GetArrayBufferData(obj, &isShared, nogc)),
+ uint32_t(len));
+
+ MOZ_TRY_VAR(data, DecodeLZ4(lz4, STRUCTURED_CLONE_MAGIC));
+ }
+
+ bool ok = holder.CopyExternalData(data.get(), data.Length());
+ NS_ENSURE_TRUE(ok, NS_ERROR_OUT_OF_MEMORY);
+
+ ErrorResult rv;
+ holder.Read(cx, result, rv);
+ return rv.StealNSResult();
+ ;
+}
+
+static nsresult EnumerateZip(nsIZipReader* zip, const nsACString& pattern,
+ nsTArray<nsString>& results) {
+ nsCOMPtr<nsIUTF8StringEnumerator> entries;
+ MOZ_TRY(zip->FindEntries(pattern, getter_AddRefs(entries)));
+
+ bool hasMore;
+ while (NS_SUCCEEDED(entries->HasMore(&hasMore)) && hasMore) {
+ nsAutoCString name;
+ MOZ_TRY(entries->GetNext(name));
+
+ results.AppendElement(NS_ConvertUTF8toUTF16(name));
+ }
+
+ return NS_OK;
+}
+
+nsresult AddonManagerStartup::EnumerateJAR(nsIURI* uri,
+ const nsACString& pattern,
+ nsTArray<nsString>& results) {
+ nsCOMPtr<nsIZipReaderCache> zipCache;
+ MOZ_TRY_VAR(zipCache, GetJarCache());
+
+ nsCOMPtr<nsIZipReader> zip;
+ nsCOMPtr<nsIFile> file;
+ if (nsCOMPtr<nsIJARURI> jarURI = do_QueryInterface(uri)) {
+ nsCOMPtr<nsIURI> fileURI;
+ nsCString entry;
+ MOZ_TRY(ParseJARURI(jarURI, getter_AddRefs(fileURI), entry));
+
+ MOZ_TRY_VAR(file, GetFile(fileURI));
+ MOZ_TRY(
+ zipCache->GetInnerZip(file, Substring(entry, 1), getter_AddRefs(zip)));
+ } else {
+ MOZ_TRY_VAR(file, GetFile(uri));
+ MOZ_TRY(zipCache->GetZip(file, getter_AddRefs(zip)));
+ }
+ MOZ_ASSERT(zip);
+
+ return EnumerateZip(zip, pattern, results);
+}
+
+nsresult AddonManagerStartup::EnumerateJARSubtree(nsIURI* uri,
+ nsTArray<nsString>& results) {
+ nsCOMPtr<nsIURI> fileURI;
+ nsCString entry;
+ MOZ_TRY(ParseJARURI(uri, getter_AddRefs(fileURI), entry));
+
+ // Mangle the path into a pattern to match all child entries by escaping any
+ // existing pattern matching metacharacters it contains and appending "/*".
+ constexpr auto metaChars = "[]()?*~|$\\"_ns;
+
+ nsCString pattern;
+ pattern.SetCapacity(entry.Length());
+
+ // The first character of the entry name is "/", which we want to skip.
+ for (auto chr : Span(Substring(entry, 1))) {
+ if (metaChars.FindChar(chr) >= 0) {
+ pattern.Append('\\');
+ }
+ pattern.Append(chr);
+ }
+ if (!pattern.IsEmpty() && !StringEndsWith(pattern, "/"_ns)) {
+ pattern.Append('/');
+ }
+ pattern.Append('*');
+
+ return EnumerateJAR(fileURI, pattern, results);
+}
+
+nsresult AddonManagerStartup::InitializeURLPreloader() {
+ MOZ_RELEASE_ASSERT(xpc::IsInAutomation());
+
+ URLPreloader::ReInitialize();
+
+ return NS_OK;
+}
+
+/******************************************************************************
+ * RegisterChrome
+ ******************************************************************************/
+
+namespace {
+static bool sObserverRegistered;
+
+struct ContentEntry final {
+ explicit ContentEntry(nsTArray<nsCString>&& aArgs, uint8_t aFlags = 0)
+ : mArgs(std::move(aArgs)), mFlags(aFlags) {}
+
+ AutoTArray<nsCString, 2> mArgs;
+ uint8_t mFlags;
+};
+
+}; // anonymous namespace
+}; // namespace mozilla
+
+MOZ_DECLARE_RELOCATE_USING_MOVE_CONSTRUCTOR(mozilla::ContentEntry);
+
+namespace mozilla {
+namespace {
+
+class RegistryEntries final : public nsIJSRAIIHelper,
+ public LinkedListElement<RegistryEntries> {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIJSRAIIHELPER
+
+ using Override = AutoTArray<nsCString, 2>;
+ using Locale = AutoTArray<nsCString, 3>;
+
+ RegistryEntries(FileLocation& location, nsTArray<Override>&& overrides,
+ nsTArray<ContentEntry>&& content, nsTArray<Locale>&& locales)
+ : mLocation(location),
+ mOverrides(std::move(overrides)),
+ mContent(std::move(content)),
+ mLocales(std::move(locales)) {}
+
+ void Register();
+
+ protected:
+ virtual ~RegistryEntries() { Unused << Destruct(); }
+
+ private:
+ FileLocation mLocation;
+ const nsTArray<Override> mOverrides;
+ const nsTArray<ContentEntry> mContent;
+ const nsTArray<Locale> mLocales;
+};
+
+NS_IMPL_ISUPPORTS(RegistryEntries, nsIJSRAIIHelper)
+
+void RegistryEntries::Register() {
+ RefPtr<nsChromeRegistry> cr = nsChromeRegistry::GetSingleton();
+
+ nsChromeRegistry::ManifestProcessingContext context(NS_EXTENSION_LOCATION,
+ mLocation);
+
+ for (auto& override : mOverrides) {
+ const char* args[] = {override[0].get(), override[1].get()};
+ cr->ManifestOverride(context, 0, const_cast<char**>(args), 0);
+ }
+
+ for (auto& content : mContent) {
+ const char* args[] = {content.mArgs[0].get(), content.mArgs[1].get()};
+ cr->ManifestContent(context, 0, const_cast<char**>(args), content.mFlags);
+ }
+
+ for (auto& locale : mLocales) {
+ const char* args[] = {locale[0].get(), locale[1].get(), locale[2].get()};
+ cr->ManifestLocale(context, 0, const_cast<char**>(args), 0);
+ }
+}
+
+NS_IMETHODIMP
+RegistryEntries::Destruct() {
+ if (isInList()) {
+ remove();
+
+ // No point in doing I/O to check for new chrome during shutdown, return
+ // early in that case.
+ if (AppShutdown::IsInOrBeyond(ShutdownPhase::AppShutdownConfirmed)) {
+ return NS_OK;
+ }
+
+ // When we remove dynamic entries from the registry, we need to rebuild it
+ // in order to ensure a consistent state. See comments in Observe().
+ RefPtr<nsChromeRegistry> cr = nsChromeRegistry::GetSingleton();
+ return cr->CheckForNewChrome();
+ }
+ return NS_OK;
+}
+
+static LinkedList<RegistryEntries>& GetRegistryEntries() {
+ static LinkedList<RegistryEntries> sEntries;
+ return sEntries;
+}
+}; // anonymous namespace
+
+NS_IMETHODIMP
+AddonManagerStartup::RegisterChrome(nsIURI* manifestURI,
+ JS::Handle<JS::Value> locations,
+ JSContext* cx, nsIJSRAIIHelper** result) {
+ auto IsArray = [cx](JS::Handle<JS::Value> val) -> bool {
+ bool isArray;
+ return JS::IsArrayObject(cx, val, &isArray) && isArray;
+ };
+
+ NS_ENSURE_ARG_POINTER(manifestURI);
+ NS_ENSURE_TRUE(IsArray(locations), NS_ERROR_INVALID_ARG);
+
+ FileLocation location;
+ MOZ_TRY_VAR(location, GetFileLocation(manifestURI));
+
+ nsTArray<RegistryEntries::Locale> locales;
+ nsTArray<ContentEntry> content;
+ nsTArray<RegistryEntries::Override> overrides;
+
+ JS::Rooted<JSObject*> locs(cx, &locations.toObject());
+ JS::Rooted<JS::Value> arrayVal(cx);
+ JS::Rooted<JSObject*> array(cx);
+
+ for (auto elem : ArrayIter(cx, locs)) {
+ arrayVal = elem.Value();
+ NS_ENSURE_TRUE(IsArray(arrayVal), NS_ERROR_INVALID_ARG);
+
+ array = &arrayVal.toObject();
+
+ AutoTArray<nsCString, 4> vals;
+ for (auto val : ArrayIter(cx, array)) {
+ nsAutoJSString str;
+ NS_ENSURE_TRUE(str.init(cx, val.Value()), NS_ERROR_OUT_OF_MEMORY);
+
+ vals.AppendElement(NS_ConvertUTF16toUTF8(str));
+ }
+ NS_ENSURE_TRUE(vals.Length() > 0, NS_ERROR_INVALID_ARG);
+
+ nsCString type = vals[0];
+ vals.RemoveElementAt(0);
+
+ if (type.EqualsLiteral("override")) {
+ NS_ENSURE_TRUE(vals.Length() == 2, NS_ERROR_INVALID_ARG);
+ overrides.AppendElement(std::move(vals));
+ } else if (type.EqualsLiteral("content")) {
+ if (vals.Length() == 3 &&
+ vals[2].EqualsLiteral("contentaccessible=yes")) {
+ NS_ENSURE_TRUE(xpc::IsInAutomation(), NS_ERROR_INVALID_ARG);
+ vals.RemoveElementAt(2);
+ content.AppendElement(ContentEntry(
+ std::move(vals), nsChromeRegistry::CONTENT_ACCESSIBLE));
+ } else {
+ NS_ENSURE_TRUE(vals.Length() == 2, NS_ERROR_INVALID_ARG);
+ content.AppendElement(ContentEntry(std::move(vals)));
+ }
+ } else if (type.EqualsLiteral("locale")) {
+ NS_ENSURE_TRUE(vals.Length() == 3, NS_ERROR_INVALID_ARG);
+ locales.AppendElement(std::move(vals));
+ } else {
+ return NS_ERROR_INVALID_ARG;
+ }
+ }
+
+ if (!sObserverRegistered) {
+ nsCOMPtr<nsIObserverService> obs = services::GetObserverService();
+ NS_ENSURE_TRUE(obs, NS_ERROR_UNEXPECTED);
+ obs->AddObserver(this, "chrome-manifests-loaded", false);
+
+ sObserverRegistered = true;
+ }
+
+ auto entry = MakeRefPtr<RegistryEntries>(
+ location, std::move(overrides), std::move(content), std::move(locales));
+
+ entry->Register();
+ GetRegistryEntries().insertBack(entry);
+
+ entry.forget(result);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+AddonManagerStartup::Observe(nsISupports* subject, const char* topic,
+ const char16_t* data) {
+ // The chrome registry is maintained as a set of global resource mappings
+ // generated mainly from manifest files, on-the-fly, as they're parsed.
+ // Entries added later override entries added earlier, and no record is kept
+ // of the former state.
+ //
+ // As a result, if we remove a dynamically-added manifest file, or a set of
+ // dynamic entries, the registry needs to be rebuilt from scratch, from the
+ // manifests and dynamic entries that remain. The chrome registry itself
+ // takes care of re-parsing manifes files. This observer notification lets
+ // us know when we need to re-register our dynamic entries.
+ if (!strcmp(topic, "chrome-manifests-loaded")) {
+ for (auto entry : GetRegistryEntries()) {
+ entry->Register();
+ }
+ }
+
+ return NS_OK;
+}
+
+} // namespace mozilla
diff --git a/toolkit/mozapps/extensions/AddonManagerStartup.h b/toolkit/mozapps/extensions/AddonManagerStartup.h
new file mode 100644
index 0000000000..a1cb5c9506
--- /dev/null
+++ b/toolkit/mozapps/extensions/AddonManagerStartup.h
@@ -0,0 +1,59 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef AddonManagerStartup_h
+#define AddonManagerStartup_h
+
+#include "amIAddonManagerStartup.h"
+#include "mozilla/Result.h"
+#include "nsCOMArray.h"
+#include "nsCOMPtr.h"
+#include "nsIFile.h"
+#include "nsIObserver.h"
+#include "nsISupports.h"
+
+namespace mozilla {
+
+class Addon;
+
+class AddonManagerStartup final : public amIAddonManagerStartup,
+ public nsIObserver {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_AMIADDONMANAGERSTARTUP
+ NS_DECL_NSIOBSERVER
+
+ AddonManagerStartup();
+
+ static AddonManagerStartup& GetSingleton();
+
+ static already_AddRefed<AddonManagerStartup> GetInstance() {
+ RefPtr<AddonManagerStartup> inst = &GetSingleton();
+ return inst.forget();
+ }
+
+ private:
+ nsIFile* ProfileDir();
+
+ nsCOMPtr<nsIFile> mProfileDir;
+
+ protected:
+ virtual ~AddonManagerStartup() = default;
+};
+
+} // namespace mozilla
+
+#define NS_ADDONMANAGERSTARTUP_CONTRACTID \
+ "@mozilla.org/addons/addon-manager-startup;1"
+
+// {17a59a6b-92b8-42e5-bce0-ab434c7a7135
+#define NS_ADDON_MANAGER_STARTUP_CID \
+ { \
+ 0x17a59a6b, 0x92b8, 0x42e5, { \
+ 0xbc, 0xe0, 0xab, 0x43, 0x4c, 0x7a, 0x71, 0x35 \
+ } \
+ }
+
+#endif // AddonManagerStartup_h
diff --git a/toolkit/mozapps/extensions/AddonManagerWebAPI.cpp b/toolkit/mozapps/extensions/AddonManagerWebAPI.cpp
new file mode 100644
index 0000000000..08ca2d1ec9
--- /dev/null
+++ b/toolkit/mozapps/extensions/AddonManagerWebAPI.cpp
@@ -0,0 +1,170 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "AddonManagerWebAPI.h"
+
+#include "mozilla/BasePrincipal.h"
+#include "mozilla/dom/Document.h"
+#include "mozilla/dom/Navigator.h"
+#include "mozilla/dom/NavigatorBinding.h"
+
+#include "mozilla/Preferences.h"
+#include "mozilla/StaticPrefs_extensions.h"
+#include "nsGlobalWindow.h"
+#include "xpcpublic.h"
+
+#include "nsIDocShell.h"
+#include "nsIScriptObjectPrincipal.h"
+
+namespace mozilla {
+using namespace mozilla::dom;
+
+#ifndef MOZ_THUNDERBIRD
+# define MOZ_AMO_HOSTNAME "addons.mozilla.org"
+# define MOZ_AMO_STAGE_HOSTNAME "addons.allizom.org"
+# define MOZ_AMO_DEV_HOSTNAME "addons-dev.allizom.org"
+#else
+# define MOZ_AMO_HOSTNAME "addons.thunderbird.net"
+# define MOZ_AMO_STAGE_HOSTNAME "addons-stage.thunderbird.net"
+# undef MOZ_AMO_DEV_HOSTNAME
+#endif
+
+static bool IsValidHost(const nsACString& host) {
+ // This hidden pref allows users to disable mozAddonManager entirely if they
+ // want for fingerprinting resistance. Someone like Tor browser will use this
+ // pref.
+ if (StaticPrefs::privacy_resistFingerprinting_block_mozAddonManager()) {
+ return false;
+ }
+
+ if (host.EqualsLiteral(MOZ_AMO_HOSTNAME)) {
+ return true;
+ }
+
+ // When testing allow access to the developer sites.
+ if (StaticPrefs::extensions_webapi_testing()) {
+ if (host.LowerCaseEqualsLiteral(MOZ_AMO_STAGE_HOSTNAME) ||
+#ifdef MOZ_AMO_DEV_HOSTNAME
+ host.LowerCaseEqualsLiteral(MOZ_AMO_DEV_HOSTNAME) ||
+#endif
+ host.LowerCaseEqualsLiteral("example.com")) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+// Checks if the given uri is secure and matches one of the hosts allowed to
+// access the API.
+bool AddonManagerWebAPI::IsValidSite(nsIURI* uri) {
+ if (!uri) {
+ return false;
+ }
+
+ if (!uri->SchemeIs("https")) {
+ if (!(xpc::IsInAutomation() &&
+ StaticPrefs::extensions_webapi_testing_http())) {
+ return false;
+ }
+ }
+
+ nsAutoCString host;
+ nsresult rv = uri->GetHost(host);
+ if (NS_FAILED(rv)) {
+ return false;
+ }
+
+ return IsValidHost(host);
+}
+
+#ifndef ANDROID
+bool AddonManagerWebAPI::IsAPIEnabled(JSContext* aCx, JSObject* aGlobal) {
+ MOZ_DIAGNOSTIC_ASSERT(JS_IsGlobalObject(aGlobal));
+ nsCOMPtr<nsPIDOMWindowInner> win = xpc::WindowOrNull(aGlobal);
+ if (!win) {
+ return false;
+ }
+
+ // Check that the current window and all parent frames are allowed access to
+ // the API.
+ while (win) {
+ nsCOMPtr<nsIScriptObjectPrincipal> sop = do_QueryInterface(win);
+ if (!sop) {
+ return false;
+ }
+
+ nsCOMPtr<nsIPrincipal> principal = sop->GetPrincipal();
+ if (!principal) {
+ return false;
+ }
+
+ // Reaching a window with a system principal means we have reached
+ // privileged UI of some kind so stop at this point and allow access.
+ if (principal->IsSystemPrincipal()) {
+ return true;
+ }
+
+ nsCOMPtr<nsIDocShell> docShell = win->GetDocShell();
+ if (!docShell) {
+ // This window has been torn down so don't allow access to the API.
+ return false;
+ }
+
+ if (!IsValidSite(win->GetDocumentURI())) {
+ return false;
+ }
+
+ // Checks whether there is a parent frame of the same type. This won't cross
+ // mozbrowser or chrome or fission/process boundaries.
+ nsCOMPtr<nsIDocShellTreeItem> parent;
+ nsresult rv = docShell->GetInProcessSameTypeParent(getter_AddRefs(parent));
+ if (NS_FAILED(rv)) {
+ return false;
+ }
+
+ // No parent means we've hit a mozbrowser or chrome or process boundary.
+ if (!parent) {
+ // With Fission, a cross-origin iframe has an out-of-process parent, but
+ // DocShell knows nothing about it. We need to ask BrowsingContext here,
+ // and only allow API access if AMO is actually at the top, not framed
+ // by evilleagueofevil.com.
+ return docShell->GetBrowsingContext()->IsTopContent();
+ }
+
+ Document* doc = win->GetDoc();
+ if (!doc) {
+ return false;
+ }
+
+ doc = doc->GetInProcessParentDocument();
+ if (!doc) {
+ // Getting here means something has been torn down so fail safe.
+ return false;
+ }
+
+ win = doc->GetInnerWindow();
+ }
+
+ // Found a document with no inner window, don't grant access to the API.
+ return false;
+}
+#else // We don't support mozAddonManager on Android
+bool AddonManagerWebAPI::IsAPIEnabled(JSContext* aCx, JSObject* aGlobal) {
+ return false;
+}
+#endif // ifndef ANDROID
+
+namespace dom {
+
+bool AddonManagerPermissions::IsHostPermitted(const GlobalObject& /*unused*/,
+ const nsAString& host) {
+ return IsValidHost(NS_ConvertUTF16toUTF8(host));
+}
+
+} // namespace dom
+
+} // namespace mozilla
diff --git a/toolkit/mozapps/extensions/AddonManagerWebAPI.h b/toolkit/mozapps/extensions/AddonManagerWebAPI.h
new file mode 100644
index 0000000000..4f34b366a4
--- /dev/null
+++ b/toolkit/mozapps/extensions/AddonManagerWebAPI.h
@@ -0,0 +1,32 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef addonmanagerwebapi_h_
+#define addonmanagerwebapi_h_
+
+#include "nsPIDOMWindow.h"
+
+namespace mozilla {
+
+class AddonManagerWebAPI {
+ public:
+ static bool IsAPIEnabled(JSContext* aCx, JSObject* aGlobal);
+
+ static bool IsValidSite(nsIURI* uri);
+};
+
+namespace dom {
+
+class AddonManagerPermissions {
+ public:
+ static bool IsHostPermitted(const GlobalObject&, const nsAString& host);
+};
+
+} // namespace dom
+
+} // namespace mozilla
+
+#endif // addonmanagerwebapi_h_
diff --git a/toolkit/mozapps/extensions/Blocklist.sys.mjs b/toolkit/mozapps/extensions/Blocklist.sys.mjs
new file mode 100644
index 0000000000..665b2acf27
--- /dev/null
+++ b/toolkit/mozapps/extensions/Blocklist.sys.mjs
@@ -0,0 +1,1491 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint "valid-jsdoc": [2, {requireReturn: false}] */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ jexlFilterFunc: "resource://services-settings/remote-settings.sys.mjs",
+});
+
+const CascadeFilter = Components.Constructor(
+ "@mozilla.org/cascade-filter;1",
+ "nsICascadeFilter",
+ "setFilterData"
+);
+
+// The whole ID should be surrounded by literal ().
+// IDs may contain alphanumerics, _, -, {}, @ and a literal '.'
+// They may also contain backslashes (needed to escape the {} and dot)
+// We filter out backslash escape sequences (like `\w`) separately
+// (see kEscapeSequences).
+const kIdSubRegex =
+ "\\([" +
+ "\\\\" + // note: just a backslash, but between regex and string it needs escaping.
+ "\\w .{}@-]+\\)";
+
+// prettier-ignore
+// Find regular expressions of the form:
+// /^((id1)|(id2)|(id3)|...|(idN))$/
+// The outer set of parens enclosing the entire list of IDs is optional.
+const kIsMultipleIds = new RegExp(
+ // Start with literal sequence /^(
+ // (the `(` is optional)
+ "^/\\^\\(?" +
+ // Then at least one ID in parens ().
+ kIdSubRegex +
+ // Followed by any number of IDs in () separated by pipes.
+ // Note: using a non-capturing group because we don't care about the value.
+ "(?:\\|" + kIdSubRegex + ")*" +
+ // Finally, we need to end with literal sequence )$/
+ // (the leading `)` is optional like at the start)
+ "\\)?\\$/$"
+);
+
+// Check for a backslash followed by anything other than a literal . or curlies
+const kEscapeSequences = /\\[^.{}]/;
+
+// Used to remove the following 3 things:
+// leading literal /^(
+// plus an optional (
+// any backslash
+// trailing literal )$/
+// plus an optional ) before the )$/
+const kRegExpRemovalRegExp = /^\/\^\(\(?|\\|\)\)?\$\/$/g;
+
+// In order to block add-ons, their type should be part of this list. There
+// may be additional requirements such as requiring the add-on to be signed.
+// See the uses of kXPIAddonTypes before introducing new addon types or
+// providers that differ from the existing types.
+XPCOMUtils.defineLazyGetter(lazy, "kXPIAddonTypes", () => {
+ // In practice, this result is equivalent to ALL_XPI_TYPES in XPIProvider.jsm.
+ // "plugin" (from GMPProvider.sys.mjs) is intentionally omitted, as we decided to
+ // not support blocklisting of GMP plugins in bug 1086668.
+ return lazy.AddonManagerPrivate.getAddonTypesByProvider("XPIProvider");
+});
+
+// For a given input string matcher, produce either a string to compare with,
+// a regular expression, or a set of strings to compare with.
+function processMatcher(str) {
+ if (!str.startsWith("/")) {
+ return str;
+ }
+ // Process regexes matching multiple IDs into a set.
+ if (kIsMultipleIds.test(str) && !kEscapeSequences.test(str)) {
+ // Remove the regexp gunk at the start and end of the string, as well
+ // as all backslashes, and split by )|( to leave the list of IDs.
+ return new Set(str.replace(kRegExpRemovalRegExp, "").split(")|("));
+ }
+ let lastSlash = str.lastIndexOf("/");
+ let pattern = str.slice(1, lastSlash);
+ let flags = str.slice(lastSlash + 1);
+ return new RegExp(pattern, flags);
+}
+
+// Returns true if the addonProps object passes the constraints set by matches.
+// (For every non-null property in matches, the same key must exist in
+// addonProps and its value must match)
+function doesAddonEntryMatch(matches, addonProps) {
+ for (let [key, value] of Object.entries(matches)) {
+ if (value === null || value === undefined) {
+ continue;
+ }
+ if (addonProps[key]) {
+ // If this property matches (member of the set, matches regex, or
+ // an exact string match), continue to look at the other properties of
+ // the `matches` object.
+ // If not, return false immediately.
+ if (value.has && value.has(addonProps[key])) {
+ continue;
+ }
+ if (value.test && value.test(addonProps[key])) {
+ continue;
+ }
+ if (typeof value == "string" && value === addonProps[key]) {
+ continue;
+ }
+ }
+ // If we get here, the property doesn't match, so this entry doesn't match.
+ return false;
+ }
+ // If we get here, all the properties must have matched.
+ return true;
+}
+
+const TOOLKIT_ID = "toolkit@mozilla.org";
+const PREF_BLOCKLIST_ITEM_URL = "extensions.blocklist.itemURL";
+const PREF_BLOCKLIST_ADDONITEM_URL = "extensions.blocklist.addonItemURL";
+const PREF_BLOCKLIST_ENABLED = "extensions.blocklist.enabled";
+const PREF_BLOCKLIST_LEVEL = "extensions.blocklist.level";
+const PREF_BLOCKLIST_USE_MLBF = "extensions.blocklist.useMLBF";
+const PREF_EM_LOGGING_ENABLED = "extensions.logging.enabled";
+const DEFAULT_SEVERITY = 3;
+const DEFAULT_LEVEL = 2;
+const MAX_BLOCK_LEVEL = 3;
+
+const BLOCKLIST_BUCKET = "blocklists";
+
+const BlocklistTelemetry = {
+ init() {
+ // Used by BlocklistTelemetry.recordAddonBlockChangeTelemetry.
+ Services.telemetry.setEventRecordingEnabled("blocklist", true);
+ },
+
+ /**
+ * Record the RemoteSettings Blocklist lastModified server time into the
+ * "blocklist.lastModified_rs keyed scalar (or "Missing Date" when unable
+ * to retrieve a valid timestamp).
+ *
+ * @param {string} blocklistType
+ * The blocklist type that has been updated ("addons" or "addons_mlbf");
+ * the "gfx" blocklist is not covered by this telemetry).
+ * @param {RemoteSettingsClient} remoteSettingsClient
+ * The RemoteSettings client to retrieve the lastModified timestamp from.
+ */
+ async recordRSBlocklistLastModified(blocklistType, remoteSettingsClient) {
+ // In some tests overrides ensureInitialized and remoteSettingsClient
+ // can be undefined, and in that case we don't want to record any
+ // telemetry scalar.
+ if (!remoteSettingsClient) {
+ return;
+ }
+
+ let lastModified = await remoteSettingsClient.getLastModified();
+ if (blocklistType === "addons_mlbf") {
+ BlocklistTelemetry.recordTimeScalar(
+ "lastModified_rs_" + blocklistType,
+ lastModified
+ );
+ BlocklistTelemetry.recordGleanDateTime(
+ Glean.blocklist.lastModifiedRsAddonsMblf,
+ lastModified
+ );
+ }
+ },
+
+ /**
+ * Record a timestamp in telemetry as a UTC string or "Missing Date" if the
+ * input is not a valid timestamp.
+ *
+ * @param {string} telemetryKey
+ * The part of after "blocklist.", as defined in Scalars.yaml.
+ * @param {number} time
+ * A timestamp to record. If invalid, "Missing Date" will be recorded.
+ */
+ recordTimeScalar(telemetryKey, time) {
+ if (time > 0) {
+ // convert from timestamp in ms into UTC datetime string, so it is going
+ // to be record in the same format previously used by blocklist.lastModified_xml.
+ let dateString = new Date(time).toUTCString();
+ Services.telemetry.scalarSet("blocklist." + telemetryKey, dateString);
+ } else {
+ Services.telemetry.scalarSet("blocklist." + telemetryKey, "Missing Date");
+ }
+ },
+
+ /**
+ * Records a glean datetime if time is > than 0, otherwise 0 is submitted.
+ *
+ * @param {nsIGleanDatetime} gleanTelemetry
+ * A glean telemetry datetime object.
+ * @param {number} time
+ * A timestamp to record.
+ */
+ recordGleanDateTime(gleanTelemetry, time) {
+ if (time > 0) {
+ // Glean date times are provided in nanoseconds, `getTime()` yields
+ // milliseconds (after the Unix epoch).
+ gleanTelemetry.set(time * 1000);
+ } else {
+ gleanTelemetry.set(0);
+ }
+ },
+
+ /**
+ * Record whether an add-on is blocked and the parameters that guided the
+ * decision to block or unblock the add-on.
+ *
+ * @param {AddonWrapper|object} addon
+ * The blocked or unblocked add-on. Not necessarily installed.
+ * Could be an object with the id, version and blocklistState
+ * properties when the AddonWrapper is not available (e.g. during
+ * update checks).
+ * @param {string} reason
+ * The reason for recording the event,
+ * "addon_install", "addon_update", "addon_update_check",
+ * "addon_db_modified", "blocklist_update".
+ */
+ recordAddonBlockChangeTelemetry(addon, reason) {
+ // Reduce the timer resolution for anonymity.
+ let hoursSinceInstall = -1;
+ if (reason === "blocklist_update" || reason === "addon_db_modified") {
+ hoursSinceInstall = Math.round(
+ (Date.now() - addon.installDate.getTime()) / 3600000
+ );
+ }
+
+ const value = addon.id;
+ const extra = {
+ blocklistState: `${addon.blocklistState}`,
+ addon_version: addon.version,
+ signed_date: `${addon.signedDate?.getTime() || 0}`,
+ hours_since: `${hoursSinceInstall}`,
+
+ ...ExtensionBlocklistMLBF.getBlocklistMetadataForTelemetry(),
+ };
+ Glean.blocklist.addonBlockChange.record({
+ value,
+ object: reason,
+ blocklist_state: extra.blocklistState,
+ addon_version: extra.addon_version,
+ signed_date: extra.signed_date,
+ hours_since: extra.hours_since,
+ mlbf_last_time: extra.mlbf_last_time,
+ mlbf_generation: extra.mlbf_generation,
+ mlbf_source: extra.mlbf_source,
+ });
+
+ Services.telemetry.recordEvent(
+ "blocklist",
+ "addonBlockChange",
+ reason,
+ value,
+ extra
+ );
+ },
+};
+
+const Utils = {
+ /**
+ * Checks whether this entry is valid for the current OS and ABI.
+ * If the entry has an "os" property then the current OS must appear in
+ * its comma separated list for it to be valid. Similarly for the
+ * xpcomabi property.
+ *
+ * @param {Object} item
+ * The blocklist item.
+ * @returns {bool}
+ * Whether the entry matches the current OS.
+ */
+ matchesOSABI(item) {
+ if (item.os) {
+ let os = item.os.split(",");
+ if (!os.includes(lazy.gAppOS)) {
+ return false;
+ }
+ }
+
+ if (item.xpcomabi) {
+ let xpcomabi = item.xpcomabi.split(",");
+ if (!xpcomabi.includes(lazy.gApp.XPCOMABI)) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ /**
+ * Checks if a version is higher than or equal to the minVersion (if provided)
+ * and lower than or equal to the maxVersion (if provided).
+ * @param {string} version
+ * The version to test.
+ * @param {string?} minVersion
+ * The minimum version. If null it is assumed that version is always
+ * larger.
+ * @param {string?} maxVersion
+ * The maximum version. If null it is assumed that version is always
+ * smaller.
+ * @returns {boolean}
+ * Whether the item matches the range.
+ */
+ versionInRange(version, minVersion, maxVersion) {
+ if (minVersion && Services.vc.compare(version, minVersion) < 0) {
+ return false;
+ }
+ if (maxVersion && Services.vc.compare(version, maxVersion) > 0) {
+ return false;
+ }
+ return true;
+ },
+
+ /**
+ * Tests if this versionRange matches the item specified, and has a matching
+ * targetApplication id and version.
+ * @param {Object} versionRange
+ * The versionRange to check against
+ * @param {string} itemVersion
+ * The version of the actual addon/plugin to test for.
+ * @param {string} appVersion
+ * The version of the application to test for.
+ * @param {string} toolkitVersion
+ * The version of toolkit to check for.
+ * @returns {boolean}
+ * True if this version range covers the item and app/toolkit version given.
+ */
+ versionsMatch(versionRange, itemVersion, appVersion, toolkitVersion) {
+ // Some platforms have no version for plugins, these don't match if there
+ // was a min/maxVersion provided
+ if (!itemVersion && (versionRange.minVersion || versionRange.maxVersion)) {
+ return false;
+ }
+
+ // Check if the item version matches
+ if (
+ !this.versionInRange(
+ itemVersion,
+ versionRange.minVersion,
+ versionRange.maxVersion
+ )
+ ) {
+ return false;
+ }
+
+ // Check if the application or toolkit version matches
+ for (let tA of versionRange.targetApplication) {
+ if (
+ tA.guid == lazy.gAppID &&
+ this.versionInRange(appVersion, tA.minVersion, tA.maxVersion)
+ ) {
+ return true;
+ }
+ if (
+ tA.guid == TOOLKIT_ID &&
+ this.versionInRange(toolkitVersion, tA.minVersion, tA.maxVersion)
+ ) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Given a blocklist JS object entry, ensure it has a versionRange property, where
+ * each versionRange property has a valid severity property
+ * and at least 1 valid targetApplication.
+ * If it didn't have a valid targetApplication array before and/or it was empty,
+ * fill it with an entry with null min/maxVersion properties, which will match
+ * every version.
+ *
+ * If there *are* targetApplications, if any of them don't have a guid property,
+ * assign them the current app's guid.
+ *
+ * @param {Object} entry
+ * blocklist entry object.
+ */
+ ensureVersionRangeIsSane(entry) {
+ if (!entry.versionRange.length) {
+ entry.versionRange.push({});
+ }
+ for (let vr of entry.versionRange) {
+ if (!vr.hasOwnProperty("severity")) {
+ vr.severity = DEFAULT_SEVERITY;
+ }
+ if (!Array.isArray(vr.targetApplication)) {
+ vr.targetApplication = [];
+ }
+ if (!vr.targetApplication.length) {
+ vr.targetApplication.push({ minVersion: null, maxVersion: null });
+ }
+ vr.targetApplication.forEach(tA => {
+ if (!tA.guid) {
+ tA.guid = lazy.gAppID;
+ }
+ });
+ }
+ },
+
+ /**
+ * Create a blocklist URL for the given blockID
+ * @param {String} id the blockID to use
+ * @returns {String} the blocklist URL.
+ */
+ _createBlocklistURL(id) {
+ let url = Services.urlFormatter.formatURLPref(PREF_BLOCKLIST_ITEM_URL);
+ return url.replace(/%blockID%/g, id);
+ },
+};
+
+/**
+ * This custom filter function is used to limit the entries returned
+ * by `RemoteSettings("...").get()` depending on the target app information
+ * defined on entries.
+ *
+ * Note that this is async because `jexlFilterFunc` is async.
+ *
+ * @param {Object} entry a Remote Settings record
+ * @param {Object} environment the JEXL environment object.
+ * @returns {Object} The entry if it matches, `null` otherwise.
+ */
+async function targetAppFilter(entry, environment) {
+ // If the entry has a JEXL filter expression, it should prevail.
+ // The legacy target app mechanism will be kept in place for old entries.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1463377
+ const { filter_expression } = entry;
+ if (filter_expression) {
+ return lazy.jexlFilterFunc(entry, environment);
+ }
+
+ // Keep entries without target information.
+ if (!("versionRange" in entry)) {
+ return entry;
+ }
+
+ const { versionRange } = entry;
+
+ // Everywhere in this method, we avoid checking the minVersion, because
+ // we want to retain items whose minVersion is higher than the current
+ // app version, so that we have the items around for app updates.
+
+ // Gfx blocklist has a specific versionRange object, which is not a list.
+ if (!Array.isArray(versionRange)) {
+ const { maxVersion = "*" } = versionRange;
+ const matchesRange =
+ Services.vc.compare(lazy.gApp.version, maxVersion) <= 0;
+ return matchesRange ? entry : null;
+ }
+
+ // Iterate the targeted applications, at least one of them must match.
+ // If no target application, keep the entry.
+ if (!versionRange.length) {
+ return entry;
+ }
+ for (const vr of versionRange) {
+ const { targetApplication = [] } = vr;
+ if (!targetApplication.length) {
+ return entry;
+ }
+ for (const ta of targetApplication) {
+ const { guid } = ta;
+ if (!guid) {
+ return entry;
+ }
+ const { maxVersion = "*" } = ta;
+ if (
+ guid == lazy.gAppID &&
+ Services.vc.compare(lazy.gApp.version, maxVersion) <= 0
+ ) {
+ return entry;
+ }
+ if (
+ guid == "toolkit@mozilla.org" &&
+ Services.vc.compare(Services.appinfo.platformVersion, maxVersion) <= 0
+ ) {
+ return entry;
+ }
+ }
+ }
+ // Skip this entry.
+ return null;
+}
+
+/**
+ * The Graphics blocklist implementation. The JSON objects for graphics blocks look
+ * something like:
+ *
+ * {
+ * "blockID": "g35",
+ * "os": "WINNT 6.1",
+ * "vendor": "0xabcd",
+ * "devices": [
+ * "0x2783",
+ * "0x1234",
+ * ],
+ * "feature": " DIRECT2D ",
+ * "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ * "driverVersion": " 8.52.322.2202 ",
+ * "driverVersionComparator": " LESS_THAN ",
+ * "versionRange": {"minVersion": "5.0", "maxVersion: "25.0"},
+ * }
+ *
+ * The RemoteSetttings client takes care of filtering out versions that don't apply.
+ * The code here stores entries in memory and sends them to the gfx component in
+ * serialized text form, using ',', '\t' and '\n' as separators.
+ */
+const GfxBlocklistRS = {
+ _ensureInitialized() {
+ if (this._initialized || !gBlocklistEnabled) {
+ return;
+ }
+ this._initialized = true;
+ this._client = lazy.RemoteSettings("gfx", {
+ bucketName: BLOCKLIST_BUCKET,
+ filterFunc: targetAppFilter,
+ });
+ this.checkForEntries = this.checkForEntries.bind(this);
+ this._client.on("sync", this.checkForEntries);
+ },
+
+ shutdown() {
+ if (this._client) {
+ this._client.off("sync", this.checkForEntries);
+ }
+ },
+
+ sync() {
+ this._ensureInitialized();
+ return this._client.sync();
+ },
+
+ async checkForEntries() {
+ this._ensureInitialized();
+ if (!gBlocklistEnabled) {
+ return []; // return value expected by tests.
+ }
+ let entries = await this._client.get().catch(ex => Cu.reportError(ex));
+ // Handle error silently. This can happen if our request to fetch data is aborted,
+ // e.g. by application shutdown.
+ if (!entries) {
+ return [];
+ }
+ // Trim helper (spaces, tabs, no-break spaces..)
+ const trim = s =>
+ (s || "").replace(/(^[\s\uFEFF\xA0]+)|([\s\uFEFF\xA0]+$)/g, "");
+
+ entries = entries.map(entry => {
+ let props = [
+ "blockID",
+ "driverVersion",
+ "driverVersionMax",
+ "driverVersionComparator",
+ "feature",
+ "featureStatus",
+ "os",
+ "vendor",
+ "devices",
+ ];
+ let rv = {};
+ for (let p of props) {
+ let val = entry[p];
+ // Ignore falsy values or empty arrays.
+ if (!val || (Array.isArray(val) && !val.length)) {
+ continue;
+ }
+ if (typeof val == "string") {
+ val = trim(val);
+ } else if (p == "devices") {
+ let invalidDevices = [];
+ let validDevices = [];
+ // We serialize the array of devices as a comma-separated string, so
+ // we need to ensure that none of the entries contain commas, also in
+ // the future.
+ val.forEach(v =>
+ v.includes(",") ? invalidDevices.push(v) : validDevices.push(v)
+ );
+ for (let dev of invalidDevices) {
+ const e = new Error(
+ `Block ${entry.blockID} contains unsupported device: ${dev}`
+ );
+ Cu.reportError(e);
+ }
+ if (!validDevices) {
+ continue;
+ }
+ val = validDevices;
+ }
+ rv[p] = val;
+ }
+ if (entry.versionRange) {
+ rv.versionRange = {
+ minVersion: trim(entry.versionRange.minVersion) || "0",
+ maxVersion: trim(entry.versionRange.maxVersion) || "*",
+ };
+ }
+ return rv;
+ });
+ if (entries.length) {
+ let sortedProps = [
+ "blockID",
+ "devices",
+ "driverVersion",
+ "driverVersionComparator",
+ "driverVersionMax",
+ "feature",
+ "featureStatus",
+ "hardware",
+ "manufacturer",
+ "model",
+ "os",
+ "osversion",
+ "product",
+ "vendor",
+ "versionRange",
+ ];
+ // Notify `GfxInfoBase`, by passing a string serialization.
+ let payload = [];
+ for (let gfxEntry of entries) {
+ let entryLines = [];
+ for (let key of sortedProps) {
+ if (gfxEntry[key]) {
+ let value = gfxEntry[key];
+ if (Array.isArray(value)) {
+ value = value.join(",");
+ } else if (value.maxVersion) {
+ // Both minVersion and maxVersion are always set on each entry.
+ value = value.minVersion + "," + value.maxVersion;
+ }
+ entryLines.push(key + ":" + value);
+ }
+ }
+ payload.push(entryLines.join("\t"));
+ }
+ Services.obs.notifyObservers(
+ null,
+ "blocklist-data-gfxItems",
+ payload.join("\n")
+ );
+ }
+ // The return value is only used by tests.
+ return entries;
+ },
+};
+
+/**
+ * The extensions blocklist implementation. The JSON objects for extension
+ * blocks look something like:
+ *
+ * {
+ * "guid": "someguid@addons.mozilla.org",
+ * "prefs": ["i.am.a.pref.that.needs.resetting"],
+ * "schema": 1480349193877,
+ * "blockID": "i12345",
+ * "details": {
+ * "bug": "https://bugzilla.mozilla.org/show_bug.cgi?id=1234567",
+ * "who": "All Firefox users who have this add-on installed. If you wish to continue using this add-on, you can enable it in the Add-ons Manager.",
+ * "why": "This add-on is in violation of the <a href=\"https://developer.mozilla.org/en-US/Add-ons/Add-on_guidelines\">Add-on Guidelines</a>, using multiple add-on IDs and potentially doing other unwanted activities.",
+ * "name": "Some pretty name",
+ * "created": "2019-05-06T19:52:20Z"
+ * },
+ * "enabled": true,
+ * "versionRange": [
+ * {
+ * "severity": 1,
+ * "maxVersion": "*",
+ * "minVersion": "0",
+ * "targetApplication": []
+ * }
+ * ],
+ * "id": "<unique guid>",
+ * "last_modified": 1480349215672,
+ * }
+ *
+ * This is a legacy format, and implements deprecated operations (bug 1620580).
+ * ExtensionBlocklistMLBF supersedes this implementation.
+ */
+const ExtensionBlocklistRS = {
+ async _ensureEntries() {
+ this.ensureInitialized();
+ if (!this._entries && gBlocklistEnabled) {
+ await this._updateEntries();
+ }
+ },
+
+ async _updateEntries() {
+ if (!gBlocklistEnabled) {
+ this._entries = [];
+ return;
+ }
+ this._entries = await this._client.get().catch(ex => Cu.reportError(ex));
+ // Handle error silently. This can happen if our request to fetch data is aborted,
+ // e.g. by application shutdown.
+ if (!this._entries) {
+ this._entries = [];
+ return;
+ }
+ this._entries.forEach(entry => {
+ entry.matches = {};
+ if (entry.guid) {
+ entry.matches.id = processMatcher(entry.guid);
+ }
+ for (let key of EXTENSION_BLOCK_FILTERS) {
+ if (key == "id" || !entry[key]) {
+ continue;
+ }
+ entry.matches[key] = processMatcher(entry[key]);
+ }
+ Utils.ensureVersionRangeIsSane(entry);
+ });
+
+ BlocklistTelemetry.recordRSBlocklistLastModified("addons", this._client);
+ },
+
+ async _filterItem(entry, environment) {
+ if (!(await targetAppFilter(entry, environment))) {
+ return null;
+ }
+ if (!Utils.matchesOSABI(entry)) {
+ return null;
+ }
+ // Need something to filter on - at least a guid or name (either could be a regex):
+ if (!entry.guid && !entry.name) {
+ let blockID = entry.blockID || entry.id;
+ Cu.reportError(new Error(`Nothing to filter add-on item ${blockID} on`));
+ return null;
+ }
+ return entry;
+ },
+
+ sync() {
+ this.ensureInitialized();
+ return this._client.sync();
+ },
+
+ ensureInitialized() {
+ if (!gBlocklistEnabled || this._initialized) {
+ return;
+ }
+ this._initialized = true;
+ this._client = lazy.RemoteSettings("addons", {
+ bucketName: BLOCKLIST_BUCKET,
+ filterFunc: this._filterItem,
+ });
+ this._onUpdate = this._onUpdate.bind(this);
+ this._client.on("sync", this._onUpdate);
+ },
+
+ shutdown() {
+ if (this._client) {
+ this._client.off("sync", this._onUpdate);
+ this._didShutdown = true;
+ }
+ },
+
+ // Called when the blocklist implementation is changed via a pref.
+ undoShutdown() {
+ if (this._didShutdown) {
+ this._client.on("sync", this._onUpdate);
+ this._didShutdown = false;
+ }
+ },
+
+ async _onUpdate() {
+ let oldEntries = this._entries || [];
+ await this.ensureInitialized();
+ await this._updateEntries();
+
+ let addons = await lazy.AddonManager.getAddonsByTypes(lazy.kXPIAddonTypes);
+ for (let addon of addons) {
+ let oldState = addon.blocklistState;
+ if (addon.updateBlocklistState) {
+ await addon.updateBlocklistState(false);
+ } else if (oldEntries) {
+ let oldEntry = this._getEntry(addon, oldEntries);
+ oldState = oldEntry
+ ? oldEntry.state
+ : Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ } else {
+ oldState = Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ }
+ let state = addon.blocklistState;
+
+ LOG(
+ "Blocklist state for " +
+ addon.id +
+ " changed from " +
+ oldState +
+ " to " +
+ state
+ );
+
+ // We don't want to re-warn about add-ons
+ if (state == oldState) {
+ continue;
+ }
+
+ // Ensure that softDisabled is false if the add-on is not soft blocked
+ if (state != Ci.nsIBlocklistService.STATE_SOFTBLOCKED) {
+ await addon.setSoftDisabled(false);
+ }
+
+ // If an add-on has dropped from hard to soft blocked just mark it as
+ // soft disabled and don't warn about it.
+ if (
+ state == Ci.nsIBlocklistService.STATE_SOFTBLOCKED &&
+ oldState == Ci.nsIBlocklistService.STATE_BLOCKED
+ ) {
+ await addon.setSoftDisabled(true);
+ }
+
+ if (
+ state == Ci.nsIBlocklistService.STATE_BLOCKED ||
+ state == Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ ) {
+ // Mark it as softblocked if necessary. Note that we avoid setting
+ // softDisabled at the same time as userDisabled to make it clear
+ // which was the original cause of the add-on becoming disabled in a
+ // way that the user can change.
+ if (
+ state == Ci.nsIBlocklistService.STATE_SOFTBLOCKED &&
+ !addon.userDisabled
+ ) {
+ await addon.setSoftDisabled(true);
+ }
+ // It's a block. We must reset certain preferences.
+ let entry = this._getEntry(addon, this._entries);
+ if (entry.prefs && entry.prefs.length) {
+ for (let pref of entry.prefs) {
+ Services.prefs.clearUserPref(pref);
+ }
+ }
+ }
+ }
+
+ lazy.AddonManagerPrivate.updateAddonAppDisabledStates();
+ },
+
+ async getState(addon, appVersion, toolkitVersion) {
+ let entry = await this.getEntry(addon, appVersion, toolkitVersion);
+ return entry ? entry.state : Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ },
+
+ async getEntry(addon, appVersion, toolkitVersion) {
+ await this._ensureEntries();
+ return this._getEntry(addon, this._entries, appVersion, toolkitVersion);
+ },
+
+ _getEntry(addon, addonEntries, appVersion, toolkitVersion) {
+ if (!gBlocklistEnabled || !addon) {
+ return null;
+ }
+
+ // Not all applications implement nsIXULAppInfo (e.g. xpcshell doesn't).
+ if (!appVersion && !lazy.gApp.version) {
+ return null;
+ }
+
+ if (!appVersion) {
+ appVersion = lazy.gApp.version;
+ }
+ if (!toolkitVersion) {
+ toolkitVersion = lazy.gApp.platformVersion;
+ }
+
+ let addonProps = {};
+ for (let key of EXTENSION_BLOCK_FILTERS) {
+ addonProps[key] = addon[key];
+ }
+ if (addonProps.creator) {
+ addonProps.creator = addonProps.creator.name;
+ }
+
+ for (let entry of addonEntries) {
+ // First check if it matches our properties. If not, just skip to the next item.
+ if (!doesAddonEntryMatch(entry.matches, addonProps)) {
+ continue;
+ }
+ // If those match, check the app or toolkit version works:
+ for (let versionRange of entry.versionRange) {
+ if (
+ Utils.versionsMatch(
+ versionRange,
+ addon.version,
+ appVersion,
+ toolkitVersion
+ )
+ ) {
+ let blockID = entry.blockID || entry.id;
+ return {
+ state:
+ versionRange.severity >= gBlocklistLevel
+ ? Ci.nsIBlocklistService.STATE_BLOCKED
+ : Ci.nsIBlocklistService.STATE_SOFTBLOCKED,
+ url: Utils._createBlocklistURL(blockID),
+ prefs: entry.prefs || [],
+ };
+ }
+ }
+ }
+ return null;
+ },
+};
+
+/**
+ * The extensions blocklist implementation, the third version.
+ *
+ * The current blocklist is represented by a multi-level bloom filter (MLBF)
+ * (aka "Cascade Bloom Filter") that works like a set, i.e. supports a has()
+ * operation, except it is probabilistic. The MLBF is 100% accurate for known
+ * entries and unreliable for unknown entries. When the backend generates the
+ * MLBF, all known add-ons are recorded, including their block state. Unknown
+ * add-ons are identified by their signature date being newer than the MLBF's
+ * generation time, and they are considered to not be blocked.
+ *
+ * Legacy blocklists used to distinguish between "soft block" and "hard block",
+ * but the current blocklist only supports one type of block ("hard block").
+ * After checking the blocklist states, any previous "soft blocked" addons will
+ * either be (hard) blocked or unblocked based on the blocklist.
+ *
+ * The MLBF is attached to a RemoteSettings record, as follows:
+ *
+ * {
+ * "generation_time": 1585692000000,
+ * "attachment": { ... RemoteSettings attachment ... }
+ * "attachment_type": "bloomfilter-base",
+ * }
+ *
+ * The collection can also contain stashes:
+ *
+ * {
+ * "stash_time": 1585692000001,
+ * "stash": {
+ * "blocked": [ "addonid:1.0", ... ],
+ * "unblocked": [ "addonid:1.0", ... ]
+ * }
+ *
+ * Stashes can be used to update the blocklist without forcing the whole MLBF
+ * to be downloaded again. These stashes are applied on top of the base MLBF.
+ */
+const ExtensionBlocklistMLBF = {
+ RS_ATTACHMENT_ID: "addons-mlbf.bin",
+
+ async _fetchMLBF(record) {
+ // |record| may be unset. In that case, the MLBF dump is used instead
+ // (provided that the client has been built with it included).
+ let hash = record?.attachment.hash;
+ if (this._mlbfData && hash && this._mlbfData.cascadeHash === hash) {
+ // MLBF not changed, save the efforts of downloading the data again.
+
+ // Although the MLBF has not changed, the time in the record has. This
+ // means that the MLBF is known to provide accurate results for add-ons
+ // that were signed after the previously known date (but before the newly
+ // given date). To ensure that add-ons in this time range are also blocked
+ // as expected, update the cached generationTime.
+ if (record.generation_time > this._mlbfData.generationTime) {
+ this._mlbfData.generationTime = record.generation_time;
+ }
+ return this._mlbfData;
+ }
+ const {
+ buffer,
+ record: actualRecord,
+ _source: rsAttachmentSource,
+ } = await this._client.attachments.download(record, {
+ attachmentId: this.RS_ATTACHMENT_ID,
+ fallbackToCache: true,
+ fallbackToDump: true,
+ });
+ return {
+ cascadeHash: actualRecord.attachment.hash,
+ cascadeFilter: new CascadeFilter(new Uint8Array(buffer)),
+ // Note: generation_time is semantically distinct from last_modified.
+ // generation_time is compared with the signing date of the add-on, so it
+ // should be in sync with the signing service's clock.
+ // In contrast, last_modified does not have such strong requirements.
+ generationTime: actualRecord.generation_time,
+ // Used for telemetry.
+ rsAttachmentSource,
+ };
+ },
+
+ async _updateMLBF(forceUpdate = false) {
+ // The update process consists of fetching the collection, followed by
+ // potentially multiple network requests. As long as the collection has not
+ // been changed, repeated update requests can be coalesced. But when the
+ // collection has been updated, all pending update requests should await the
+ // new update request instead of the previous one.
+ if (!forceUpdate && this._updatePromise) {
+ return this._updatePromise;
+ }
+ const isUpdateReplaced = () => this._updatePromise != updatePromise;
+ const updatePromise = (async () => {
+ if (!gBlocklistEnabled) {
+ this._mlbfData = null;
+ this._stashes = null;
+ return;
+ }
+ let records = await this._client.get();
+ if (isUpdateReplaced()) {
+ return;
+ }
+
+ let mlbfRecords = records
+ .filter(r => r.attachment)
+ // Newest attachments first.
+ .sort((a, b) => b.generation_time - a.generation_time);
+ const mlbfRecord = mlbfRecords.find(
+ r => r.attachment_type == "bloomfilter-base"
+ );
+ this._stashes = records
+ .filter(({ stash }) => {
+ return (
+ // Exclude non-stashes, e.g. MLBF attachments.
+ stash &&
+ // Sanity check for type.
+ Array.isArray(stash.blocked) &&
+ Array.isArray(stash.unblocked)
+ );
+ })
+ // Sort by stash time - newest first.
+ .sort((a, b) => b.stash_time - a.stash_time)
+ .map(({ stash, stash_time }) => ({
+ blocked: new Set(stash.blocked),
+ unblocked: new Set(stash.unblocked),
+ stash_time,
+ }));
+
+ let mlbf = await this._fetchMLBF(mlbfRecord);
+ // When a MLBF dump is packaged with the browser, mlbf will always be
+ // non-null at this point.
+ if (isUpdateReplaced()) {
+ return;
+ }
+ this._mlbfData = mlbf;
+ })()
+ .catch(e => {
+ Cu.reportError(e);
+ })
+ .then(() => {
+ if (!isUpdateReplaced()) {
+ this._updatePromise = null;
+ this._recordPostUpdateTelemetry();
+ }
+ return this._updatePromise;
+ });
+ this._updatePromise = updatePromise;
+ return updatePromise;
+ },
+
+ // Update the telemetry of the blocklist. This is always called, even if
+ // the update request failed (e.g. due to network errors or data corruption).
+ _recordPostUpdateTelemetry() {
+ BlocklistTelemetry.recordRSBlocklistLastModified(
+ "addons_mlbf",
+ this._client
+ );
+ Glean.blocklist.mlbfSource.set(
+ this._mlbfData?.rsAttachmentSource || "unknown"
+ );
+ BlocklistTelemetry.recordTimeScalar(
+ "mlbf_generation_time",
+ this._mlbfData?.generationTime
+ );
+ BlocklistTelemetry.recordGleanDateTime(
+ Glean.blocklist.mlbfGenerationTime,
+ this._mlbfData?.generationTime
+ );
+ // stashes has conveniently already been sorted by stash_time, newest first.
+ let stashes = this._stashes || [];
+ BlocklistTelemetry.recordTimeScalar(
+ "mlbf_stash_time_oldest",
+ stashes[stashes.length - 1]?.stash_time
+ );
+ BlocklistTelemetry.recordTimeScalar(
+ "mlbf_stash_time_newest",
+ stashes[0]?.stash_time
+ );
+ BlocklistTelemetry.recordGleanDateTime(
+ Glean.blocklist.mlbfStashTimeOldest,
+ stashes[stashes.length - 1]?.stash_time
+ );
+
+ BlocklistTelemetry.recordGleanDateTime(
+ Glean.blocklist.mlbfStashTimeNewest,
+ stashes[0]?.stash_time
+ );
+ },
+
+ // Used by BlocklistTelemetry.recordAddonBlockChangeTelemetry.
+ getBlocklistMetadataForTelemetry() {
+ // Blocklist telemetry can only be reported when a blocklist decision
+ // has been made. That implies that the blocklist has been loaded, so
+ // ExtensionBlocklistMLBF should have been initialized.
+ // (except when the blocklist is disabled, or blocklist v2 is used)
+ const generationTime = this._mlbfData?.generationTime ?? 0;
+
+ // Keys to include in the blocklist.addonBlockChange telemetry event.
+ return {
+ mlbf_last_time:
+ // stashes are sorted, newest first. Stashes are newer than the MLBF.
+ `${this._stashes?.[0]?.stash_time ?? generationTime}`,
+ mlbf_generation: `${generationTime}`,
+ mlbf_source: this._mlbfData?.rsAttachmentSource ?? "unknown",
+ };
+ },
+
+ ensureInitialized() {
+ if (!gBlocklistEnabled || this._initialized) {
+ return;
+ }
+ this._initialized = true;
+ this._client = lazy.RemoteSettings("addons-bloomfilters", {
+ bucketName: BLOCKLIST_BUCKET,
+ // Prevent the attachment for being pruned, since its ID does
+ // not match any record.
+ keepAttachmentsIds: [this.RS_ATTACHMENT_ID],
+ });
+ this._onUpdate = this._onUpdate.bind(this);
+ this._client.on("sync", this._onUpdate);
+ },
+
+ shutdown() {
+ if (this._client) {
+ this._client.off("sync", this._onUpdate);
+ this._didShutdown = true;
+ }
+ },
+
+ // Called when the blocklist implementation is changed via a pref.
+ undoShutdown() {
+ if (this._didShutdown) {
+ this._client.on("sync", this._onUpdate);
+ this._didShutdown = false;
+ }
+ },
+
+ async _onUpdate() {
+ this.ensureInitialized();
+ await this._updateMLBF(true);
+
+ let addons = await lazy.AddonManager.getAddonsByTypes(lazy.kXPIAddonTypes);
+ for (let addon of addons) {
+ let oldState = addon.blocklistState;
+ await addon.updateBlocklistState(false);
+ let state = addon.blocklistState;
+
+ LOG(
+ "Blocklist state for " +
+ addon.id +
+ " changed from " +
+ oldState +
+ " to " +
+ state
+ );
+
+ // We don't want to re-warn about add-ons
+ if (state == oldState) {
+ continue;
+ }
+
+ // Ensure that softDisabled is false if the add-on is not soft blocked
+ // (by a previous implementation of the blocklist).
+ if (state != Ci.nsIBlocklistService.STATE_SOFTBLOCKED) {
+ await addon.setSoftDisabled(false);
+ }
+
+ BlocklistTelemetry.recordAddonBlockChangeTelemetry(
+ addon,
+ "blocklist_update"
+ );
+ }
+
+ lazy.AddonManagerPrivate.updateAddonAppDisabledStates();
+ },
+
+ async getState(addon) {
+ let state = await this.getEntry(addon);
+ return state ? state.state : Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ },
+
+ async getEntry(addon) {
+ if (!this._stashes) {
+ this.ensureInitialized();
+ await this._updateMLBF(false);
+ } else if (this._updatePromise) {
+ // _stashes has been initialized, but the initialization of _mlbfData is
+ // still pending.
+ await this._updatePromise;
+ }
+
+ let blockKey = addon.id + ":" + addon.version;
+
+ // _stashes will be unset if !gBlocklistEnabled.
+ if (this._stashes) {
+ // Stashes are ordered by newest first.
+ for (let stash of this._stashes) {
+ // blocked and unblocked do not have overlapping entries.
+ if (stash.blocked.has(blockKey)) {
+ return this._createBlockEntry(addon);
+ }
+ if (stash.unblocked.has(blockKey)) {
+ return null;
+ }
+ }
+ }
+
+ // signedDate is a Date if the add-on is signed, null if not signed,
+ // undefined if it's an addon update descriptor instead of an addon wrapper.
+ let { signedDate } = addon;
+ if (!signedDate) {
+ // The MLBF does not apply to unsigned add-ons.
+ return null;
+ }
+
+ if (!this._mlbfData) {
+ // This could happen in theory in any of the following cases:
+ // - the blocklist is disabled.
+ // - The RemoteSettings backend served a malformed MLBF.
+ // - The RemoteSettings backend is unreachable, and this client was built
+ // without including a dump of the MLBF.
+ //
+ // ... in other words, this is unlikely to happen in practice.
+ return null;
+ }
+ let { cascadeFilter, generationTime } = this._mlbfData;
+ if (!cascadeFilter.has(blockKey)) {
+ // Add-on not blocked or unknown.
+ return null;
+ }
+ // Add-on blocked, or unknown add-on inadvertently labeled as blocked.
+
+ let { signedState } = addon;
+ if (
+ signedState !== lazy.AddonManager.SIGNEDSTATE_PRELIMINARY &&
+ signedState !== lazy.AddonManager.SIGNEDSTATE_SIGNED
+ ) {
+ // The block decision can only be relied upon for known add-ons, i.e.
+ // signed via AMO. Anything else is unknown and ignored:
+ //
+ // - SIGNEDSTATE_SYSTEM and SIGNEDSTATE_PRIVILEGED are signed
+ // independently of AMO.
+ //
+ // - SIGNEDSTATE_NOT_REQUIRED already has an early return above due to
+ // signedDate being unset for these kinds of add-ons.
+ //
+ // - SIGNEDSTATE_BROKEN, SIGNEDSTATE_UNKNOWN and SIGNEDSTATE_MISSING
+ // means that the signature cannot be relied upon. It is equivalent to
+ // removing the signature from the XPI file, which already causes them
+ // to be disabled on release builds (where MOZ_REQUIRE_SIGNING=true).
+ return null;
+ }
+
+ if (signedDate.getTime() > generationTime) {
+ // The bloom filter only reports 100% accurate results for known add-ons.
+ // Since the add-on was unknown when the bloom filter was generated, the
+ // block decision is incorrect and should be treated as unblocked.
+ return null;
+ }
+
+ if (AppConstants.NIGHTLY_BUILD && addon.type === "locale") {
+ // Only Mozilla can create langpacks with a valid signature.
+ // Langpacks for Release, Beta and ESR are submitted to AMO.
+ // DevEd does not support external langpacks (bug 1563923), only builtins.
+ // (and built-in addons are not subjected to the blocklist).
+ // Langpacks for Nightly are not known to AMO, so the MLBF cannot be used.
+ return null;
+ }
+
+ return this._createBlockEntry(addon);
+ },
+
+ _createBlockEntry(addon) {
+ return {
+ state: Ci.nsIBlocklistService.STATE_BLOCKED,
+ url: this.createBlocklistURL(addon.id, addon.version),
+ };
+ },
+
+ createBlocklistURL(id, version) {
+ let url = Services.urlFormatter.formatURLPref(PREF_BLOCKLIST_ADDONITEM_URL);
+ return url.replace(/%addonID%/g, id).replace(/%addonVersion%/g, version);
+ },
+};
+
+const EXTENSION_BLOCK_FILTERS = [
+ "id",
+ "name",
+ "creator",
+ "homepageURL",
+ "updateURL",
+];
+
+var gLoggingEnabled = null;
+var gBlocklistEnabled = true;
+var gBlocklistLevel = DEFAULT_LEVEL;
+
+/**
+ * @class nsIBlocklistPrompt
+ *
+ * nsIBlocklistPrompt is used, if available, by the default implementation of
+ * nsIBlocklistService to display a confirmation UI to the user before blocking
+ * extensions/plugins.
+ */
+/**
+ * @method prompt
+ *
+ * Prompt the user about newly blocked addons. The prompt is then resposible
+ * for soft-blocking any addons that need to be afterwards
+ *
+ * @param {object[]} aAddons
+ * An array of addons and plugins that are blocked. These are javascript
+ * objects with properties:
+ * name - the plugin or extension name,
+ * version - the version of the extension or plugin,
+ * icon - the plugin or extension icon,
+ * disable - can be used by the nsIBlocklistPrompt to allows users to decide
+ * whether a soft-blocked add-on should be disabled,
+ * blocked - true if the item is hard-blocked, false otherwise,
+ * item - the nsIPluginTag or Addon object
+ */
+
+// It is not possible to use the one in Services since it will not successfully
+// QueryInterface nsIXULAppInfo in xpcshell tests due to other code calling
+// Services.appinfo before the nsIXULAppInfo is created by the tests.
+XPCOMUtils.defineLazyGetter(lazy, "gApp", function () {
+ // eslint-disable-next-line mozilla/use-services
+ let appinfo = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime);
+ try {
+ appinfo.QueryInterface(Ci.nsIXULAppInfo);
+ } catch (ex) {
+ // Not all applications implement nsIXULAppInfo (e.g. xpcshell doesn't).
+ if (
+ !(ex instanceof Components.Exception) ||
+ ex.result != Cr.NS_NOINTERFACE
+ ) {
+ throw ex;
+ }
+ }
+ return appinfo;
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "gAppID", function () {
+ return lazy.gApp.ID;
+});
+XPCOMUtils.defineLazyGetter(lazy, "gAppOS", function () {
+ return lazy.gApp.OS;
+});
+
+/**
+ * Logs a string to the error console.
+ * @param {string} string
+ * The string to write to the error console..
+ */
+function LOG(string) {
+ if (gLoggingEnabled) {
+ dump("*** " + string + "\n");
+ Services.console.logStringMessage(string);
+ }
+}
+
+export let Blocklist = {
+ _init() {
+ Services.obs.addObserver(this, "xpcom-shutdown");
+ gLoggingEnabled = Services.prefs.getBoolPref(
+ PREF_EM_LOGGING_ENABLED,
+ false
+ );
+ gBlocklistEnabled = Services.prefs.getBoolPref(
+ PREF_BLOCKLIST_ENABLED,
+ true
+ );
+ gBlocklistLevel = Math.min(
+ Services.prefs.getIntPref(PREF_BLOCKLIST_LEVEL, DEFAULT_LEVEL),
+ MAX_BLOCK_LEVEL
+ );
+ this._chooseExtensionBlocklistImplementationFromPref();
+ Services.prefs.addObserver("extensions.blocklist.", this);
+ Services.prefs.addObserver(PREF_EM_LOGGING_ENABLED, this);
+ BlocklistTelemetry.init();
+ },
+ isLoaded: true,
+
+ shutdown() {
+ GfxBlocklistRS.shutdown();
+ this.ExtensionBlocklist.shutdown();
+
+ Services.obs.removeObserver(this, "xpcom-shutdown");
+ Services.prefs.removeObserver("extensions.blocklist.", this);
+ Services.prefs.removeObserver(PREF_EM_LOGGING_ENABLED, this);
+ },
+
+ observe(subject, topic, prefName) {
+ switch (topic) {
+ case "xpcom-shutdown":
+ this.shutdown();
+ break;
+ case "nsPref:changed":
+ switch (prefName) {
+ case PREF_EM_LOGGING_ENABLED:
+ gLoggingEnabled = Services.prefs.getBoolPref(
+ PREF_EM_LOGGING_ENABLED,
+ false
+ );
+ break;
+ case PREF_BLOCKLIST_ENABLED:
+ gBlocklistEnabled = Services.prefs.getBoolPref(
+ PREF_BLOCKLIST_ENABLED,
+ true
+ );
+ this._blocklistUpdated();
+ break;
+ case PREF_BLOCKLIST_LEVEL:
+ gBlocklistLevel = Math.min(
+ Services.prefs.getIntPref(PREF_BLOCKLIST_LEVEL, DEFAULT_LEVEL),
+ MAX_BLOCK_LEVEL
+ );
+ this._blocklistUpdated();
+ break;
+ case PREF_BLOCKLIST_USE_MLBF:
+ let oldImpl = this.ExtensionBlocklist;
+ this._chooseExtensionBlocklistImplementationFromPref();
+ // The implementation may be unchanged when the pref is ignored.
+ if (oldImpl != this.ExtensionBlocklist && oldImpl._initialized) {
+ oldImpl.shutdown();
+ this.ExtensionBlocklist.undoShutdown();
+ this.ExtensionBlocklist._onUpdate();
+ } // else neither has been initialized yet. Wait for it to happen.
+ break;
+ }
+ break;
+ }
+ },
+
+ loadBlocklistAsync() {
+ // Need to ensure we notify gfx of new stuff.
+ // Geckoview calls this for each new tab (bug 1730026), so ensure we only
+ // check for entries when first initialized.
+ if (!GfxBlocklistRS._initialized) {
+ GfxBlocklistRS.checkForEntries();
+ }
+ this.ExtensionBlocklist.ensureInitialized();
+ },
+
+ getAddonBlocklistState(addon, appVersion, toolkitVersion) {
+ // NOTE: appVersion/toolkitVersion are only used by ExtensionBlocklistRS.
+ return this.ExtensionBlocklist.getState(addon, appVersion, toolkitVersion);
+ },
+
+ getAddonBlocklistEntry(addon, appVersion, toolkitVersion) {
+ // NOTE: appVersion/toolkitVersion are only used by ExtensionBlocklistRS.
+ return this.ExtensionBlocklist.getEntry(addon, appVersion, toolkitVersion);
+ },
+
+ recordAddonBlockChangeTelemetry(addon, reason) {
+ BlocklistTelemetry.recordAddonBlockChangeTelemetry(addon, reason);
+ },
+ // TODO bug 1649906 and 1824863: Remove blocklist v2 (dead code).
+ allowDeprecatedBlocklistV2:
+ AppConstants.platform === "android" && !AppConstants.NIGHTLY_BUILD,
+
+ _chooseExtensionBlocklistImplementationFromPref() {
+ if (
+ this.allowDeprecatedBlocklistV2 &&
+ !Services.prefs.getBoolPref(PREF_BLOCKLIST_USE_MLBF, false)
+ ) {
+ this.ExtensionBlocklist = ExtensionBlocklistRS;
+ } else {
+ this.ExtensionBlocklist = ExtensionBlocklistMLBF;
+ }
+ },
+
+ _blocklistUpdated() {
+ this.ExtensionBlocklist._onUpdate();
+ },
+};
+
+Blocklist._init();
+
+// Allow tests to reach implementation objects.
+export const BlocklistPrivate = {
+ BlocklistTelemetry,
+ ExtensionBlocklistMLBF,
+ ExtensionBlocklistRS,
+ GfxBlocklistRS,
+};
diff --git a/toolkit/mozapps/extensions/LightweightThemeManager.sys.mjs b/toolkit/mozapps/extensions/LightweightThemeManager.sys.mjs
new file mode 100644
index 0000000000..8a9225e8a7
--- /dev/null
+++ b/toolkit/mozapps/extensions/LightweightThemeManager.sys.mjs
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Holds optional fallback theme data that will be returned when no data for an
+// active theme can be found. This the case for WebExtension Themes, for example.
+var _fallbackThemeData = null;
+
+export var LightweightThemeManager = {
+ set fallbackThemeData(data) {
+ if (data && Object.getOwnPropertyNames(data).length) {
+ _fallbackThemeData = Object.assign({}, data);
+ } else {
+ _fallbackThemeData = null;
+ }
+ },
+
+ /*
+ * Returns the currently active theme, taking the fallback theme into account
+ * if we'd be using the default theme otherwise.
+ *
+ * This will always return the original theme data and not make use of
+ * locally persisted resources.
+ */
+ get currentThemeWithFallback() {
+ return _fallbackThemeData && _fallbackThemeData.theme;
+ },
+
+ get themeData() {
+ return _fallbackThemeData || { theme: null };
+ },
+};
diff --git a/toolkit/mozapps/extensions/amContentHandler.sys.mjs b/toolkit/mozapps/extensions/amContentHandler.sys.mjs
new file mode 100644
index 0000000000..7da2355dcb
--- /dev/null
+++ b/toolkit/mozapps/extensions/amContentHandler.sys.mjs
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const XPI_CONTENT_TYPE = "application/x-xpinstall";
+const MSG_INSTALL_ADDON = "WebInstallerInstallAddonFromWebpage";
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ ThirdPartyUtil: ["@mozilla.org/thirdpartyutil;1", "mozIThirdPartyUtil"],
+});
+
+export function amContentHandler() {}
+
+amContentHandler.prototype = {
+ /**
+ * Handles a new request for an application/x-xpinstall file.
+ *
+ * @param aMimetype
+ * The mimetype of the file
+ * @param aContext
+ * The context passed to nsIChannel.asyncOpen
+ * @param aRequest
+ * The nsIRequest dealing with the content
+ */
+ handleContent(aMimetype, aContext, aRequest) {
+ if (aMimetype != XPI_CONTENT_TYPE) {
+ throw Components.Exception("", Cr.NS_ERROR_WONT_HANDLE_CONTENT);
+ }
+
+ if (!(aRequest instanceof Ci.nsIChannel)) {
+ throw Components.Exception("", Cr.NS_ERROR_WONT_HANDLE_CONTENT);
+ }
+
+ let uri = aRequest.URI;
+
+ // This check will allow a link to an xpi clicked by the user to trigger the
+ // addon install flow, but prevents window.open or window.location from triggering
+ // an addon install even when called from inside a event listener triggered by
+ // user input.
+ if (
+ !aRequest.loadInfo.hasValidUserGestureActivation &&
+ Services.prefs.getBoolPref("xpinstall.userActivation.required", true)
+ ) {
+ const error = Components.Exception(
+ `${uri.spec} install cancelled because of missing user gesture activation`,
+ Cr.NS_ERROR_WONT_HANDLE_CONTENT
+ );
+ // Report the error in the BrowserConsole, the error thrown from here doesn't
+ // seem to be visible anywhere.
+ Cu.reportError(error);
+ throw error;
+ }
+
+ aRequest.cancel(Cr.NS_BINDING_ABORTED);
+
+ let { loadInfo } = aRequest;
+ const { triggeringPrincipal } = loadInfo;
+
+ let browsingContext = loadInfo.targetBrowsingContext;
+
+ let sourceHost;
+ let sourceURL;
+
+ try {
+ sourceURL =
+ triggeringPrincipal.spec != "" ? triggeringPrincipal.spec : undefined;
+ sourceHost = triggeringPrincipal.host;
+ } catch (error) {
+ // Ignore errors when retrieving the host for the principal (e.g. data URIs return
+ // an NS_ERROR_FAILURE when principal.host is accessed).
+ }
+
+ let install = {
+ uri: uri.spec,
+ hash: null,
+ name: null,
+ icon: null,
+ mimetype: XPI_CONTENT_TYPE,
+ triggeringPrincipal,
+ callbackID: -1,
+ method: "link",
+ sourceHost,
+ sourceURL,
+ browsingContext,
+ hasCrossOriginAncestor: lazy.ThirdPartyUtil.isThirdPartyChannel(aRequest),
+ };
+
+ Services.cpmm.sendAsyncMessage(MSG_INSTALL_ADDON, install);
+ },
+
+ classID: Components.ID("{7beb3ba8-6ec3-41b4-b67c-da89b8518922}"),
+ QueryInterface: ChromeUtils.generateQI(["nsIContentHandler"]),
+
+ log(aMsg) {
+ let msg = "amContentHandler.js: " + (aMsg.join ? aMsg.join("") : aMsg);
+ Services.console.logStringMessage(msg);
+ dump(msg + "\n");
+ },
+};
diff --git a/toolkit/mozapps/extensions/amIAddonManagerStartup.idl b/toolkit/mozapps/extensions/amIAddonManagerStartup.idl
new file mode 100644
index 0000000000..10a235a849
--- /dev/null
+++ b/toolkit/mozapps/extensions/amIAddonManagerStartup.idl
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIFile;
+interface nsIJSRAIIHelper;
+interface nsIURI;
+
+[scriptable, builtinclass, uuid(01dfa47b-87e4-4135-877b-586d033e1b5d)]
+interface amIAddonManagerStartup : nsISupports
+{
+ /**
+ * Reads and parses startup data from the addonState.json.lz4 file, checks
+ * for modifications, and returns the result.
+ *
+ * Returns null for an empty or nonexistent state file, but throws for an
+ * invalid one.
+ */
+ [implicit_jscontext]
+ jsval readStartupData();
+
+ /**
+ * Registers a set of dynamic chrome registry entries, and returns an object
+ * with a `destruct()` method which must be called in order to unregister
+ * the entries.
+ *
+ * @param manifestURI The base manifest URI for the entries. URL values are
+ * resolved relative to this URI.
+ * @param entries An array of arrays, each containing a registry entry as it
+ * would appar in a chrome.manifest file. Only the following entry
+ * types are currently accepted:
+ *
+ * - "locale" A locale package entry. Must be a 4-element array.
+ * - "override" A URL override entry. Must be a 3-element array.
+ */
+ [implicit_jscontext]
+ nsIJSRAIIHelper registerChrome(in nsIURI manifestURI, in jsval entries);
+
+ [implicit_jscontext]
+ jsval encodeBlob(in jsval value);
+
+ [implicit_jscontext]
+ jsval decodeBlob(in jsval value);
+
+ /**
+ * Enumerates over all entries in the JAR file at the given URI, and returns
+ * an array of entry paths which match the given pattern. The URI may be
+ * either a file: URL pointing directly to a zip file, or a jar: URI
+ * pointing to a zip file nested within another zip file. Only one level of
+ * nesting is supported.
+ *
+ * This should be used in preference to manually opening or retrieving a
+ * ZipReader from the zip cache, since the former causes main thread IO and
+ * the latter can lead to file locking issues due to unpredictable GC behavior
+ * keeping the cached ZipReader alive after the cache is flushed.
+ *
+ * @param uri The URI of the zip file to enumerate.
+ * @param pattern The pattern to match, as passed to nsIZipReader.findEntries.
+ */
+ Array<AString> enumerateJAR(in nsIURI uri, in AUTF8String pattern);
+
+ /**
+ * Similar to |enumerateJAR| above, but accepts the URI of a directory
+ * within a JAR file, and returns a list of all entries below it.
+ *
+ * The given URI must be a jar: URI, and its JAR file must point either to a
+ * file: URI, or to a singly-nested JAR within another JAR file (i.e.,
+ * "jar:file:///thing.jar!/" or "jar:jar:file:///thing.jar!/stuff.jar!/").
+ * Multiple levels of nesting are not supported.
+ */
+ Array<AString> enumerateJARSubtree(in nsIURI uri);
+
+ /**
+ * Initializes the URL Preloader.
+ *
+ * NOT FOR USE OUTSIDE OF UNIT TESTS.
+ */
+ void initializeURLPreloader();
+
+};
diff --git a/toolkit/mozapps/extensions/amIWebInstallPrompt.idl b/toolkit/mozapps/extensions/amIWebInstallPrompt.idl
new file mode 100644
index 0000000000..6724303cba
--- /dev/null
+++ b/toolkit/mozapps/extensions/amIWebInstallPrompt.idl
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIURI;
+interface nsIVariant;
+
+webidl Element;
+
+/**
+ * amIWebInstallPrompt is used, if available, by the default implementation of
+ * amIWebInstallInfo to display a confirmation UI to the user before running
+ * installs.
+ */
+[scriptable, uuid(386906f1-4d18-45bf-bc81-5dcd68e42c3b)]
+interface amIWebInstallPrompt : nsISupports
+{
+ /**
+ * Get a confirmation that the user wants to start the installs.
+ *
+ * @param aBrowser
+ * The browser that triggered the installs
+ * @param aUri
+ * The URI of the site that triggered the installs
+ * @param aInstalls
+ * The AddonInstalls that were requested
+ */
+ void confirm(in Element aBrowser, in nsIURI aUri,
+ in Array<nsIVariant> aInstalls);
+};
diff --git a/toolkit/mozapps/extensions/amInstallTrigger.sys.mjs b/toolkit/mozapps/extensions/amInstallTrigger.sys.mjs
new file mode 100644
index 0000000000..fc1c506bf0
--- /dev/null
+++ b/toolkit/mozapps/extensions/amInstallTrigger.sys.mjs
@@ -0,0 +1,271 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 { Preferences } from "resource://gre/modules/Preferences.sys.mjs";
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ ThirdPartyUtil: ["@mozilla.org/thirdpartyutil;1", "mozIThirdPartyUtil"],
+});
+
+const XPINSTALL_MIMETYPE = "application/x-xpinstall";
+
+const MSG_INSTALL_ENABLED = "WebInstallerIsInstallEnabled";
+const MSG_INSTALL_ADDON = "WebInstallerInstallAddonFromWebpage";
+const MSG_INSTALL_CALLBACK = "WebInstallerInstallCallback";
+
+const SUPPORTED_XPI_SCHEMES = ["http", "https"];
+
+var log = Log.repository.getLogger("AddonManager.InstallTrigger");
+log.level =
+ Log.Level[
+ Preferences.get("extensions.logging.enabled", false) ? "Warn" : "Trace"
+ ];
+
+function CallbackObject(id, callback, mediator) {
+ this.id = id;
+ this.callback = callback;
+ this.callCallback = function (url, status) {
+ try {
+ this.callback(url, status);
+ } catch (e) {
+ log.warn("InstallTrigger callback threw an exception: " + e);
+ }
+
+ mediator._callbacks.delete(id);
+ };
+}
+
+function RemoteMediator(window) {
+ this._windowID = window.windowGlobalChild.innerWindowId;
+
+ this.mm = window.docShell.messageManager;
+ this.mm.addWeakMessageListener(MSG_INSTALL_CALLBACK, this);
+
+ this._lastCallbackID = 0;
+ this._callbacks = new Map();
+}
+
+RemoteMediator.prototype = {
+ receiveMessage(message) {
+ if (message.name == MSG_INSTALL_CALLBACK) {
+ let payload = message.data;
+ let callbackHandler = this._callbacks.get(payload.callbackID);
+ if (callbackHandler) {
+ callbackHandler.callCallback(payload.url, payload.status);
+ }
+ }
+ },
+
+ enabled(url) {
+ let params = {
+ mimetype: XPINSTALL_MIMETYPE,
+ };
+ return this.mm.sendSyncMessage(MSG_INSTALL_ENABLED, params)[0];
+ },
+
+ install(install, principal, callback, window) {
+ let callbackID = this._addCallback(callback);
+
+ install.mimetype = XPINSTALL_MIMETYPE;
+ install.triggeringPrincipal = principal;
+ install.callbackID = callbackID;
+ install.browsingContext = BrowsingContext.getFromWindow(window);
+
+ return Services.cpmm.sendSyncMessage(MSG_INSTALL_ADDON, install)[0];
+ },
+
+ _addCallback(callback) {
+ if (!callback || typeof callback != "function") {
+ return -1;
+ }
+
+ let callbackID = this._windowID + "-" + ++this._lastCallbackID;
+ let callbackObject = new CallbackObject(callbackID, callback, this);
+ this._callbacks.set(callbackID, callbackObject);
+ return callbackID;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsISupportsWeakReference"]),
+};
+
+export function InstallTrigger() {}
+
+InstallTrigger.prototype = {
+ // We've declared ourselves as providing the nsIDOMGlobalPropertyInitializer
+ // interface. This means that when the InstallTrigger property is gotten from
+ // the window that will createInstance this object and then call init(),
+ // passing the window were bound to. It will then automatically create the
+ // WebIDL wrapper (InstallTriggerImpl) for this object. This indirection is
+ // necessary because webidl does not (yet) support statics (bug 863952). See
+ // bug 926712 and then bug 1442360 for more details about this implementation.
+ init(window) {
+ this._window = window;
+ this._principal = window.document.nodePrincipal;
+ this._url = window.document.documentURIObject;
+
+ this._mediator = new RemoteMediator(window);
+ // If we can't set up IPC (e.g., because this is a top-level window or
+ // something), then don't expose InstallTrigger. The Window code handles
+ // that, if we throw an exception here.
+ },
+
+ enabled() {
+ return this._mediator.enabled(this._url.spec);
+ },
+
+ updateEnabled() {
+ return this.enabled();
+ },
+
+ install(installs, callback) {
+ if (Services.prefs.getBoolPref("xpinstall.userActivation.required", true)) {
+ if (!this._window.windowUtils.isHandlingUserInput) {
+ throw new this._window.Error(
+ "InstallTrigger.install can only be called from a user input handler"
+ );
+ }
+ }
+
+ let keys = Object.keys(installs);
+ if (keys.length > 1) {
+ throw new this._window.Error("Only one XPI may be installed at a time");
+ }
+
+ let item = installs[keys[0]];
+
+ if (typeof item === "string") {
+ item = { URL: item };
+ }
+ if (!item.URL) {
+ throw new this._window.Error(
+ "Missing URL property for '" + keys[0] + "'"
+ );
+ }
+
+ let url = this._resolveURL(item.URL);
+ if (!this._checkLoadURIFromScript(url)) {
+ throw new this._window.Error(
+ "Insufficient permissions to install: " + url.spec
+ );
+ }
+
+ if (!SUPPORTED_XPI_SCHEMES.includes(url.scheme)) {
+ Cu.reportError(
+ `InstallTrigger call disallowed on install url with unsupported scheme: ${JSON.stringify(
+ {
+ installPrincipal: this._principal.spec,
+ installURL: url.spec,
+ }
+ )}`
+ );
+ throw new this._window.Error(`Unsupported scheme`);
+ }
+
+ let iconUrl = null;
+ if (item.IconURL) {
+ iconUrl = this._resolveURL(item.IconURL);
+ if (!this._checkLoadURIFromScript(iconUrl)) {
+ iconUrl = null; // If page can't load the icon, just ignore it
+ }
+ }
+
+ const getTriggeringSource = () => {
+ let url;
+ let host;
+ try {
+ if (this._url?.schemeIs("http") || this._url?.schemeIs("https")) {
+ url = this._url.spec;
+ host = this._url.host;
+ } else if (this._url?.schemeIs("blob")) {
+ // For a blob url, we keep the blob url as the sourceURL and
+ // we pick the related sourceHost from either the principal
+ // or the precursorPrincipal (if the principal is a null principal
+ // and the precursor one is defined).
+ url = this._url.spec;
+ host =
+ this._principal.isNullPrincipal &&
+ this._principal.precursorPrincipal
+ ? this._principal.precursorPrincipal.host
+ : this._principal.host;
+ } else if (!this._principal.isNullPrincipal) {
+ url = this._principal.exposableSpec;
+ host = this._principal.host;
+ } else if (this._principal.precursorPrincipal) {
+ url = this._principal.precursorPrincipal.exposableSpec;
+ host = this._principal.precursorPrincipal.host;
+ } else {
+ // Fallback to this._url as last resort.
+ url = this._url.spec;
+ host = this._url.host;
+ }
+ } catch (err) {
+ Cu.reportError(err);
+ }
+ // Fallback to an empty string if url and host are still undefined.
+ return {
+ sourceURL: url || "",
+ sourceHost: host || "",
+ };
+ };
+
+ const { sourceHost, sourceURL } = getTriggeringSource();
+
+ let installData = {
+ uri: url.spec,
+ hash: item.Hash || null,
+ name: item.name,
+ icon: iconUrl ? iconUrl.spec : null,
+ method: "installTrigger",
+ sourceHost,
+ sourceURL,
+ hasCrossOriginAncestor: lazy.ThirdPartyUtil.isThirdPartyWindow(
+ this._window
+ ),
+ };
+
+ return this._mediator.install(
+ installData,
+ this._principal,
+ callback,
+ this._window
+ );
+ },
+
+ startSoftwareUpdate(url, flags) {
+ let filename = Services.io.newURI(url).QueryInterface(Ci.nsIURL).filename;
+ let args = {};
+ args[filename] = { URL: url };
+ return this.install(args);
+ },
+
+ installChrome(type, url, skin) {
+ return this.startSoftwareUpdate(url);
+ },
+
+ _resolveURL(url) {
+ return Services.io.newURI(url, null, this._url);
+ },
+
+ _checkLoadURIFromScript(uri) {
+ let secman = Services.scriptSecurityManager;
+ try {
+ secman.checkLoadURIWithPrincipal(
+ this._principal,
+ uri,
+ secman.DISALLOW_INHERIT_PRINCIPAL
+ );
+ return true;
+ } catch (e) {
+ return false;
+ }
+ },
+
+ classID: Components.ID("{9df8ef2b-94da-45c9-ab9f-132eb55fddf1}"),
+ contractID: "@mozilla.org/addons/installtrigger;1",
+ QueryInterface: ChromeUtils.generateQI(["nsIDOMGlobalPropertyInitializer"]),
+};
diff --git a/toolkit/mozapps/extensions/amManager.sys.mjs b/toolkit/mozapps/extensions/amManager.sys.mjs
new file mode 100644
index 0000000000..4f04bfe39d
--- /dev/null
+++ b/toolkit/mozapps/extensions/amManager.sys.mjs
@@ -0,0 +1,359 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This component serves as integration between the platform and AddonManager.
+ * It is responsible for initializing and shutting down the AddonManager as well
+ * as passing new installs from webpages to the AddonManager.
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "separatePrivilegedMozillaWebContentProcess",
+ "browser.tabs.remote.separatePrivilegedMozillaWebContentProcess",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "extensionsWebAPITesting",
+ "extensions.webapi.testing",
+ false
+);
+
+// The old XPInstall error codes
+const EXECUTION_ERROR = -203;
+const CANT_READ_ARCHIVE = -207;
+const USER_CANCELLED = -210;
+const DOWNLOAD_ERROR = -228;
+const UNSUPPORTED_TYPE = -244;
+const SUCCESS = 0;
+
+const MSG_INSTALL_ENABLED = "WebInstallerIsInstallEnabled";
+const MSG_INSTALL_ADDON = "WebInstallerInstallAddonFromWebpage";
+const MSG_INSTALL_CALLBACK = "WebInstallerInstallCallback";
+
+const MSG_PROMISE_REQUEST = "WebAPIPromiseRequest";
+const MSG_PROMISE_RESULT = "WebAPIPromiseResult";
+const MSG_INSTALL_EVENT = "WebAPIInstallEvent";
+const MSG_INSTALL_CLEANUP = "WebAPICleanup";
+const MSG_ADDON_EVENT_REQ = "WebAPIAddonEventRequest";
+const MSG_ADDON_EVENT = "WebAPIAddonEvent";
+
+var AddonManager, AddonManagerPrivate;
+
+export function amManager() {
+ ({ AddonManager, AddonManagerPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+ ));
+
+ Services.mm.addMessageListener(MSG_INSTALL_ENABLED, this);
+ Services.mm.addMessageListener(MSG_PROMISE_REQUEST, this);
+ Services.mm.addMessageListener(MSG_INSTALL_CLEANUP, this);
+ Services.mm.addMessageListener(MSG_ADDON_EVENT_REQ, this);
+
+ Services.ppmm.addMessageListener(MSG_INSTALL_ADDON, this);
+
+ Services.obs.addObserver(this, "message-manager-close");
+ Services.obs.addObserver(this, "message-manager-disconnect");
+
+ AddonManager.webAPI.setEventHandler(this.sendEvent);
+
+ // Needed so receiveMessage can be called directly by JS callers
+ this.wrappedJSObject = this;
+}
+
+amManager.prototype = {
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "addons-startup":
+ AddonManagerPrivate.startup();
+ break;
+
+ case "message-manager-close":
+ case "message-manager-disconnect":
+ this.childClosed(aSubject);
+ break;
+ }
+ },
+
+ installAddonFromWebpage(aPayload, aBrowser, aCallback) {
+ let retval = true;
+
+ const { mimetype, triggeringPrincipal, hash, icon, name, uri } = aPayload;
+
+ // NOTE: consider removing this call to isInstallAllowed from here, later it is going to be called
+ // again from inside AddonManager.installAddonFromWebpage as part of the block/allow logic.
+ //
+ // The sole purpose of the call here seems to be "clearing the optional InstallTrigger callback",
+ // which seems to be actually wrong if we are still proceeding to call getInstallForURL and the same
+ // logic used to block the install flow using the exact same method call later on.
+ if (!AddonManager.isInstallAllowed(mimetype, triggeringPrincipal)) {
+ aCallback = null;
+ retval = false;
+ }
+
+ let telemetryInfo = {
+ source: AddonManager.getInstallSourceFromHost(aPayload.sourceHost),
+ sourceURL: aPayload.sourceURL,
+ };
+
+ if ("method" in aPayload) {
+ telemetryInfo.method = aPayload.method;
+ }
+
+ AddonManager.getInstallForURL(uri, {
+ hash,
+ name,
+ icon,
+ browser: aBrowser,
+ triggeringPrincipal,
+ telemetryInfo,
+ sendCookies: true,
+ }).then(aInstall => {
+ function callCallback(status) {
+ try {
+ aCallback?.onInstallEnded(uri, status);
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+
+ if (!aInstall) {
+ callCallback(UNSUPPORTED_TYPE);
+ return;
+ }
+
+ if (aCallback) {
+ aInstall.addListener({
+ onDownloadCancelled(aInstall) {
+ callCallback(USER_CANCELLED);
+ },
+
+ onDownloadFailed(aInstall) {
+ if (aInstall.error == AddonManager.ERROR_CORRUPT_FILE) {
+ callCallback(CANT_READ_ARCHIVE);
+ } else {
+ callCallback(DOWNLOAD_ERROR);
+ }
+ },
+
+ onInstallFailed(aInstall) {
+ callCallback(EXECUTION_ERROR);
+ },
+
+ onInstallEnded(aInstall, aStatus) {
+ callCallback(SUCCESS);
+ },
+ });
+ }
+
+ AddonManager.installAddonFromWebpage(
+ mimetype,
+ aBrowser,
+ triggeringPrincipal,
+ aInstall,
+ {
+ hasCrossOriginAncestor: aPayload.hasCrossOriginAncestor,
+ }
+ );
+ });
+
+ return retval;
+ },
+
+ notify(aTimer) {
+ AddonManagerPrivate.backgroundUpdateTimerHandler();
+ },
+
+ // Maps message manager instances for content processes to the associated
+ // AddonListener instances.
+ addonListeners: new Map(),
+
+ _addAddonListener(target) {
+ if (!this.addonListeners.has(target)) {
+ let handler = (event, id) => {
+ target.sendAsyncMessage(MSG_ADDON_EVENT, { event, id });
+ };
+ let listener = {
+ onEnabling: addon => handler("onEnabling", addon.id),
+ onEnabled: addon => handler("onEnabled", addon.id),
+ onDisabling: addon => handler("onDisabling", addon.id),
+ onDisabled: addon => handler("onDisabled", addon.id),
+ onInstalling: addon => handler("onInstalling", addon.id),
+ onInstalled: addon => handler("onInstalled", addon.id),
+ onUninstalling: addon => handler("onUninstalling", addon.id),
+ onUninstalled: addon => handler("onUninstalled", addon.id),
+ onOperationCancelled: addon =>
+ handler("onOperationCancelled", addon.id),
+ };
+ this.addonListeners.set(target, listener);
+ AddonManager.addAddonListener(listener);
+ }
+ },
+
+ _removeAddonListener(target) {
+ if (this.addonListeners.has(target)) {
+ AddonManager.removeAddonListener(this.addonListeners.get(target));
+ this.addonListeners.delete(target);
+ }
+ },
+
+ /**
+ * messageManager callback function.
+ *
+ * Listens to requests from child processes for InstallTrigger
+ * activity, and sends back callbacks.
+ */
+ receiveMessage(aMessage) {
+ let payload = aMessage.data;
+
+ switch (aMessage.name) {
+ case MSG_INSTALL_ENABLED:
+ return AddonManager.isInstallEnabled(payload.mimetype);
+
+ case MSG_INSTALL_ADDON: {
+ let browser = payload.browsingContext.top.embedderElement;
+
+ let callback = null;
+ if (payload.callbackID != -1) {
+ let mm = browser.messageManager;
+ callback = {
+ onInstallEnded(url, status) {
+ mm.sendAsyncMessage(MSG_INSTALL_CALLBACK, {
+ callbackID: payload.callbackID,
+ url,
+ status,
+ });
+ },
+ };
+ }
+
+ return this.installAddonFromWebpage(payload, browser, callback);
+ }
+
+ case MSG_PROMISE_REQUEST: {
+ if (
+ !lazy.extensionsWebAPITesting &&
+ lazy.separatePrivilegedMozillaWebContentProcess &&
+ aMessage.target &&
+ aMessage.target.remoteType != null &&
+ aMessage.target.remoteType !== "privilegedmozilla"
+ ) {
+ return undefined;
+ }
+
+ let mm = aMessage.target.messageManager;
+ let resolve = value => {
+ mm.sendAsyncMessage(MSG_PROMISE_RESULT, {
+ callbackID: payload.callbackID,
+ resolve: value,
+ });
+ };
+ let reject = value => {
+ mm.sendAsyncMessage(MSG_PROMISE_RESULT, {
+ callbackID: payload.callbackID,
+ reject: value,
+ });
+ };
+
+ let API = AddonManager.webAPI;
+ if (payload.type in API) {
+ API[payload.type](aMessage.target, ...payload.args).then(
+ resolve,
+ reject
+ );
+ } else {
+ reject("Unknown Add-on API request.");
+ }
+ break;
+ }
+
+ case MSG_INSTALL_CLEANUP: {
+ if (
+ !lazy.extensionsWebAPITesting &&
+ lazy.separatePrivilegedMozillaWebContentProcess &&
+ aMessage.target &&
+ aMessage.target.remoteType != null &&
+ aMessage.target.remoteType !== "privilegedmozilla"
+ ) {
+ return undefined;
+ }
+
+ AddonManager.webAPI.clearInstalls(payload.ids);
+ break;
+ }
+
+ case MSG_ADDON_EVENT_REQ: {
+ if (
+ !lazy.extensionsWebAPITesting &&
+ lazy.separatePrivilegedMozillaWebContentProcess &&
+ aMessage.target &&
+ aMessage.target.remoteType != null &&
+ aMessage.target.remoteType !== "privilegedmozilla"
+ ) {
+ return undefined;
+ }
+
+ let target = aMessage.target.messageManager;
+ if (payload.enabled) {
+ this._addAddonListener(target);
+ } else {
+ this._removeAddonListener(target);
+ }
+ }
+ }
+ return undefined;
+ },
+
+ childClosed(target) {
+ AddonManager.webAPI.clearInstallsFrom(target);
+ this._removeAddonListener(target);
+ },
+
+ sendEvent(mm, data) {
+ mm.sendAsyncMessage(MSG_INSTALL_EVENT, data);
+ },
+
+ classID: Components.ID("{4399533d-08d1-458c-a87a-235f74451cfa}"),
+ QueryInterface: ChromeUtils.generateQI([
+ "amIAddonManager",
+ "nsITimerCallback",
+ "nsIObserver",
+ ]),
+};
+
+const BLOCKLIST_JSM = "resource://gre/modules/Blocklist.jsm";
+ChromeUtils.defineModuleGetter(lazy, "Blocklist", BLOCKLIST_JSM);
+
+export function BlocklistService() {
+ this.wrappedJSObject = this;
+}
+
+BlocklistService.prototype = {
+ STATE_NOT_BLOCKED: Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ STATE_SOFTBLOCKED: Ci.nsIBlocklistService.STATE_SOFTBLOCKED,
+ STATE_BLOCKED: Ci.nsIBlocklistService.STATE_BLOCKED,
+
+ get isLoaded() {
+ return Cu.isModuleLoaded(BLOCKLIST_JSM) && lazy.Blocklist.isLoaded;
+ },
+
+ observe(...args) {
+ return lazy.Blocklist.observe(...args);
+ },
+
+ notify() {
+ lazy.Blocklist.notify();
+ },
+
+ classID: Components.ID("{66354bc9-7ed1-4692-ae1d-8da97d6b205e}"),
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsIBlocklistService",
+ "nsITimerCallback",
+ ]),
+};
diff --git a/toolkit/mozapps/extensions/amWebAPI.sys.mjs b/toolkit/mozapps/extensions/amWebAPI.sys.mjs
new file mode 100644
index 0000000000..abe838af89
--- /dev/null
+++ b/toolkit/mozapps/extensions/amWebAPI.sys.mjs
@@ -0,0 +1,289 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "AMO_ABUSEREPORT",
+ "extensions.abuseReport.amWebAPI.enabled",
+ false
+);
+
+const MSG_PROMISE_REQUEST = "WebAPIPromiseRequest";
+const MSG_PROMISE_RESULT = "WebAPIPromiseResult";
+const MSG_INSTALL_EVENT = "WebAPIInstallEvent";
+const MSG_INSTALL_CLEANUP = "WebAPICleanup";
+const MSG_ADDON_EVENT_REQ = "WebAPIAddonEventRequest";
+const MSG_ADDON_EVENT = "WebAPIAddonEvent";
+
+class APIBroker {
+ constructor(mm) {
+ this.mm = mm;
+
+ this._promises = new Map();
+
+ // _installMap maps integer ids to DOM AddonInstall instances
+ this._installMap = new Map();
+
+ this.mm.addMessageListener(MSG_PROMISE_RESULT, this);
+ this.mm.addMessageListener(MSG_INSTALL_EVENT, this);
+
+ this._eventListener = null;
+ }
+
+ receiveMessage(message) {
+ let payload = message.data;
+
+ switch (message.name) {
+ case MSG_PROMISE_RESULT: {
+ if (!this._promises.has(payload.callbackID)) {
+ return;
+ }
+
+ let resolve = this._promises.get(payload.callbackID);
+ this._promises.delete(payload.callbackID);
+ resolve(payload);
+ break;
+ }
+
+ case MSG_INSTALL_EVENT: {
+ let install = this._installMap.get(payload.id);
+ if (!install) {
+ let err = new Error(
+ `Got install event for unknown install ${payload.id}`
+ );
+ Cu.reportError(err);
+ return;
+ }
+ install._dispatch(payload);
+ break;
+ }
+
+ case MSG_ADDON_EVENT: {
+ if (this._eventListener) {
+ this._eventListener(payload);
+ }
+ }
+ }
+ }
+
+ sendRequest(type, ...args) {
+ return new Promise(resolve => {
+ let callbackID = APIBroker._nextID++;
+
+ this._promises.set(callbackID, resolve);
+ this.mm.sendAsyncMessage(MSG_PROMISE_REQUEST, { type, callbackID, args });
+ });
+ }
+
+ setAddonListener(callback) {
+ this._eventListener = callback;
+ if (callback) {
+ this.mm.addMessageListener(MSG_ADDON_EVENT, this);
+ this.mm.sendAsyncMessage(MSG_ADDON_EVENT_REQ, { enabled: true });
+ } else {
+ this.mm.removeMessageListener(MSG_ADDON_EVENT, this);
+ this.mm.sendAsyncMessage(MSG_ADDON_EVENT_REQ, { enabled: false });
+ }
+ }
+
+ sendCleanup(ids) {
+ this.setAddonListener(null);
+ this.mm.sendAsyncMessage(MSG_INSTALL_CLEANUP, { ids });
+ }
+}
+
+APIBroker._nextID = 0;
+
+// Base class for building classes to back content-exposed interfaces.
+class APIObject {
+ init(window, broker, properties) {
+ this.window = window;
+ this.broker = broker;
+
+ // Copy any provided properties onto this object, webidl bindings
+ // will only expose to content what should be exposed.
+ for (let key of Object.keys(properties)) {
+ this[key] = properties[key];
+ }
+ }
+
+ /**
+ * Helper to implement an asychronous method visible to content, where
+ * the method is implemented by sending a message to the parent process
+ * and then wrapping the returned object or error in an appropriate object.
+ * This helper method ensures that:
+ * - Returned Promise objects are from the content window
+ * - Rejected Promises have Error objects from the content window
+ * - Only non-internal errors are exposed to the caller
+ *
+ * @param {string} apiRequest The command to invoke in the parent process.
+ * @param {array<cloneable>} apiArgs The arguments to include with the
+ * request to the parent process.
+ * @param {function} resultConvert If provided, a function called with the
+ * result from the parent process as an
+ * argument. Used to convert the result
+ * into something appropriate for content.
+ * @returns {Promise<any>} A Promise suitable for passing directly to content.
+ */
+ _apiTask(apiRequest, apiArgs, resultConverter) {
+ let win = this.window;
+ let broker = this.broker;
+ return new win.Promise((resolve, reject) => {
+ (async function () {
+ let result = await broker.sendRequest(apiRequest, ...apiArgs);
+ if ("reject" in result) {
+ let err = new win.Error(result.reject.message);
+ // We don't currently put any other properties onto Errors
+ // generated by mozAddonManager. If/when we do, they will
+ // need to get copied here.
+ reject(err);
+ return;
+ }
+
+ let obj = result.resolve;
+ if (resultConverter) {
+ obj = resultConverter(obj);
+ }
+ resolve(obj);
+ })().catch(err => {
+ Cu.reportError(err);
+ reject(new win.Error("Unexpected internal error"));
+ });
+ });
+ }
+}
+
+class Addon extends APIObject {
+ constructor(...args) {
+ super();
+ this.init(...args);
+ }
+
+ uninstall() {
+ return this._apiTask("addonUninstall", [this.id]);
+ }
+
+ setEnabled(value) {
+ return this._apiTask("addonSetEnabled", [this.id, value]);
+ }
+}
+
+class AddonInstall extends APIObject {
+ constructor(window, broker, properties) {
+ super();
+ this.init(window, broker, properties);
+
+ broker._installMap.set(properties.id, this);
+ }
+
+ _dispatch(data) {
+ // The message for the event includes updated copies of all install
+ // properties. Use the usual "let webidl filter visible properties" trick.
+ for (let key of Object.keys(data)) {
+ this[key] = data[key];
+ }
+
+ let event = new this.window.Event(data.event);
+ this.__DOM_IMPL__.dispatchEvent(event);
+ }
+
+ install() {
+ return this._apiTask("addonInstallDoInstall", [this.id]);
+ }
+
+ cancel() {
+ return this._apiTask("addonInstallCancel", [this.id]);
+ }
+}
+
+export class WebAPI extends APIObject {
+ constructor() {
+ super();
+ this.allInstalls = [];
+ this.listenerCount = 0;
+ }
+
+ init(window) {
+ let mm = window.docShell.messageManager;
+ let broker = new APIBroker(mm);
+
+ super.init(window, broker, {});
+
+ window.addEventListener("unload", event => {
+ this.broker.sendCleanup(this.allInstalls);
+ });
+ }
+
+ getAddonByID(id) {
+ return this._apiTask("getAddonByID", [id], addonInfo => {
+ if (!addonInfo) {
+ return null;
+ }
+ let addon = new Addon(this.window, this.broker, addonInfo);
+ return this.window.Addon._create(this.window, addon);
+ });
+ }
+
+ createInstall(options) {
+ if (!Services.prefs.getBoolPref("xpinstall.enabled", true)) {
+ throw new this.window.Error("Software installation is disabled.");
+ }
+
+ const triggeringPrincipal = this.window.document.nodePrincipal;
+
+ let installOptions = {
+ ...options,
+ triggeringPrincipal,
+ // Provide the host from which the amWebAPI is being called
+ // (so that we can detect if the API is being used from the disco pane,
+ // AMO, testpilot or another unknown webpage).
+ sourceHost: this.window.location?.host,
+ sourceURL: this.window.location?.href,
+ };
+ return this._apiTask("createInstall", [installOptions], installInfo => {
+ if (!installInfo) {
+ return null;
+ }
+ let install = new AddonInstall(this.window, this.broker, installInfo);
+ this.allInstalls.push(installInfo.id);
+ return this.window.AddonInstall._create(this.window, install);
+ });
+ }
+
+ reportAbuse(id) {
+ return this._apiTask("addonReportAbuse", [id]);
+ }
+
+ get abuseReportPanelEnabled() {
+ return lazy.AMO_ABUSEREPORT;
+ }
+
+ eventListenerAdded(type) {
+ if (this.listenerCount == 0) {
+ this.broker.setAddonListener(data => {
+ let event = new this.window.AddonEvent(data.event, data);
+ this.__DOM_IMPL__.dispatchEvent(event);
+ });
+ }
+ this.listenerCount++;
+ }
+
+ eventListenerRemoved(type) {
+ this.listenerCount--;
+ if (this.listenerCount == 0) {
+ this.broker.setAddonListener(null);
+ }
+ }
+}
+
+WebAPI.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIDOMGlobalPropertyInitializer",
+]);
+WebAPI.prototype.classID = Components.ID(
+ "{8866d8e3-4ea5-48b7-a891-13ba0ac15235}"
+);
diff --git a/toolkit/mozapps/extensions/components.conf b/toolkit/mozapps/extensions/components.conf
new file mode 100644
index 0000000000..c38b5e1100
--- /dev/null
+++ b/toolkit/mozapps/extensions/components.conf
@@ -0,0 +1,45 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ 'js_name': 'blocklist',
+ 'cid': '{66354bc9-7ed1-4692-ae1d-8da97d6b205e}',
+ 'contract_ids': ['@mozilla.org/extensions/blocklist;1'],
+ 'esModule': 'resource://gre/modules/amManager.sys.mjs',
+ 'constructor': 'BlocklistService',
+ 'interfaces': ['nsIBlocklistService'],
+ 'processes': ProcessSelector.MAIN_PROCESS_ONLY,
+ },
+ {
+ 'cid': '{4399533d-08d1-458c-a87a-235f74451cfa}',
+ 'contract_ids': ['@mozilla.org/addons/integration;1'],
+ 'esModule': 'resource://gre/modules/amManager.sys.mjs',
+ 'constructor': 'amManager',
+ },
+ {
+ 'cid': '{9df8ef2b-94da-45c9-ab9f-132eb55fddf1}',
+ 'contract_ids': ['@mozilla.org/addons/installtrigger;1'],
+ 'esModule': 'resource://gre/modules/amInstallTrigger.sys.mjs',
+ 'constructor': 'InstallTrigger',
+ },
+]
+
+if buildconfig.substs['MOZ_WIDGET_TOOLKIT'] != 'android':
+ Classes += [
+ {
+ 'cid': '{7beb3ba8-6ec3-41b4-b67c-da89b8518922}',
+ 'contract_ids': ['@mozilla.org/uriloader/content-handler;1?type=application/x-xpinstall'],
+ 'esModule': 'resource://gre/modules/amContentHandler.sys.mjs',
+ 'constructor': 'amContentHandler',
+ },
+ {
+ 'cid': '{8866d8e3-4ea5-48b7-a891-13ba0ac15235}',
+ 'contract_ids': ['@mozilla.org/addon-web-api/manager;1'],
+ 'esModule': 'resource://gre/modules/amWebAPI.sys.mjs',
+ 'constructor': 'WebAPI',
+ },
+ ]
diff --git a/toolkit/mozapps/extensions/content/OpenH264-license.txt b/toolkit/mozapps/extensions/content/OpenH264-license.txt
new file mode 100644
index 0000000000..ad37989b8c
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/OpenH264-license.txt
@@ -0,0 +1,59 @@
+-------------------------------------------------------
+About The Cisco-Provided Binary of OpenH264 Video Codec
+-------------------------------------------------------
+
+Cisco provides this program under the terms of the BSD license.
+
+Additionally, this binary is licensed under Cisco’s AVC/H.264 Patent Portfolio License from MPEG LA, at no cost to you, provided that the requirements and conditions shown below in the AVC/H.264 Patent Portfolio sections are met.
+
+As with all AVC/H.264 codecs, you may also obtain your own patent license from MPEG LA or from the individual patent owners, or proceed at your own risk. Your rights from Cisco under the BSD license are not affected by this choice.
+
+For more information on the OpenH264 binary licensing, please see the OpenH264 FAQ found at http://www.openh264.org/faq.html#binary
+
+A corresponding source code to this binary program is available under the same BSD terms, which can be found at http://www.openh264.org
+
+-----------
+BSD License
+-----------
+
+Copyright © 2014 Cisco Systems, Inc.
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+-----------------------------------------
+AVC/H.264 Patent Portfolio License Notice
+-----------------------------------------
+
+The binary form of this Software is distributed by Cisco under the AVC/H.264 Patent Portfolio License from MPEG LA, and is subject to the following requirements, which may or may not be applicable to your use of this software:
+
+THIS PRODUCT IS LICENSED UNDER THE AVC PATENT PORTFOLIO LICENSE FOR THE PERSONAL USE OF A CONSUMER OR OTHER USES IN WHICH IT DOES NOT RECEIVE REMUNERATION TO (i) ENCODE VIDEO IN COMPLIANCE WITH THE AVC STANDARD (“AVC VIDEO”) AND/OR (ii) DECODE AVC VIDEO THAT WAS ENCODED BY A CONSUMER ENGAGED IN A PERSONAL ACTIVITY AND/OR WAS OBTAINED FROM A VIDEO PROVIDER LICENSED TO PROVIDE AVC VIDEO. NO LICENSE IS GRANTED OR SHALL BE IMPLIED FOR ANY OTHER USE. ADDITIONAL INFORMATION MAY BE OBTAINED FROM MPEG LA, L.L.C. SEE HTTP://WWW.MPEGLA.COM
+
+Accordingly, please be advised that content providers and broadcasters using AVC/H.264 in their service may be required to obtain a separate use license from MPEG LA, referred to as "(b) sublicenses" in the SUMMARY OF AVC/H.264 LICENSE TERMS from MPEG LA found at http://www.openh264.org/mpegla
+
+---------------------------------------------
+AVC/H.264 Patent Portfolio License Conditions
+---------------------------------------------
+
+In addition, the Cisco-provided binary of this Software is licensed under Cisco's license from MPEG LA only if the following conditions are met:
+
+1. The Cisco-provided binary is separately downloaded to an end user’s device, and not integrated into or combined with third party software prior to being downloaded to the end user’s device;
+
+2. The end user must have the ability to control (e.g., to enable, disable, or re-enable) the use of the Cisco-provided binary;
+
+3. Third party software, in the location where end users can control the use of the Cisco-provided binary, must display the following text:
+
+ "OpenH264 Video Codec provided by Cisco Systems, Inc."
+
+4. Any third-party software that makes use of the Cisco-provided binary must reproduce all of the above text, as well as this last condition, in the EULA and/or in another location where licensing information is to be presented to the end user.
+
+
+
+ v1.0
diff --git a/toolkit/mozapps/extensions/content/aboutaddons.css b/toolkit/mozapps/extensions/content/aboutaddons.css
new file mode 100644
index 0000000000..86766610d8
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/aboutaddons.css
@@ -0,0 +1,768 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:root {
+ --addon-icon-size: 32px;
+ --card-border-zap-gradient: linear-gradient(90deg, #9059FF 0%, #FF4AA2 52.08%, #FFBD4F 100%);
+ --main-margin-start: 28px;
+ --section-width: 664px;
+ --sidebar-width: var(--in-content-sidebar-width);
+ --z-index-sticky-container: 5;
+ --z-index-popup: 10;
+}
+
+@media (max-width: 830px) {
+ :root {
+ --main-margin-start: 16px;
+ /* Maintain a main margin so card shadows don't overlap the sidebar. */
+ --sidebar-width: calc(var(--in-content-sidebar-width) - var(--main-margin-start));
+ }
+}
+
+*|*[hidden] {
+ display: none !important;
+}
+
+body {
+ cursor: default;
+ /* The page starts to look really bad lower than this. */
+ min-width: 500px;
+}
+
+#full {
+ display: grid;
+ grid-template-columns: var(--sidebar-width) 1fr;
+}
+
+#sidebar {
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ margin: 0;
+ overflow: hidden auto;
+}
+
+@media (prefers-reduced-motion) {
+ /* Setting border-inline-end on #sidebar makes it a focusable element */
+ #sidebar::after {
+ content: "";
+ width: 1px;
+ height: 100%;
+ background-color: var(--in-content-border-color);
+ top: 0;
+ inset-inline-end: 0;
+ position: absolute;
+ }
+}
+
+#categories {
+ display: flex;
+ flex-direction: column;
+ padding-inline-end: 4px; /* Leave space for the button focus styles. */
+}
+
+.category {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ margin-block: 0;
+ align-items: center;
+ font-weight: normal;
+}
+
+.category[badge-count]::after {
+ display: inline-block;
+ min-width: 20px;
+ background-color: var(--in-content-accent-color);
+ color: var(--in-content-primary-button-text-color);
+ font-weight: bold;
+ /* Use a large border-radius to get semi-circles on the sides. */
+ border-radius: 1000px;
+ padding: 2px 6px;
+ content: attr(badge-count);
+ text-align: center;
+ margin-inline-start: 8px;
+ grid-column: 2;
+}
+
+.category[name="discover"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-discover.svg");
+}
+.category[name="locale"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-languages.svg");
+}
+.category[name="extension"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-extensions.svg");
+}
+.category[name="theme"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-themes.svg");
+}
+.category[name="plugin"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-plugins.svg");
+}
+.category[name="dictionary"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-dictionaries.svg");
+}
+.category[name="available-updates"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-available.svg");
+}
+.category[name="recent-updates"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-recent.svg");
+}
+.category[name="sitepermission"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-sitepermission.svg");
+}
+
+.sticky-container {
+ background: var(--in-content-page-background);
+ width: 100%;
+ position: sticky;
+ top: 0;
+ z-index: var(--z-index-sticky-container);
+}
+
+.main-search {
+ background: var(--in-content-page-background);
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ padding-inline-start: 28px;
+ padding-top: 20px;
+ padding-bottom: 30px;
+ max-width: var(--section-width);
+}
+
+search-addons > search-textbox {
+ margin: 0;
+ width: 20em;
+ min-height: 32px;
+}
+
+.search-label {
+ margin-inline-end: 8px;
+}
+
+.main-heading {
+ background: var(--in-content-page-background);
+ display: flex;
+ margin-inline-start: var(--main-margin-start);
+ padding-bottom: 16px;
+ max-width: var(--section-width);
+}
+
+.spacer {
+ flex-grow: 1;
+}
+
+#updates-message {
+ display: flex;
+ align-items: center;
+ margin-inline-end: 8px;
+}
+
+.back-button {
+ margin-inline-end: 16px;
+}
+
+/* Plugins aren't yet disabled by safemode (bug 342333),
+ so don't show that warning when viewing plugins. */
+#page-header[current-param="plugin"] message-bar[warning-type="safe-mode"] {
+ display: none;
+}
+
+#main {
+ margin-inline-start: var(--main-margin-start);
+ margin-bottom: 28px;
+ max-width: var(--section-width);
+}
+
+global-warnings,
+#abuse-reports-messages {
+ margin-inline-start: var(--main-margin-start);
+ max-width: var(--section-width);
+}
+
+/* The margin between message bars. */
+message-bar-stack > * {
+ margin-bottom: 8px;
+}
+
+/* List sections */
+
+.list-section-heading {
+ font-size: 17px;
+ font-weight: 600;
+ margin-bottom: 16px;
+}
+
+.list-section-subheading {
+ font-size: 0.9em;
+ font-weight: 400;
+ margin-block-start: 0.5em;
+}
+
+.section {
+ margin-bottom: 32px;
+}
+
+/* Add-on cards */
+
+.addon.card {
+ margin-bottom: 16px;
+ transition: opacity 150ms, box-shadow 150ms;
+}
+
+addon-list:not([type="theme"]) addon-card:not([expanded]):not([panelopen]) > .addon.card[active="false"]:not(:focus-within):not(:hover) {
+ opacity: 0.6;
+}
+
+.addon.card:hover {
+ box-shadow: var(--card-shadow);
+}
+
+addon-card:not([expanded]) > .addon.card:hover {
+ box-shadow: var(--card-shadow-hover);
+ cursor: pointer;
+}
+
+addon-card[expanded] .addon.card {
+ padding-bottom: 0;
+}
+
+.addon-card-collapsed {
+ display: flex;
+}
+
+addon-list addon-card > .addon.card {
+ user-select: none;
+}
+
+.addon-card-message,
+.update-postponed-bar {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ margin: 8px calc(var(--card-padding) * -1) calc(var(--card-padding) * -1);
+}
+
+addon-card[expanded] .addon-card-message,
+addon-card[expanded] .update-postponed-bar {
+ border-radius: 0;
+ margin-bottom: 0;
+}
+
+addon-card[expanded] .update-postponed-bar + .addon-card-message {
+ /* Remove margin between the two message bars when they are both
+ * visible in the detail view */
+ margin-top: 0px;
+}
+
+.update-postponed-bar + .addon-card-message {
+ /* Prevent the small overlapping between the two message bars
+ * when they are both visible at the same time one after the
+ * other on the same addon card */
+ margin-top: 12px;
+}
+
+/* Theme preview image. */
+.card-heading-image {
+ /* If the width, height or aspect ratio changes, don't forget to update the
+ * getScreenshotUrlForAddon function in aboutaddons.js */
+ width: var(--section-width);
+ /* Adjust height so that the image preserves the aspect ratio from AMO.
+ * For details, see https://bugzilla.mozilla.org/show_bug.cgi?id=1546123 */
+ height: calc(var(--section-width) * 92 / 680);
+ object-fit: cover;
+}
+
+.card-heading-icon {
+ flex-shrink: 0;
+ width: var(--addon-icon-size);
+ height: var(--addon-icon-size);
+ margin-inline-end: 16px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.card-contents {
+ word-break: break-word;
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+.addon-name-container {
+ /* Subtract the top line-height so the text and icon align at the top. */
+ margin-top: -3px;
+ display: flex;
+ align-items: center;
+}
+
+.addon-name {
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 22px;
+ margin: 0;
+ margin-inline-end: 8px;
+}
+
+.addon-name-link,
+.addon-name-link:hover {
+ color: var(--in-content-text-color);
+ text-decoration: none;
+}
+
+.addon-name-link:-moz-focusring {
+ /* Since the parent is overflow:hidden to ellipsize the regular outline is hidden. */
+ outline-offset: -1px;
+ outline-width: 1px;
+}
+
+.addon-badge {
+ display: inline-block;
+ margin-inline-end: 8px;
+ width: 22px;
+ height: 22px;
+ background-repeat: no-repeat;
+ background-position: center;
+ flex-shrink: 0;
+ border-radius: 11px;
+ -moz-context-properties: fill;
+ fill: #fff;
+}
+
+.addon-badge-private-browsing-allowed {
+ background-image: url("chrome://global/skin/icons/indicator-private-browsing.svg");
+}
+
+.addon-badge-recommended {
+ background-color: var(--orange-50);
+ background-image: url("chrome://mozapps/skin/extensions/recommended.svg");
+}
+
+.addon-badge-line {
+ background-color: #fff;
+ background-image: url("chrome://mozapps/skin/extensions/line.svg");
+ background-size: 16px;
+ border-radius: 10px;
+ border: 1px solid #CFCFD8;
+ width: 20px;
+ height: 20px;
+}
+
+.addon-badge-verified {
+ background-color: var(--green-70);
+ background-image: url("chrome://global/skin/icons/check.svg");
+}
+
+.theme-enable-button {
+ min-width: auto;
+ font-size: 13px;
+ min-height: auto;
+ height: 24px;
+ margin: 0;
+ padding: 0 8px;
+ font-weight: normal;
+}
+
+.addon-description {
+ font-size: 14px;
+ line-height: 20px;
+ color: var(--text-color-deemphasized);
+ font-weight: 400;
+}
+
+/* Prevent the content from wrapping unless expanded. */
+addon-card:not([expanded]) .card-contents {
+ /* We're hiding the content when it's too long, so we need to define the
+ * width. As long as this is less than the width of its parent it works. */
+ width: 1px;
+ white-space: nowrap;
+}
+
+/* Ellipsize if the content is too long. */
+addon-card:not([expanded]) .addon-name,
+addon-card:not([expanded]) .addon-description {
+ text-overflow: ellipsis;
+ overflow-x: hidden;
+}
+
+.page-options-menu {
+ align-self: center;
+}
+
+.page-options-menu > .more-options-button {
+ background-image: url("chrome://global/skin/icons/settings.svg");
+ width: 32px;
+ height: 32px;
+}
+
+/* Recommended add-ons on list views */
+.recommended-heading {
+ margin-bottom: 24px;
+ margin-top: 48px;
+}
+
+/* Discopane extensions to the add-on card */
+
+recommended-addon-card .addon-description:not(:empty) {
+ margin-top: 0.5em;
+}
+
+.disco-card-head {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+.disco-addon-name {
+ font-size: inherit;
+ font-weight: normal;
+ line-height: normal;
+ margin: 0;
+}
+
+.disco-addon-author {
+ font-size: 12px;
+ font-weight: normal;
+}
+
+.disco-description-statistics {
+ margin-top: 1em;
+ display: grid;
+ grid-template-columns: repeat(2, max-content);
+ grid-column-gap: 2em;
+ align-items: center;
+}
+
+.disco-cta-button {
+ font-size: 14px;
+ flex-shrink: 0;
+ flex-grow: 0;
+ align-self: baseline;
+ margin-inline-end: 0;
+}
+
+.discopane-notice {
+ margin: 24px 0;
+}
+
+.discopane-notice-content {
+ padding-block: 6px;
+}
+
+.discopane-notice-content > span {
+ flex-grow: 1;
+ margin-inline-end: 4px;
+}
+
+.discopane-notice-content > button {
+ flex-grow: 0;
+ flex-shrink: 0;
+}
+
+.view-footer {
+ text-align: center;
+}
+
+.view-footer-item {
+ margin-top: 30px;
+}
+
+.privacy-policy-link {
+ font-size: small;
+}
+
+.theme-recommendation {
+ text-align: start;
+}
+
+addon-details {
+ color: var(--text-color-deemphasized);
+}
+
+.addon-detail-description-wrapper {
+ margin: 16px 0;
+}
+
+.addon-detail-description-collapse .addon-detail-description {
+ max-height: 20rem;
+ overflow: hidden;
+}
+
+/* Include button to beat out .button-link which is below this */
+button.addon-detail-description-toggle {
+ display: flex;
+ align-items: center;
+ margin-top: 8px;
+ font-weight: normal;
+ gap: 4px;
+}
+
+.addon-detail-description-toggle::after {
+ content: "";
+ display: block;
+ background-image: url("chrome://global/skin/icons/arrow-up-12.svg");
+ background-repeat: no-repeat;
+ background-position: center;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: 12px;
+ height: 12px;
+}
+
+.addon-detail-description-collapse .addon-detail-description-toggle::after {
+ transform: scaleY(-1);
+}
+
+.addon-detail-contribute {
+ display: flex;
+ padding: var(--card-padding);
+ border: 1px solid var(--in-content-box-border-color);
+ border-radius: 4px;
+ margin-bottom: var(--card-padding);
+ flex-direction: column;
+}
+
+.addon-detail-contribute > label {
+ font-style: italic;
+}
+
+.addon-detail-contribute-button {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ background-image: url("chrome://global/skin/icons/heart.svg");
+ background-repeat: no-repeat;
+ background-position: 8px;
+ padding-inline-start: 28px;
+ margin-top: var(--card-padding);
+ margin-bottom: 0;
+ align-self: flex-end;
+}
+
+.addon-detail-contribute-button:dir(rtl) {
+ background-position-x: right 8px;
+}
+
+.addon-detail-sitepermissions,
+.addon-detail-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-top: 1px solid var(--in-content-border-color);
+ margin: 0 calc(var(--card-padding) * -1);
+ padding: var(--card-padding);
+ color: var(--in-content-text-color);
+}
+
+.addon-detail-row.addon-detail-help-row {
+ display: block;
+ color: var(--text-color-deemphasized);
+ padding-top: 4px;
+ padding-bottom: var(--card-padding);
+ border: none;
+}
+
+.addon-detail-row-has-help {
+ padding-bottom: 0;
+}
+
+.addon-detail-row input[type="checkbox"] {
+ margin: 0;
+}
+
+.addon-detail-actions,
+.addon-detail-rating {
+ display: flex;
+}
+
+.addon-detail-actions {
+ gap: 20px;
+}
+
+.addon-detail-actions > label {
+ flex-wrap: wrap;
+}
+
+.addon-detail-rating > a {
+ margin-inline-start: 8px;
+}
+
+.more-options-button {
+ min-width: auto;
+ min-height: auto;
+ width: 24px;
+ height: 24px;
+ margin: 0;
+ margin-inline-start: 8px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ background-image: url("chrome://global/skin/icons/more.svg");
+ background-repeat: no-repeat;
+ background-position: center center;
+ /* Get the -badged ::after element in the right spot. */
+ padding: 1px;
+ display: flex;
+ justify-content: flex-end;
+}
+
+.more-options-button-badged::after {
+ content: "";
+ display: block;
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background-color: var(--in-content-accent-color);;
+}
+
+panel-item[action="remove"]::part(button) {
+ background-image: url("chrome://global/skin/icons/delete.svg");
+}
+
+panel-item[action="install-update"]::part(button) {
+ background-image: url("chrome://global/skin/icons/update-icon.svg");
+}
+
+panel-item[action="report"]::part(button) {
+ background-image: url(chrome://global/skin/icons/warning.svg);
+}
+
+.hide-amo-link .amo-link-container {
+ display: none;
+}
+
+.button-link {
+ min-height: auto;
+ background: none !important;
+ padding: 0;
+ margin: 0;
+ color: var(--in-content-link-color) !important;
+ cursor: pointer;
+ border: none;
+}
+
+.button-link:hover {
+ color: var(--in-content-link-color-hover) !important;
+ text-decoration: underline;
+}
+
+.button-link:active {
+ color: var(--in-content-link-color-active) !important;
+ text-decoration: none;
+}
+
+.inline-options-stack {
+ /* If the options browser triggers an alert we need room to show it. */
+ min-height: 250px;
+ width: 100%;
+ background-color: white;
+ margin-block: 4px;
+}
+
+addon-permissions-list > .addon-detail-row {
+ border-top: none;
+}
+
+.addon-permissions-list {
+ list-style-type: none;
+ margin: 0;
+ padding-inline-start: 8px;
+}
+
+.addon-permissions-list > li {
+ border: none;
+ padding-block: 4px;
+ padding-inline-start: 2rem;
+ background-image: none;
+ background-position: 0 center;
+ background-size: 1.6rem 1.6rem;
+ background-repeat: no-repeat;
+}
+
+.addon-permissions-list > li:dir(rtl) {
+ background-position-x: right 0;
+}
+
+/* using a list-style-image prevents aligning the image */
+.addon-permissions-list > li.permission-checked {
+ background-image: url("chrome://global/skin/icons/check.svg");
+ -moz-context-properties: fill;
+ fill: var(--green-60);
+}
+
+.permission-header {
+ font-size: 1em;
+}
+
+.tab-group {
+ display: block;
+ margin-top: 8px;
+ /* Pull the buttons flush with the side of the card */
+ margin-inline: calc(var(--card-padding) * -1);
+ border-bottom: 1px solid var(--in-content-border-color);
+ border-top: 1px solid var(--in-content-border-color);
+ font-size: 0;
+ line-height: 0;
+}
+
+button.tab-button {
+ appearance: none;
+ border-inline: none;
+ border-block: 2px solid transparent;
+ border-radius: 0;
+ background: transparent;
+ font-size: 14px;
+ line-height: 20px;
+ margin: 0;
+ padding: 4px 16px;
+}
+
+button.tab-button:hover {
+ border-top-color: var(--in-content-box-border-color);
+}
+
+button.tab-button[selected],
+button.tab-button[selected]:hover {
+ border-top-color: currentColor;
+ color: var(--in-content-accent-color);
+}
+
+@media (prefers-contrast) {
+ button.tab-button[selected],
+ button.tab-button[selected]:hover {
+ color: var(--in-content-primary-button-text-color);
+ background-color: var(--in-content-primary-button-background);
+ }
+}
+
+button.tab-button:-moz-focusring {
+ outline-offset: -2px;
+}
+
+.tab-group[last-input-type="mouse"] > button.tab-button:-moz-focusring {
+ outline: none;
+ box-shadow: none;
+}
+
+section:not(:empty) ~ #empty-addons-message {
+ display: none;
+}
+
+@media (max-width: 830px) {
+ .category[badge-count]::after {
+ content: "";
+ display: block;
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ min-width: auto;
+ padding: 0;
+ /* move the badged dot into the top-end (right in ltr, left in rtl) corner. */
+ margin-top: -20px;
+ }
+}
+
+.permission-header > .addon-sitepermissions-host {
+ font-weight: bolder;
+}
diff --git a/toolkit/mozapps/extensions/content/aboutaddons.html b/toolkit/mozapps/extensions/content/aboutaddons.html
new file mode 100644
index 0000000000..c377aac135
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/aboutaddons.html
@@ -0,0 +1,780 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ - You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html>
+ <head>
+ <title data-l10n-id="addons-page-title"></title>
+
+ <!-- Bug 1571346 Remove 'unsafe-inline' from style-src within about:addons -->
+ <meta
+ http-equiv="Content-Security-Policy"
+ content="default-src chrome:; style-src chrome: 'unsafe-inline'; img-src chrome: file: jar: https: http:; connect-src chrome: data: https: http:; object-src 'none'"
+ />
+ <meta name="color-scheme" content="light dark" />
+ <link rel="stylesheet" href="chrome://global/content/tabprompts.css" />
+ <link rel="stylesheet" href="chrome://global/skin/tabprompts.css" />
+
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+ <link
+ rel="stylesheet"
+ href="chrome://mozapps/content/extensions/aboutaddons.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://mozapps/content/extensions/shortcuts.css"
+ />
+
+ <link
+ rel="shortcut icon"
+ href="chrome://mozapps/skin/extensions/extension.svg"
+ />
+
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="toolkit/about/aboutAddons.ftl" />
+ <link rel="localization" href="toolkit/about/abuseReports.ftl" />
+
+ <!-- Defer scripts so all the templates are loaded by the time they run. -->
+ <script
+ defer
+ src="chrome://mozapps/content/extensions/aboutaddonsCommon.js"
+ ></script>
+ <script
+ defer
+ src="chrome://mozapps/content/extensions/abuse-reports.js"
+ ></script>
+ <script
+ defer
+ src="chrome://mozapps/content/extensions/shortcuts.js"
+ ></script>
+ <script
+ defer
+ src="chrome://mozapps/content/extensions/drag-drop-addon-installer.js"
+ ></script>
+ <script
+ defer
+ src="chrome://mozapps/content/extensions/view-controller.js"
+ ></script>
+ <script
+ defer
+ src="chrome://mozapps/content/extensions/aboutaddons.js"
+ ></script>
+ <script
+ type="module"
+ src="chrome://global/content/elements/moz-toggle.mjs"
+ ></script>
+ <script
+ type="module"
+ src="chrome://global/content/elements/moz-support-link.mjs"
+ ></script>
+ </head>
+ <body>
+ <drag-drop-addon-installer></drag-drop-addon-installer>
+ <div id="full">
+ <div id="sidebar">
+ <categories-box id="categories" orientation="vertical">
+ <button
+ is="discover-button"
+ viewid="addons://discover/"
+ class="category"
+ role="tab"
+ name="discover"
+ ></button>
+ <button
+ is="category-button"
+ viewid="addons://list/extension"
+ class="category"
+ role="tab"
+ name="extension"
+ ></button>
+ <button
+ is="category-button"
+ viewid="addons://list/theme"
+ class="category"
+ role="tab"
+ name="theme"
+ ></button>
+ <button
+ is="category-button"
+ viewid="addons://list/plugin"
+ class="category"
+ role="tab"
+ name="plugin"
+ ></button>
+ <button
+ is="category-button"
+ viewid="addons://list/dictionary"
+ class="category"
+ role="tab"
+ name="dictionary"
+ hidden
+ default-hidden
+ ></button>
+ <button
+ is="category-button"
+ viewid="addons://list/locale"
+ class="category"
+ role="tab"
+ name="locale"
+ hidden
+ default-hidden
+ ></button>
+ <button
+ is="category-button"
+ viewid="addons://list/sitepermission"
+ class="category"
+ role="tab"
+ name="sitepermission"
+ hidden
+ default-hidden
+ ></button>
+ <button
+ is="category-button"
+ viewid="addons://updates/available"
+ class="category"
+ role="tab"
+ name="available-updates"
+ hidden
+ default-hidden
+ ></button>
+ <button
+ is="category-button"
+ viewid="addons://updates/recent"
+ class="category"
+ role="tab"
+ name="recent-updates"
+ hidden
+ default-hidden
+ ></button>
+ </categories-box>
+ <div class="spacer"></div>
+ <sidebar-footer></sidebar-footer>
+ </div>
+ <div id="content">
+ <addon-page-header
+ id="page-header"
+ page-options-id="page-options"
+ ></addon-page-header>
+ <addon-page-options id="page-options"></addon-page-options>
+
+ <message-bar-stack
+ id="abuse-reports-messages"
+ reverse
+ max-message-bar-count="3"
+ >
+ </message-bar-stack>
+
+ <div id="main"></div>
+ </div>
+ </div>
+
+ <proxy-context-menu id="contentAreaContextMenu"></proxy-context-menu>
+
+ <template name="addon-page-header">
+ <div class="sticky-container">
+ <div class="main-search">
+ <label
+ for="search-addons"
+ class="search-label"
+ data-l10n-id="default-heading-search-label"
+ ></label>
+ <search-addons></search-addons>
+ </div>
+ <div class="main-heading">
+ <button
+ class="back-button"
+ action="go-back"
+ data-l10n-id="header-back-button"
+ hidden
+ ></button>
+ <h1 class="header-name"></h1>
+ <div class="spacer"></div>
+ <addon-updates-message
+ id="updates-message"
+ hidden
+ ></addon-updates-message>
+ <div class="page-options-menu">
+ <button
+ class="more-options-button"
+ action="page-options"
+ aria-haspopup="menu"
+ aria-expanded="false"
+ data-l10n-id="addon-page-options-button"
+ ></button>
+ </div>
+ </div>
+ </div>
+ <global-warnings></global-warnings>
+ </template>
+
+ <template name="addon-page-options">
+ <panel-list>
+ <panel-item
+ action="check-for-updates"
+ data-l10n-id="addon-updates-check-for-updates"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ <panel-item
+ action="view-recent-updates"
+ data-l10n-id="addon-updates-view-updates"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ <hr />
+ <panel-item
+ action="install-from-file"
+ data-l10n-id="addon-install-from-file"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ <panel-item
+ action="debug-addons"
+ data-l10n-id="addon-open-about-debugging"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ <hr />
+ <panel-item
+ action="set-update-automatically"
+ data-l10n-id="addon-updates-update-addons-automatically"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ <panel-item
+ action="reset-update-states"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ <hr />
+ <panel-item
+ action="manage-shortcuts"
+ data-l10n-id="addon-manage-extensions-shortcuts"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ </panel-list>
+ </template>
+
+ <template name="addon-options">
+ <panel-list>
+ <panel-item
+ data-l10n-id="remove-addon-button"
+ action="remove"
+ ></panel-item>
+ <panel-item
+ data-l10n-id="install-update-button"
+ action="install-update"
+ badged
+ ></panel-item>
+ <panel-item
+ data-l10n-id="preferences-addon-button"
+ action="preferences"
+ ></panel-item>
+ <hr />
+ <panel-item
+ data-l10n-id="report-addon-button"
+ action="report"
+ ></panel-item>
+ <hr />
+ <panel-item
+ data-l10n-id="manage-addon-button"
+ action="expand"
+ ></panel-item>
+ </panel-list>
+ </template>
+
+ <template name="plugin-options">
+ <panel-list>
+ <panel-item
+ data-l10n-id="always-activate-button"
+ action="always-activate"
+ ></panel-item>
+ <panel-item
+ data-l10n-id="never-activate-button"
+ action="never-activate"
+ ></panel-item>
+ <hr />
+ <panel-item
+ data-l10n-id="preferences-addon-button"
+ action="preferences"
+ ></panel-item>
+ <hr />
+ <panel-item
+ data-l10n-id="manage-addon-button"
+ action="expand"
+ ></panel-item>
+ </panel-list>
+ </template>
+
+ <template name="addon-permissions-list">
+ <div class="addon-permissions-required" hidden>
+ <h2
+ class="permission-header"
+ data-l10n-id="addon-permissions-required"
+ ></h2>
+ <ul class="addon-permissions-list"></ul>
+ </div>
+ <div class="addon-permissions-optional" hidden>
+ <h2
+ class="permission-header"
+ data-l10n-id="addon-permissions-optional"
+ ></h2>
+ <ul class="addon-permissions-list"></ul>
+ </div>
+ <div
+ class="addon-detail-row addon-permissions-empty"
+ data-l10n-id="addon-permissions-empty"
+ hidden
+ ></div>
+ <div class="addon-detail-row">
+ <a
+ is="moz-support-link"
+ support-page="extension-permissions"
+ data-l10n-id="addon-permissions-learnmore"
+ ></a>
+ </div>
+ </template>
+
+ <template name="addon-sitepermissions-list">
+ <div class="addon-permissions-required" hidden>
+ <h2
+ class="permission-header"
+ data-l10n-id="addon-sitepermissions-required"
+ >
+ <span
+ data-l10n-name="hostname"
+ class="addon-sitepermissions-host"
+ ></span>
+ </h2>
+ <ul class="addon-permissions-list"></ul>
+ </div>
+ </template>
+
+ <template name="card">
+ <div class="card addon">
+ <img class="card-heading-image" role="presentation" />
+ <div class="addon-card-collapsed">
+ <img class="card-heading-icon addon-icon" alt="" />
+ <div class="card-contents">
+ <div class="addon-name-container">
+ <a
+ class="addon-badge addon-badge-recommended"
+ is="moz-support-link"
+ support-page="add-on-badges"
+ utm-content="promoted-addon-badge"
+ data-l10n-id="addon-badge-recommended2"
+ hidden
+ >
+ </a>
+ <a
+ class="addon-badge addon-badge-line"
+ is="moz-support-link"
+ support-page="add-on-badges"
+ utm-content="promoted-addon-badge"
+ data-l10n-id="addon-badge-line3"
+ hidden
+ >
+ </a>
+ <a
+ class="addon-badge addon-badge-verified"
+ is="moz-support-link"
+ support-page="add-on-badges"
+ utm-content="promoted-addon-badge"
+ data-l10n-id="addon-badge-verified2"
+ hidden
+ >
+ </a>
+ <a
+ class="addon-badge addon-badge-private-browsing-allowed"
+ is="moz-support-link"
+ support-page="extensions-pb"
+ data-l10n-id="addon-badge-private-browsing-allowed2"
+ hidden
+ >
+ </a>
+ <div class="spacer"></div>
+ <button
+ class="theme-enable-button"
+ action="toggle-disabled"
+ hidden
+ ></button>
+ <moz-toggle
+ class="extension-enable-button"
+ action="toggle-disabled"
+ data-l10n-id="extension-enable-addon-button-label"
+ hidden
+ ></moz-toggle>
+ <button
+ class="more-options-button"
+ action="more-options"
+ data-l10n-id="addon-options-button"
+ aria-haspopup="menu"
+ aria-expanded="false"
+ ></button>
+ </div>
+ <!-- This ends up in the tab order when the ellipsis happens, but it isn't necessary. -->
+ <span class="addon-description" tabindex="-1"></span>
+ </div>
+ </div>
+ <message-bar class="update-postponed-bar" align="center" hidden>
+ <span
+ class="description"
+ data-l10n-id="install-postponed-message"
+ ></span>
+ <button
+ action="install-postponed"
+ data-l10n-id="install-postponed-button"
+ ></button>
+ </message-bar>
+ <message-bar class="addon-card-message" align="center" hidden>
+ <span></span>
+ <button action="link"></button>
+ </message-bar>
+ </div>
+ </template>
+
+ <template name="addon-name-container-in-disco-card">
+ <div class="disco-card-head">
+ <h3 class="disco-addon-name"></h3>
+ <span class="disco-addon-author"
+ ><a data-l10n-name="author" target="_blank"></a
+ ></span>
+ </div>
+ <button class="disco-cta-button" action="install-addon"></button>
+ <button
+ class="disco-cta-button"
+ data-l10n-id="manage-addon-button"
+ action="manage-addon"
+ ></button>
+ </template>
+
+ <template name="addon-description-in-disco-card">
+ <div>
+ <span class="disco-description-main"></span>
+ </div>
+ <div class="disco-description-statistics">
+ <five-star-rating></five-star-rating>
+ <span class="disco-user-count"></span>
+ </div>
+ </template>
+
+ <template name="addon-details">
+ <button-group class="tab-group">
+ <button
+ is="named-deck-button"
+ deck="details-deck"
+ name="details"
+ data-l10n-id="details-addon-button"
+ class="tab-button ghost-button"
+ ></button>
+ <button
+ is="named-deck-button"
+ deck="details-deck"
+ name="preferences"
+ data-l10n-id="preferences-addon-button"
+ class="tab-button ghost-button"
+ ></button>
+ <button
+ is="named-deck-button"
+ deck="details-deck"
+ name="permissions"
+ data-l10n-id="permissions-addon-button"
+ class="tab-button ghost-button"
+ ></button>
+ <button
+ is="named-deck-button"
+ deck="details-deck"
+ name="release-notes"
+ data-l10n-id="release-notes-addon-button"
+ class="tab-button ghost-button"
+ ></button>
+ </button-group>
+ <named-deck id="details-deck" is-tabbed>
+ <section name="details">
+ <div class="addon-detail-description-wrapper">
+ <div class="addon-detail-description"></div>
+ <button
+ class="button-link addon-detail-description-toggle"
+ data-l10n-id="addon-detail-description-expand"
+ hidden
+ ></button>
+ </div>
+ <div class="addon-detail-contribute">
+ <label data-l10n-id="detail-contributions-description"></label>
+ <button
+ class="addon-detail-contribute-button"
+ action="contribute"
+ data-l10n-id="detail-contributions-button"
+ data-l10n-attrs="accesskey"
+ ></button>
+ </div>
+ <div class="addon-detail-sitepermissions">
+ <addon-sitepermissions-list></addon-sitepermissions-list>
+ </div>
+ <div class="addon-detail-row addon-detail-row-updates">
+ <label data-l10n-id="addon-detail-updates-label"></label>
+ <div class="addon-detail-actions">
+ <button
+ class="button-link"
+ data-l10n-id="addon-detail-update-check-label"
+ action="update-check"
+ hidden
+ ></button>
+ <label class="radio-container-with-text">
+ <input type="radio" name="autoupdate" value="1" />
+ <span data-l10n-id="addon-detail-updates-radio-default"></span>
+ </label>
+ <label class="radio-container-with-text">
+ <input type="radio" name="autoupdate" value="2" />
+ <span data-l10n-id="addon-detail-updates-radio-on"></span>
+ </label>
+ <label class="radio-container-with-text">
+ <input type="radio" name="autoupdate" value="0" />
+ <span data-l10n-id="addon-detail-updates-radio-off"></span>
+ </label>
+ </div>
+ </div>
+ <div
+ class="addon-detail-row addon-detail-row-has-help addon-detail-row-private-browsing"
+ hidden
+ >
+ <label data-l10n-id="detail-private-browsing-label"></label>
+ <div class="addon-detail-actions">
+ <label class="radio-container-with-text">
+ <input type="radio" name="private-browsing" value="1" />
+ <span data-l10n-id="addon-detail-private-browsing-allow"></span>
+ </label>
+ <label class="radio-container-with-text">
+ <input type="radio" name="private-browsing" value="0" />
+ <span
+ data-l10n-id="addon-detail-private-browsing-disallow"
+ ></span>
+ </label>
+ </div>
+ </div>
+ <div
+ class="addon-detail-row addon-detail-help-row"
+ data-l10n-id="addon-detail-private-browsing-help"
+ hidden
+ >
+ <a
+ is="moz-support-link"
+ support-page="extensions-pb"
+ data-l10n-name="learn-more"
+ ></a>
+ </div>
+ <div
+ class="addon-detail-row addon-detail-row-has-help addon-detail-row-private-browsing-disallowed"
+ hidden
+ >
+ <label data-l10n-id="detail-private-disallowed-label"></label>
+ </div>
+ <div
+ class="addon-detail-row addon-detail-help-row"
+ data-l10n-id="detail-private-disallowed-description2"
+ hidden
+ >
+ <a
+ is="moz-support-link"
+ data-l10n-name="learn-more"
+ support-page="extensions-pb"
+ ></a>
+ </div>
+ <div
+ class="addon-detail-row addon-detail-row-has-help addon-detail-row-private-browsing-required"
+ hidden
+ >
+ <label
+ class="learn-more-label-link"
+ data-l10n-id="detail-private-required-label"
+ ></label>
+ </div>
+ <div
+ class="addon-detail-row addon-detail-help-row"
+ data-l10n-id="detail-private-required-description2"
+ hidden
+ >
+ <a
+ is="moz-support-link"
+ data-l10n-name="learn-more"
+ support-page="extensions-pb"
+ ></a>
+ </div>
+ <div class="addon-detail-row addon-detail-row-author">
+ <label data-l10n-id="addon-detail-author-label"></label>
+ <a target="_blank"></a>
+ </div>
+ <div class="addon-detail-row addon-detail-row-version">
+ <label data-l10n-id="addon-detail-version-label"></label>
+ </div>
+ <div class="addon-detail-row addon-detail-row-lastUpdated">
+ <label data-l10n-id="addon-detail-last-updated-label"></label>
+ </div>
+ <div class="addon-detail-row addon-detail-row-homepage">
+ <label data-l10n-id="addon-detail-homepage-label"></label>
+ <!-- URLs should always be displayed as LTR. -->
+ <a target="_blank" dir="ltr"></a>
+ </div>
+ <div class="addon-detail-row addon-detail-row-rating">
+ <label data-l10n-id="addon-detail-rating-label"></label>
+ <div class="addon-detail-rating">
+ <five-star-rating></five-star-rating>
+ <a target="_blank"></a>
+ </div>
+ </div>
+ </section>
+ <inline-options-browser name="preferences"></inline-options-browser>
+ <addon-permissions-list name="permissions"></addon-permissions-list>
+ <update-release-notes name="release-notes"></update-release-notes>
+ </named-deck>
+ </template>
+
+ <template name="five-star-rating">
+ <link
+ rel="stylesheet"
+ href="chrome://mozapps/content/extensions/rating-star.css"
+ />
+ <span class="rating-star"></span>
+ <span class="rating-star"></span>
+ <span class="rating-star"></span>
+ <span class="rating-star"></span>
+ <span class="rating-star"></span>
+ </template>
+
+ <template name="taar-notice">
+ <message-bar class="discopane-notice" dismissable>
+ <div class="discopane-notice-content">
+ <span data-l10n-id="discopane-notice-recommendations"></span>
+ <a
+ is="moz-support-link"
+ support-page="personalized-addons"
+ data-l10n-id="discopane-notice-learn-more"
+ action="notice-learn-more"
+ ></a>
+ </div>
+ </message-bar>
+ </template>
+
+ <template name="recommended-footer">
+ <div class="amo-link-container view-footer-item">
+ <button
+ class="primary"
+ action="open-amo"
+ data-l10n-id="find-more-addons"
+ ></button>
+ </div>
+ <div class="view-footer-item">
+ <a
+ class="privacy-policy-link"
+ data-l10n-id="privacy-policy"
+ target="_blank"
+ ></a>
+ </div>
+ </template>
+
+ <template name="discopane">
+ <header>
+ <p>
+ <span data-l10n-id="discopane-intro">
+ <a
+ class="discopane-intro-learn-more-link"
+ is="moz-support-link"
+ support-page="recommended-extensions-program"
+ data-l10n-name="learn-more-trigger"
+ >
+ </a>
+ </span>
+ </p>
+ </header>
+ <taar-notice></taar-notice>
+ <recommended-addon-list></recommended-addon-list>
+ <footer is="recommended-footer" class="view-footer"></footer>
+ </template>
+
+ <template name="recommended-extensions-section">
+ <h2
+ data-l10n-id="recommended-extensions-heading"
+ class="header-name recommended-heading"
+ ></h2>
+ <taar-notice></taar-notice>
+ <recommended-addon-list
+ type="extension"
+ hide-installed
+ ></recommended-addon-list>
+ <footer is="recommended-footer" class="view-footer"></footer>
+ </template>
+
+ <template name="recommended-themes-footer">
+ <p data-l10n-id="recommended-theme-1" class="theme-recommendation">
+ <a data-l10n-name="link" target="_blank"></a>
+ </p>
+ <div class="amo-link-container view-footer-item">
+ <button
+ class="primary"
+ action="open-amo"
+ data-l10n-id="find-more-themes"
+ ></button>
+ </div>
+ </template>
+
+ <template name="recommended-themes-section">
+ <h2
+ data-l10n-id="recommended-themes-heading"
+ class="header-name recommended-heading"
+ ></h2>
+ <recommended-addon-list
+ type="theme"
+ hide-installed
+ ></recommended-addon-list>
+ <footer is="recommended-themes-footer" class="view-footer"></footer>
+ </template>
+
+ <template id="shortcut-view">
+ <div class="error-message">
+ <img
+ class="error-message-icon"
+ src="chrome://global/skin/arrow/panelarrow-vertical.svg"
+ />
+ <div class="error-message-label"></div>
+ </div>
+ <message-bar-stack
+ id="duplicate-warning-messages"
+ reverse
+ max-message-bar-count="5"
+ >
+ </message-bar-stack>
+ </template>
+
+ <template id="shortcut-card-template">
+ <div class="card shortcut">
+ <div class="card-heading">
+ <img class="card-heading-icon addon-icon" />
+ <h2 class="addon-name"></h2>
+ </div>
+ </div>
+ </template>
+
+ <template id="shortcut-row-template">
+ <div class="shortcut-row">
+ <label class="shortcut-label"></label>
+ <input
+ class="shortcut-input"
+ data-l10n-id="shortcuts-input"
+ type="text"
+ readonly
+ />
+ <button class="shortcut-remove-button ghost-button"></button>
+ </div>
+ </template>
+
+ <template id="expand-row-template">
+ <div class="expand-row">
+ <button class="expand-button"></button>
+ </div>
+ </template>
+
+ <template id="shortcuts-no-addons">
+ <div data-l10n-id="shortcuts-no-addons"></div>
+ </template>
+
+ <template id="shortcuts-no-commands-template">
+ <div data-l10n-id="shortcuts-no-commands"></div>
+ <ul class="shortcuts-no-commands-list"></ul>
+ </template>
+ </body>
+</html>
diff --git a/toolkit/mozapps/extensions/content/aboutaddons.js b/toolkit/mozapps/extensions/content/aboutaddons.js
new file mode 100644
index 0000000000..8275a2afef
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/aboutaddons.js
@@ -0,0 +1,4232 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* eslint max-len: ["error", 80] */
+/* import-globals-from aboutaddonsCommon.js */
+/* import-globals-from abuse-reports.js */
+/* import-globals-from view-controller.js */
+/* global windowRoot */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
+ BuiltInThemes: "resource:///modules/BuiltInThemes.sys.mjs",
+ ClientID: "resource://gre/modules/ClientID.sys.mjs",
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+ ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs",
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+ ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(this, "extensionStylesheets", () => {
+ const { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+ );
+ return ExtensionParent.extensionStylesheets;
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "manifestV3enabled",
+ "extensions.manifestV3.enabled"
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "SUPPORT_URL",
+ "app.support.baseURL",
+ "",
+ null,
+ val => Services.urlFormatter.formatURL(val)
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "XPINSTALL_ENABLED",
+ "xpinstall.enabled",
+ true
+);
+
+const UPDATES_RECENT_TIMESPAN = 2 * 24 * 3600000; // 2 days (in milliseconds)
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "ABUSE_REPORT_ENABLED",
+ "extensions.abuseReport.enabled",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "LIST_RECOMMENDATIONS_ENABLED",
+ "extensions.htmlaboutaddons.recommendations.enabled",
+ false
+);
+
+const PLUGIN_ICON_URL = "chrome://global/skin/icons/plugin.svg";
+const EXTENSION_ICON_URL =
+ "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+
+const PERMISSION_MASKS = {
+ enable: AddonManager.PERM_CAN_ENABLE,
+ "always-activate": AddonManager.PERM_CAN_ENABLE,
+ disable: AddonManager.PERM_CAN_DISABLE,
+ "never-activate": AddonManager.PERM_CAN_DISABLE,
+ uninstall: AddonManager.PERM_CAN_UNINSTALL,
+ upgrade: AddonManager.PERM_CAN_UPGRADE,
+ "change-privatebrowsing": AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS,
+};
+
+const PREF_DISCOVERY_API_URL = "extensions.getAddons.discovery.api_url";
+const PREF_THEME_RECOMMENDATION_URL =
+ "extensions.recommendations.themeRecommendationUrl";
+const PREF_RECOMMENDATION_HIDE_NOTICE = "extensions.recommendations.hideNotice";
+const PREF_PRIVACY_POLICY_URL = "extensions.recommendations.privacyPolicyUrl";
+const PREF_RECOMMENDATION_ENABLED = "browser.discovery.enabled";
+const PREF_TELEMETRY_ENABLED = "datareporting.healthreport.uploadEnabled";
+const PRIVATE_BROWSING_PERM_NAME = "internal:privateBrowsingAllowed";
+const PRIVATE_BROWSING_PERMS = {
+ permissions: [PRIVATE_BROWSING_PERM_NAME],
+ origins: [],
+};
+
+const L10N_ID_MAPPING = {
+ "theme-disabled-heading": "theme-disabled-heading2",
+};
+
+function getL10nIdMapping(id) {
+ return L10N_ID_MAPPING[id] || id;
+}
+
+function shouldSkipAnimations() {
+ return (
+ document.body.hasAttribute("skip-animations") ||
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ );
+}
+
+function callListeners(name, args, listeners) {
+ for (let listener of listeners) {
+ try {
+ if (name in listener) {
+ listener[name](...args);
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+}
+
+function getUpdateInstall(addon) {
+ return (
+ // Install object for a pending update.
+ addon.updateInstall ||
+ // Install object for a postponed upgrade (only for extensions,
+ // because is the only addon type that can postpone their own
+ // updates).
+ (addon.type === "extension" &&
+ addon.pendingUpgrade &&
+ addon.pendingUpgrade.install)
+ );
+}
+
+function isManualUpdate(install) {
+ let isManual =
+ install.existingAddon &&
+ !AddonManager.shouldAutoUpdate(install.existingAddon);
+ let isExtension =
+ install.existingAddon && install.existingAddon.type == "extension";
+ return (
+ (isManual && isInState(install, "available")) ||
+ (isExtension && isInState(install, "postponed"))
+ );
+}
+
+const AddonManagerListenerHandler = {
+ listeners: new Set(),
+
+ addListener(listener) {
+ this.listeners.add(listener);
+ },
+
+ removeListener(listener) {
+ this.listeners.delete(listener);
+ },
+
+ delegateEvent(name, args) {
+ callListeners(name, args, this.listeners);
+ },
+
+ startup() {
+ this._listener = new Proxy(
+ {},
+ {
+ has: () => true,
+ get:
+ (_, name) =>
+ (...args) =>
+ this.delegateEvent(name, args),
+ }
+ );
+ AddonManager.addAddonListener(this._listener);
+ AddonManager.addInstallListener(this._listener);
+ AddonManager.addManagerListener(this._listener);
+ this._permissionHandler = (type, data) => {
+ if (type == "change-permissions") {
+ this.delegateEvent("onChangePermissions", [data]);
+ }
+ };
+ ExtensionPermissions.addListener(this._permissionHandler);
+ },
+
+ shutdown() {
+ AddonManager.removeAddonListener(this._listener);
+ AddonManager.removeInstallListener(this._listener);
+ AddonManager.removeManagerListener(this._listener);
+ ExtensionPermissions.removeListener(this._permissionHandler);
+ },
+};
+
+/**
+ * This object wires the AddonManager event listeners into addon-card and
+ * addon-details elements rather than needing to add/remove listeners all the
+ * time as the view changes.
+ */
+const AddonCardListenerHandler = new Proxy(
+ {},
+ {
+ has: () => true,
+ get(_, name) {
+ return (...args) => {
+ let elements = [];
+ let addonId;
+
+ // We expect args[0] to be of type:
+ // - AddonInstall, on AddonManager install events
+ // - AddonWrapper, on AddonManager addon events
+ // - undefined, on AddonManager manage events
+ if (args[0]) {
+ addonId =
+ args[0].addon?.id ||
+ args[0].existingAddon?.id ||
+ args[0].extensionId ||
+ args[0].id;
+ }
+
+ if (addonId) {
+ let cardSelector = `addon-card[addon-id="${addonId}"]`;
+ elements = document.querySelectorAll(
+ `${cardSelector}, ${cardSelector} addon-details`
+ );
+ } else if (name == "onUpdateModeChanged") {
+ elements = document.querySelectorAll("addon-card");
+ }
+
+ callListeners(name, args, elements);
+ };
+ },
+ }
+);
+AddonManagerListenerHandler.addListener(AddonCardListenerHandler);
+
+function isAbuseReportSupported(addon) {
+ return (
+ ABUSE_REPORT_ENABLED &&
+ AbuseReporter.isSupportedAddonType(addon.type) &&
+ !(addon.isBuiltin || addon.isSystem)
+ );
+}
+
+async function isAllowedInPrivateBrowsing(addon) {
+ // Use the Promise directly so this function stays sync for the other case.
+ let perms = await ExtensionPermissions.get(addon.id);
+ return perms.permissions.includes(PRIVATE_BROWSING_PERM_NAME);
+}
+
+function hasPermission(addon, permission) {
+ return !!(addon.permissions & PERMISSION_MASKS[permission]);
+}
+
+function isInState(install, state) {
+ return install.state == AddonManager["STATE_" + state.toUpperCase()];
+}
+
+async function getAddonMessageInfo(addon) {
+ const { name } = addon;
+ const { STATE_BLOCKED, STATE_SOFTBLOCKED } = Ci.nsIBlocklistService;
+
+ if (addon.blocklistState === STATE_BLOCKED) {
+ return {
+ linkUrl: await addon.getBlocklistURL(),
+ messageId: "details-notification-blocked",
+ messageArgs: { name },
+ type: "error",
+ };
+ } else if (isDisabledUnsigned(addon)) {
+ return {
+ linkUrl: SUPPORT_URL + "unsigned-addons",
+ messageId: "details-notification-unsigned-and-disabled",
+ messageArgs: { name },
+ type: "error",
+ };
+ } else if (
+ !addon.isCompatible &&
+ (AddonManager.checkCompatibility ||
+ addon.blocklistState !== STATE_SOFTBLOCKED)
+ ) {
+ return {
+ messageId: "details-notification-incompatible",
+ messageArgs: { name, version: Services.appinfo.version },
+ type: "warning",
+ };
+ } else if (!isCorrectlySigned(addon)) {
+ return {
+ linkUrl: SUPPORT_URL + "unsigned-addons",
+ messageId: "details-notification-unsigned",
+ messageArgs: { name },
+ type: "warning",
+ };
+ } else if (addon.blocklistState === STATE_SOFTBLOCKED) {
+ return {
+ linkUrl: await addon.getBlocklistURL(),
+ messageId: "details-notification-softblocked",
+ messageArgs: { name },
+ type: "warning",
+ };
+ } else if (addon.isGMPlugin && !addon.isInstalled && addon.isActive) {
+ return {
+ messageId: "details-notification-gmp-pending",
+ messageArgs: { name },
+ type: "warning",
+ };
+ }
+ return {};
+}
+
+function checkForUpdate(addon) {
+ return new Promise(resolve => {
+ let listener = {
+ onUpdateAvailable(addon, install) {
+ if (AddonManager.shouldAutoUpdate(addon)) {
+ // Make sure that an update handler is attached to all the install
+ // objects when updated xpis are going to be installed automatically.
+ attachUpdateHandler(install);
+
+ let failed = () => {
+ detachUpdateHandler(install);
+ install.removeListener(updateListener);
+ resolve({ installed: false, pending: false, found: true });
+ };
+ let updateListener = {
+ onDownloadFailed: failed,
+ onInstallCancelled: failed,
+ onInstallFailed: failed,
+ onInstallEnded: (...args) => {
+ detachUpdateHandler(install);
+ install.removeListener(updateListener);
+ resolve({ installed: true, pending: false, found: true });
+ },
+ onInstallPostponed: (...args) => {
+ detachUpdateHandler(install);
+ install.removeListener(updateListener);
+ resolve({ installed: false, pending: true, found: true });
+ },
+ };
+ install.addListener(updateListener);
+ install.install();
+ } else {
+ resolve({ installed: false, pending: true, found: true });
+ }
+ },
+ onNoUpdateAvailable() {
+ resolve({ found: false });
+ },
+ };
+ addon.findUpdates(listener, AddonManager.UPDATE_WHEN_USER_REQUESTED);
+ });
+}
+
+async function checkForUpdates() {
+ let addons = await AddonManager.getAddonsByTypes(null);
+ addons = addons.filter(addon => hasPermission(addon, "upgrade"));
+ let updates = await Promise.all(addons.map(addon => checkForUpdate(addon)));
+ gViewController.notifyEMUpdateCheckFinished();
+ return updates.reduce(
+ (counts, update) => ({
+ installed: counts.installed + (update.installed ? 1 : 0),
+ pending: counts.pending + (update.pending ? 1 : 0),
+ found: counts.found + (update.found ? 1 : 0),
+ }),
+ { installed: 0, pending: 0, found: 0 }
+ );
+}
+
+// Don't change how we handle this while the page is open.
+const INLINE_OPTIONS_ENABLED = Services.prefs.getBoolPref(
+ "extensions.htmlaboutaddons.inline-options.enabled"
+);
+const OPTIONS_TYPE_MAP = {
+ [AddonManager.OPTIONS_TYPE_TAB]: "tab",
+ [AddonManager.OPTIONS_TYPE_INLINE_BROWSER]: INLINE_OPTIONS_ENABLED
+ ? "inline"
+ : "tab",
+};
+
+// Check if an add-on has the provided options type, accounting for the pref
+// to disable inline options.
+function getOptionsType(addon, type) {
+ return OPTIONS_TYPE_MAP[addon.optionsType];
+}
+
+// Check whether the options page can be loaded in the current browser window.
+async function isAddonOptionsUIAllowed(addon) {
+ if (addon.type !== "extension" || !getOptionsType(addon)) {
+ // Themes never have options pages.
+ // Some plugins have preference pages, and they can always be shown.
+ // Extensions do not need to be checked if they do not have options pages.
+ return true;
+ }
+ if (!PrivateBrowsingUtils.isContentWindowPrivate(window)) {
+ return true;
+ }
+ if (addon.incognito === "not_allowed") {
+ return false;
+ }
+ // The current page is in a private browsing window, and the add-on does not
+ // have the permission to access private browsing windows. Block access.
+ return (
+ // Note: This function is async because isAllowedInPrivateBrowsing is async.
+ isAllowedInPrivateBrowsing(addon)
+ );
+}
+
+let _templates = {};
+
+/**
+ * Import a template from the main document.
+ */
+function importTemplate(name) {
+ if (!_templates.hasOwnProperty(name)) {
+ _templates[name] = document.querySelector(`template[name="${name}"]`);
+ }
+ let template = _templates[name];
+ if (template) {
+ return document.importNode(template.content, true);
+ }
+ throw new Error(`Unknown template: ${name}`);
+}
+
+function nl2br(text) {
+ let frag = document.createDocumentFragment();
+ let hasAppended = false;
+ for (let part of text.split("\n")) {
+ if (hasAppended) {
+ frag.appendChild(document.createElement("br"));
+ }
+ frag.appendChild(new Text(part));
+ hasAppended = true;
+ }
+ return frag;
+}
+
+/**
+ * Select the screeenshot to display above an add-on card.
+ *
+ * @param {AddonWrapper|DiscoAddonWrapper} addon
+ * @returns {string|null}
+ * The URL of the best fitting screenshot, if any.
+ */
+function getScreenshotUrlForAddon(addon) {
+ if (addon.id == "default-theme@mozilla.org") {
+ return "chrome://mozapps/content/extensions/default-theme/preview.svg";
+ }
+ const builtInThemePreview = BuiltInThemes.previewForBuiltInThemeId(addon.id);
+ if (builtInThemePreview) {
+ return builtInThemePreview;
+ }
+
+ let { screenshots } = addon;
+ if (!screenshots || !screenshots.length) {
+ return null;
+ }
+
+ // The image size is defined at .card-heading-image in aboutaddons.css, and
+ // is based on the aspect ratio for a 680x92 image. Use the image if possible,
+ // and otherwise fall back to the first image and hope for the best.
+ let screenshot = screenshots.find(s => s.width === 680 && s.height === 92);
+ if (!screenshot) {
+ console.warn(`Did not find screenshot with desired size for ${addon.id}.`);
+ screenshot = screenshots[0];
+ }
+ return screenshot.url;
+}
+
+/**
+ * Adds UTM parameters to a given URL, if it is an AMO URL.
+ *
+ * @param {string} contentAttribute
+ * Identifies the part of the UI with which the link is associated.
+ * @param {string} url
+ * @returns {string}
+ * The url with UTM parameters if it is an AMO URL.
+ * Otherwise the url in unmodified form.
+ */
+function formatUTMParams(contentAttribute, url) {
+ let parsedUrl = new URL(url);
+ let domain = `.${parsedUrl.hostname}`;
+ if (
+ !domain.endsWith(".mozilla.org") &&
+ // For testing: addons-dev.allizom.org and addons.allizom.org
+ !domain.endsWith(".allizom.org")
+ ) {
+ return url;
+ }
+
+ parsedUrl.searchParams.set("utm_source", "firefox-browser");
+ parsedUrl.searchParams.set("utm_medium", "firefox-browser");
+ parsedUrl.searchParams.set("utm_content", contentAttribute);
+ return parsedUrl.href;
+}
+
+// A wrapper around an item from the "results" array from AMO's discovery API.
+// See https://addons-server.readthedocs.io/en/latest/topics/api/discovery.html
+class DiscoAddonWrapper {
+ /**
+ * @param {object} details
+ * An item in the "results" array from AMO's discovery API.
+ */
+ constructor(details) {
+ // Reuse AddonRepository._parseAddon to have the AMO response parsing logic
+ // in one place.
+ let repositoryAddon = AddonRepository._parseAddon(details.addon);
+
+ // Note: Any property used by RecommendedAddonCard should appear here.
+ // The property names and values should have the same semantics as
+ // AddonWrapper, to ease the reuse of helper functions in this file.
+ this.id = repositoryAddon.id;
+ this.type = repositoryAddon.type;
+ this.name = repositoryAddon.name;
+ this.screenshots = repositoryAddon.screenshots;
+ this.sourceURI = repositoryAddon.sourceURI;
+ this.creator = repositoryAddon.creator;
+ this.averageRating = repositoryAddon.averageRating;
+
+ this.dailyUsers = details.addon.average_daily_users;
+
+ this.editorialDescription = details.description_text;
+ this.iconURL = details.addon.icon_url;
+ this.amoListingUrl = details.addon.url;
+
+ this.taarRecommended = details.is_recommendation;
+ }
+}
+
+/**
+ * A helper to retrieve the list of recommended add-ons via AMO's discovery API.
+ */
+var DiscoveryAPI = {
+ // Map<boolean, Promise> Promises from fetching the API results with or
+ // without a client ID. The `false` (no client ID) case could actually
+ // have been fetched with a client ID. See getResults() for more info.
+ _resultPromises: new Map(),
+
+ /**
+ * Fetch the list of recommended add-ons. The results are cached.
+ *
+ * Pending requests are coalesced, so there is only one request at any given
+ * time. If a request fails, the pending promises are rejected, but a new
+ * call will result in a new request. A succesful response is cached for the
+ * lifetime of the document.
+ *
+ * @param {boolean} preferClientId
+ * A boolean indicating a preference for using a client ID.
+ * This will not overwrite the user preference but will
+ * avoid sending a client ID if no request has been made yet.
+ * @returns {Promise<DiscoAddonWrapper[]>}
+ */
+ async getResults(preferClientId = true) {
+ // Allow a caller to set preferClientId to false, but not true if discovery
+ // is disabled.
+ preferClientId = preferClientId && this.clientIdDiscoveryEnabled;
+
+ // Reuse a request for this preference first.
+ let resultPromise =
+ this._resultPromises.get(preferClientId) ||
+ // If the client ID isn't preferred, we can still reuse a request with the
+ // client ID.
+ (!preferClientId && this._resultPromises.get(true));
+
+ if (resultPromise) {
+ return resultPromise;
+ }
+
+ // Nothing is prepared for this preference, make a new request.
+ resultPromise = this._fetchRecommendedAddons(preferClientId).catch(e => {
+ // Delete the pending promise, so _fetchRecommendedAddons can be
+ // called again at the next property access.
+ this._resultPromises.delete(preferClientId);
+ Cu.reportError(e);
+ throw e;
+ });
+
+ // Store the new result for the preference.
+ this._resultPromises.set(preferClientId, resultPromise);
+
+ return resultPromise;
+ },
+
+ get clientIdDiscoveryEnabled() {
+ // These prefs match Discovery.jsm for enabling clientId cookies.
+ return (
+ Services.prefs.getBoolPref(PREF_RECOMMENDATION_ENABLED, false) &&
+ Services.prefs.getBoolPref(PREF_TELEMETRY_ENABLED, false) &&
+ !PrivateBrowsingUtils.isContentWindowPrivate(window)
+ );
+ },
+
+ async _fetchRecommendedAddons(useClientId) {
+ let discoveryApiUrl = new URL(
+ Services.urlFormatter.formatURLPref(PREF_DISCOVERY_API_URL)
+ );
+
+ if (useClientId) {
+ let clientId = await ClientID.getClientIdHash();
+ discoveryApiUrl.searchParams.set("telemetry-client-id", clientId);
+ }
+ let res = await fetch(discoveryApiUrl.href, {
+ credentials: "omit",
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to fetch recommended add-ons, ${res.status}`);
+ }
+ let { results } = await res.json();
+ return results.map(details => new DiscoAddonWrapper(details));
+ },
+};
+
+class SearchAddons extends HTMLElement {
+ connectedCallback() {
+ if (this.childElementCount === 0) {
+ this.input = document.createXULElement("search-textbox");
+ this.input.setAttribute("searchbutton", true);
+ this.input.setAttribute("maxlength", 100);
+ this.input.setAttribute("data-l10n-attrs", "placeholder");
+ document.l10n.setAttributes(this.input, "addons-heading-search-input");
+ this.append(this.input);
+ }
+ this.input.addEventListener("command", this);
+ }
+
+ disconnectedCallback() {
+ this.input.removeEventListener("command", this);
+ }
+
+ handleEvent(e) {
+ if (e.type === "command") {
+ this.searchAddons(this.value);
+ }
+ }
+
+ get value() {
+ return this.input.value;
+ }
+
+ searchAddons(query) {
+ if (query.length === 0) {
+ return;
+ }
+
+ let url = formatUTMParams(
+ "addons-manager-search",
+ AddonRepository.getSearchURL(query)
+ );
+
+ let browser = getBrowserElement();
+ let chromewin = browser.ownerGlobal;
+ chromewin.openWebLinkIn(url, "tab");
+ }
+}
+customElements.define("search-addons", SearchAddons);
+
+class MessageBarStackElement extends HTMLElement {
+ constructor() {
+ super();
+ this._observer = null;
+ const shadowRoot = this.attachShadow({ mode: "open" });
+ shadowRoot.append(this.constructor.template.content.cloneNode(true));
+ }
+
+ connectedCallback() {
+ // Close any message bar that should be allowed based on the
+ // maximum number of message bars.
+ this.closeMessageBars();
+
+ // Observe mutations to close older bars when new ones have been
+ // added.
+ this._observer = new MutationObserver(() => {
+ this._observer.disconnect();
+ this.closeMessageBars();
+ this._observer.observe(this, { childList: true });
+ });
+ this._observer.observe(this, { childList: true });
+ }
+
+ disconnectedCallback() {
+ this._observer.disconnect();
+ this._observer = null;
+ }
+
+ closeMessageBars() {
+ const { maxMessageBarCount } = this;
+ if (maxMessageBarCount > 1) {
+ // Remove the older message bars if the stack reached the
+ // maximum number of message bars allowed.
+ while (this.childElementCount > maxMessageBarCount) {
+ this.firstElementChild.remove();
+ }
+ }
+ }
+
+ get maxMessageBarCount() {
+ return parseInt(this.getAttribute("max-message-bar-count"), 10);
+ }
+
+ static get template() {
+ const template = document.createElement("template");
+
+ const style = document.createElement("style");
+ // Render the stack in the reverse order if the stack has the
+ // reverse attribute set.
+ style.textContent = `
+ :host {
+ display: block;
+ }
+ :host([reverse]) > slot {
+ display: flex;
+ flex-direction: column-reverse;
+ }
+ `;
+ template.content.append(style);
+ template.content.append(document.createElement("slot"));
+
+ Object.defineProperty(this, "template", {
+ value: template,
+ });
+
+ return template;
+ }
+}
+
+customElements.define("message-bar-stack", MessageBarStackElement);
+
+class GlobalWarnings extends MessageBarStackElement {
+ constructor() {
+ super();
+ // This won't change at runtime, but we'll want to fake it in tests.
+ this.inSafeMode = Services.appinfo.inSafeMode;
+ this.globalWarning = null;
+ }
+
+ connectedCallback() {
+ this.refresh();
+ this.addEventListener("click", this);
+ AddonManagerListenerHandler.addListener(this);
+ }
+
+ disconnectedCallback() {
+ this.removeEventListener("click", this);
+ AddonManagerListenerHandler.removeListener(this);
+ }
+
+ refresh() {
+ if (this.inSafeMode) {
+ this.setWarning("safe-mode");
+ } else if (
+ AddonManager.checkUpdateSecurityDefault &&
+ !AddonManager.checkUpdateSecurity
+ ) {
+ this.setWarning("update-security", { action: true });
+ } else if (!AddonManager.checkCompatibility) {
+ this.setWarning("check-compatibility", { action: true });
+ } else {
+ this.removeWarning();
+ }
+ }
+
+ setWarning(type, opts) {
+ if (
+ this.globalWarning &&
+ this.globalWarning.getAttribute("warning-type") !== type
+ ) {
+ this.removeWarning();
+ }
+ if (!this.globalWarning) {
+ this.globalWarning = document.createElement("message-bar");
+ this.globalWarning.setAttribute("warning-type", type);
+ let textContainer = document.createElement("span");
+ document.l10n.setAttributes(textContainer, `extensions-warning-${type}`);
+ this.globalWarning.appendChild(textContainer);
+ if (opts && opts.action) {
+ let button = document.createElement("button");
+ document.l10n.setAttributes(
+ button,
+ `extensions-warning-${type}-button`
+ );
+ button.setAttribute("action", type);
+ this.globalWarning.appendChild(button);
+ }
+ this.appendChild(this.globalWarning);
+ }
+ }
+
+ removeWarning() {
+ if (this.globalWarning) {
+ this.globalWarning.remove();
+ this.globalWarning = null;
+ }
+ }
+
+ handleEvent(e) {
+ if (e.type === "click") {
+ switch (e.target.getAttribute("action")) {
+ case "update-security":
+ AddonManager.checkUpdateSecurity = true;
+ break;
+ case "check-compatibility":
+ AddonManager.checkCompatibility = true;
+ break;
+ }
+ }
+ }
+
+ /**
+ * AddonManager listener events.
+ */
+
+ onCompatibilityModeChanged() {
+ this.refresh();
+ }
+
+ onCheckUpdateSecurityChanged() {
+ this.refresh();
+ }
+}
+customElements.define("global-warnings", GlobalWarnings);
+
+class AddonPageHeader extends HTMLElement {
+ connectedCallback() {
+ if (this.childElementCount === 0) {
+ this.appendChild(importTemplate("addon-page-header"));
+ this.heading = this.querySelector(".header-name");
+ this.backButton = this.querySelector(".back-button");
+ this.pageOptionsMenuButton = this.querySelector(
+ '[action="page-options"]'
+ );
+ // The addon-page-options element is outside of this element since this is
+ // position: sticky and that would break the positioning of the menu.
+ this.pageOptionsMenu = document.getElementById(
+ this.getAttribute("page-options-id")
+ );
+ }
+ document.addEventListener("view-selected", this);
+ this.addEventListener("click", this);
+ this.addEventListener("mousedown", this);
+ // Use capture since the event is actually triggered on the internal
+ // panel-list and it doesn't bubble.
+ this.pageOptionsMenu.addEventListener("shown", this, true);
+ this.pageOptionsMenu.addEventListener("hidden", this, true);
+ }
+
+ disconnectedCallback() {
+ document.removeEventListener("view-selected", this);
+ this.removeEventListener("click", this);
+ this.removeEventListener("mousedown", this);
+ this.pageOptionsMenu.removeEventListener("shown", this, true);
+ this.pageOptionsMenu.removeEventListener("hidden", this, true);
+ }
+
+ setViewInfo({ type, param }) {
+ this.setAttribute("current-view", type);
+ this.setAttribute("current-param", param);
+ let viewType = type === "list" ? param : type;
+ this.setAttribute("type", viewType);
+
+ this.heading.hidden = viewType === "detail";
+ this.backButton.hidden = viewType !== "detail" && viewType !== "shortcuts";
+
+ this.backButton.disabled = !history.state?.previousView;
+
+ if (viewType !== "detail") {
+ document.l10n.setAttributes(this.heading, `${viewType}-heading`);
+ }
+ }
+
+ handleEvent(e) {
+ let { backButton, pageOptionsMenu, pageOptionsMenuButton } = this;
+ if (e.type === "click") {
+ switch (e.target) {
+ case backButton:
+ window.history.back();
+ break;
+ case pageOptionsMenuButton:
+ if (e.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
+ this.pageOptionsMenu.toggle(e);
+ }
+ break;
+ }
+ } else if (
+ e.type == "mousedown" &&
+ e.target == pageOptionsMenuButton &&
+ e.button == 0
+ ) {
+ this.pageOptionsMenu.toggle(e);
+ } else if (
+ e.target == pageOptionsMenu.panel &&
+ (e.type == "shown" || e.type == "hidden")
+ ) {
+ this.pageOptionsMenuButton.setAttribute(
+ "aria-expanded",
+ this.pageOptionsMenu.open
+ );
+ } else if (e.target == document && e.type == "view-selected") {
+ const { type, param } = e.detail;
+ this.setViewInfo({ type, param });
+ }
+ }
+}
+customElements.define("addon-page-header", AddonPageHeader);
+
+class AddonUpdatesMessage extends HTMLElement {
+ static get observedAttributes() {
+ return ["state"];
+ }
+
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ let style = document.createElement("style");
+ style.textContent = `
+ @import "chrome://global/skin/in-content/common.css";
+ button {
+ margin: 0;
+ }
+ `;
+ this.message = document.createElement("span");
+ this.message.hidden = true;
+ this.button = document.createElement("button");
+ this.button.addEventListener("click", e => {
+ if (e.button === 0) {
+ gViewController.loadView("updates/available");
+ }
+ });
+ this.button.hidden = true;
+ this.shadowRoot.append(style, this.message, this.button);
+ }
+
+ connectedCallback() {
+ document.l10n.connectRoot(this.shadowRoot);
+ document.l10n.translateFragment(this.shadowRoot);
+ }
+
+ disconnectedCallback() {
+ document.l10n.disconnectRoot(this.shadowRoot);
+ }
+
+ attributeChangedCallback(name, oldVal, newVal) {
+ if (name === "state" && oldVal !== newVal) {
+ let l10nId = `addon-updates-${newVal}`;
+ switch (newVal) {
+ case "updating":
+ case "installed":
+ case "none-found":
+ this.button.hidden = true;
+ this.message.hidden = false;
+ document.l10n.setAttributes(this.message, l10nId);
+ break;
+ case "manual-updates-found":
+ this.message.hidden = true;
+ this.button.hidden = false;
+ document.l10n.setAttributes(this.button, l10nId);
+ break;
+ }
+ }
+ }
+
+ set state(val) {
+ this.setAttribute("state", val);
+ }
+}
+customElements.define("addon-updates-message", AddonUpdatesMessage);
+
+class AddonPageOptions extends HTMLElement {
+ connectedCallback() {
+ if (this.childElementCount === 0) {
+ this.render();
+ }
+ this.addEventListener("click", this);
+ this.panel.addEventListener("showing", this);
+ AddonManagerListenerHandler.addListener(this);
+ }
+
+ disconnectedCallback() {
+ this.removeEventListener("click", this);
+ this.panel.removeEventListener("showing", this);
+ AddonManagerListenerHandler.removeListener(this);
+ }
+
+ toggle(...args) {
+ return this.panel.toggle(...args);
+ }
+
+ get open() {
+ return this.panel.open;
+ }
+
+ render() {
+ this.appendChild(importTemplate("addon-page-options"));
+ this.panel = this.querySelector("panel-list");
+ this.installFromFile = this.querySelector('[action="install-from-file"]');
+ this.toggleUpdatesEl = this.querySelector(
+ '[action="set-update-automatically"]'
+ );
+ this.resetUpdatesEl = this.querySelector('[action="reset-update-states"]');
+ this.onUpdateModeChanged();
+ }
+
+ async handleEvent(e) {
+ if (e.type === "click") {
+ e.target.disabled = true;
+ try {
+ await this.onClick(e);
+ } finally {
+ e.target.disabled = false;
+ }
+ } else if (e.type === "showing") {
+ this.installFromFile.hidden = !XPINSTALL_ENABLED;
+ }
+ }
+
+ async onClick(e) {
+ switch (e.target.getAttribute("action")) {
+ case "check-for-updates":
+ await this.checkForUpdates();
+ break;
+ case "view-recent-updates":
+ gViewController.loadView("updates/recent");
+ break;
+ case "install-from-file":
+ if (XPINSTALL_ENABLED) {
+ installAddonsFromFilePicker();
+ }
+ break;
+ case "debug-addons":
+ this.openAboutDebugging();
+ break;
+ case "set-update-automatically":
+ await this.toggleAutomaticUpdates();
+ break;
+ case "reset-update-states":
+ await this.resetAutomaticUpdates();
+ break;
+ case "manage-shortcuts":
+ gViewController.loadView("shortcuts/shortcuts");
+ break;
+ }
+ }
+
+ async checkForUpdates(e) {
+ let message = document.getElementById("updates-message");
+ message.state = "updating";
+ message.hidden = false;
+ let { installed, pending } = await checkForUpdates();
+ if (pending > 0) {
+ message.state = "manual-updates-found";
+ } else if (installed > 0) {
+ message.state = "installed";
+ } else {
+ message.state = "none-found";
+ }
+ }
+
+ openAboutDebugging() {
+ let mainWindow = window.windowRoot.ownerGlobal;
+ if ("switchToTabHavingURI" in mainWindow) {
+ let principal = Services.scriptSecurityManager.getSystemPrincipal();
+ mainWindow.switchToTabHavingURI(
+ `about:debugging#/runtime/this-firefox`,
+ true,
+ {
+ ignoreFragment: "whenComparing",
+ triggeringPrincipal: principal,
+ }
+ );
+ }
+ }
+
+ automaticUpdatesEnabled() {
+ return AddonManager.updateEnabled && AddonManager.autoUpdateDefault;
+ }
+
+ toggleAutomaticUpdates() {
+ if (!this.automaticUpdatesEnabled()) {
+ // One or both of the prefs is false, i.e. the checkbox is not
+ // checked. Now toggle both to true. If the user wants us to
+ // auto-update add-ons, we also need to auto-check for updates.
+ AddonManager.updateEnabled = true;
+ AddonManager.autoUpdateDefault = true;
+ } else {
+ // Both prefs are true, i.e. the checkbox is checked.
+ // Toggle the auto pref to false, but don't touch the enabled check.
+ AddonManager.autoUpdateDefault = false;
+ }
+ }
+
+ async resetAutomaticUpdates() {
+ let addons = await AddonManager.getAllAddons();
+ for (let addon of addons) {
+ if ("applyBackgroundUpdates" in addon) {
+ addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT;
+ }
+ }
+ }
+
+ /**
+ * AddonManager listener events.
+ */
+
+ onUpdateModeChanged() {
+ let updatesEnabled = this.automaticUpdatesEnabled();
+ this.toggleUpdatesEl.checked = updatesEnabled;
+ let resetType = updatesEnabled ? "automatic" : "manual";
+ let resetStringId = `addon-updates-reset-updates-to-${resetType}`;
+ document.l10n.setAttributes(this.resetUpdatesEl, resetStringId);
+ }
+}
+customElements.define("addon-page-options", AddonPageOptions);
+
+class CategoryButton extends HTMLButtonElement {
+ connectedCallback() {
+ if (this.childElementCount != 0) {
+ return;
+ }
+
+ // Make sure the aria-selected attribute is set correctly.
+ this.selected = this.hasAttribute("selected");
+
+ document.l10n.setAttributes(this, `addon-category-${this.name}-title`);
+
+ let text = document.createElement("span");
+ text.classList.add("category-name");
+ document.l10n.setAttributes(text, `addon-category-${this.name}`);
+
+ this.append(text);
+ }
+
+ load() {
+ gViewController.loadView(this.viewId);
+ }
+
+ get isVisible() {
+ // Make a category button visible only if the related addon type is
+ // supported by the AddonManager Providers actually registered to
+ // the AddonManager.
+ return AddonManager.hasAddonType(this.name);
+ }
+
+ get badgeCount() {
+ return parseInt(this.getAttribute("badge-count"), 10) || 0;
+ }
+
+ set badgeCount(val) {
+ let count = parseInt(val, 10);
+ if (count) {
+ this.setAttribute("badge-count", count);
+ } else {
+ this.removeAttribute("badge-count");
+ }
+ }
+
+ get selected() {
+ return this.hasAttribute("selected");
+ }
+
+ set selected(val) {
+ this.toggleAttribute("selected", !!val);
+ this.setAttribute("aria-selected", !!val);
+ }
+
+ get name() {
+ return this.getAttribute("name");
+ }
+
+ get viewId() {
+ return this.getAttribute("viewid");
+ }
+
+ // Just setting the hidden attribute isn't enough in case the category gets
+ // hidden while about:addons is closed since it could be the last active view
+ // which will unhide the button when it gets selected.
+ get defaultHidden() {
+ return this.hasAttribute("default-hidden");
+ }
+}
+customElements.define("category-button", CategoryButton, { extends: "button" });
+
+class DiscoverButton extends CategoryButton {
+ get isVisible() {
+ return isDiscoverEnabled();
+ }
+}
+customElements.define("discover-button", DiscoverButton, { extends: "button" });
+
+// Create the button-group element so it gets loaded.
+document.createElement("button-group");
+class CategoriesBox extends customElements.get("button-group") {
+ constructor() {
+ super();
+ // This will resolve when the initial category states have been set from
+ // our cached prefs. This is intended for use in testing to verify that we
+ // are caching the previous state.
+ this.promiseRendered = new Promise(resolve => {
+ this._resolveRendered = resolve;
+ });
+ }
+
+ handleEvent(e) {
+ if (e.target == document && e.type == "view-selected") {
+ const { type, param } = e.detail;
+ this.select(`addons://${type}/${param}`);
+ return;
+ }
+
+ if (e.target == this && e.type == "button-group:key-selected") {
+ this.activeChild.load();
+ return;
+ }
+
+ if (e.type == "click") {
+ const button = e.target.closest("[viewid]");
+ if (button) {
+ button.load();
+ return;
+ }
+ }
+
+ // Forward the unhandled events to the button-group custom element.
+ super.handleEvent(e);
+ }
+
+ disconnectedCallback() {
+ document.removeEventListener("view-selected", this);
+ this.removeEventListener("button-group:key-selected", this);
+ this.removeEventListener("click", this);
+ AddonManagerListenerHandler.removeListener(this);
+ super.disconnectedCallback();
+ }
+
+ async initialize() {
+ let hiddenTypes = new Set([]);
+
+ for (let button of this.children) {
+ let { defaultHidden, name } = button;
+ button.hidden =
+ !button.isVisible || (defaultHidden && this.shouldHideCategory(name));
+
+ if (defaultHidden && AddonManager.hasAddonType(name)) {
+ hiddenTypes.add(name);
+ }
+ }
+
+ let hiddenUpdated;
+ if (hiddenTypes.size) {
+ hiddenUpdated = this.updateHiddenCategories(Array.from(hiddenTypes));
+ }
+
+ this.updateAvailableCount();
+
+ document.addEventListener("view-selected", this);
+ this.addEventListener("button-group:key-selected", this);
+ this.addEventListener("click", this);
+ AddonManagerListenerHandler.addListener(this);
+
+ this._resolveRendered();
+ await hiddenUpdated;
+ }
+
+ shouldHideCategory(name) {
+ return Services.prefs.getBoolPref(`extensions.ui.${name}.hidden`, true);
+ }
+
+ setShouldHideCategory(name, hide) {
+ Services.prefs.setBoolPref(`extensions.ui.${name}.hidden`, hide);
+ }
+
+ getButtonByName(name) {
+ return this.querySelector(`[name="${name}"]`);
+ }
+
+ get selectedChild() {
+ return this._selectedChild;
+ }
+
+ set selectedChild(node) {
+ if (node && this.contains(node)) {
+ if (this._selectedChild) {
+ this._selectedChild.selected = false;
+ }
+ this._selectedChild = node;
+ this._selectedChild.selected = true;
+ }
+ }
+
+ select(viewId) {
+ let button = this.querySelector(`[viewid="${viewId}"]`);
+ if (button) {
+ this.activeChild = button;
+ this.selectedChild = button;
+ button.hidden = false;
+ Services.prefs.setStringPref(PREF_UI_LASTCATEGORY, viewId);
+ }
+ }
+
+ selectType(type) {
+ this.select(`addons://list/${type}`);
+ }
+
+ onInstalled(addon) {
+ let button = this.getButtonByName(addon.type);
+ if (button) {
+ button.hidden = false;
+ this.setShouldHideCategory(addon.type, false);
+ }
+ this.updateAvailableCount();
+ }
+
+ onInstallStarted(install) {
+ this.onInstalled(install);
+ }
+
+ onNewInstall() {
+ this.updateAvailableCount();
+ }
+
+ onInstallPostponed() {
+ this.updateAvailableCount();
+ }
+
+ onInstallCancelled() {
+ this.updateAvailableCount();
+ }
+
+ async updateAvailableCount() {
+ let installs = await AddonManager.getAllInstalls();
+ var count = installs.filter(install => {
+ return isManualUpdate(install) && !install.installed;
+ }).length;
+ let availableButton = this.getButtonByName("available-updates");
+ availableButton.hidden = !availableButton.selected && count == 0;
+ availableButton.badgeCount = count;
+ }
+
+ async updateHiddenCategories(types) {
+ let hiddenTypes = new Set(types);
+ let getAddons = AddonManager.getAddonsByTypes(types);
+ let getInstalls = AddonManager.getInstallsByTypes(types);
+
+ for (let addon of await getAddons) {
+ if (addon.hidden) {
+ continue;
+ }
+
+ this.onInstalled(addon);
+ hiddenTypes.delete(addon.type);
+
+ if (!hiddenTypes.size) {
+ return;
+ }
+ }
+
+ for (let install of await getInstalls) {
+ if (
+ install.existingAddon ||
+ install.state == AddonManager.STATE_AVAILABLE
+ ) {
+ continue;
+ }
+
+ this.onInstalled(install);
+ hiddenTypes.delete(install.type);
+
+ if (!hiddenTypes.size) {
+ return;
+ }
+ }
+
+ for (let type of hiddenTypes) {
+ let button = this.getButtonByName(type);
+ if (button.selected) {
+ // Cancel the load if this view should be hidden.
+ gViewController.resetState();
+ }
+ this.setShouldHideCategory(type, true);
+ button.hidden = true;
+ }
+ }
+}
+customElements.define("categories-box", CategoriesBox);
+
+class SidebarFooter extends HTMLElement {
+ connectedCallback() {
+ let list = document.createElement("ul");
+ list.classList.add("sidebar-footer-list");
+
+ let systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+ let prefsItem = this.createItem({
+ icon: "chrome://global/skin/icons/settings.svg",
+ createLinkElement: () => {
+ let link = document.createElement("a");
+ link.href = "about:preferences";
+ link.id = "preferencesButton";
+ return link;
+ },
+ titleL10nId: "sidebar-settings-button-title",
+ labelL10nId: "addons-settings-button",
+ onClick: e => {
+ e.preventDefault();
+ windowRoot.ownerGlobal.switchToTabHavingURI("about:preferences", true, {
+ ignoreFragment: "whenComparing",
+ triggeringPrincipal: systemPrincipal,
+ });
+ },
+ });
+
+ let supportItem = this.createItem({
+ icon: "chrome://global/skin/icons/help.svg",
+ createLinkElement: () => {
+ let link = document.createElement("a", { is: "moz-support-link" });
+ link.setAttribute("support-page", "addons-help");
+ link.id = "help-button";
+ return link;
+ },
+ titleL10nId: "sidebar-help-button-title",
+ labelL10nId: "help-button",
+ });
+
+ list.append(prefsItem, supportItem);
+ this.append(list);
+ }
+
+ createItem({ onClick, titleL10nId, labelL10nId, icon, createLinkElement }) {
+ let listItem = document.createElement("li");
+
+ let link = createLinkElement();
+ link.classList.add("sidebar-footer-link");
+ link.addEventListener("click", onClick);
+ document.l10n.setAttributes(link, titleL10nId);
+
+ let img = document.createElement("img");
+ img.src = icon;
+ img.className = "sidebar-footer-icon";
+
+ let label = document.createElement("span");
+ label.className = "sidebar-footer-label";
+ document.l10n.setAttributes(label, labelL10nId);
+
+ link.append(img, label);
+ listItem.append(link);
+ return listItem;
+ }
+}
+customElements.define("sidebar-footer", SidebarFooter, { extends: "footer" });
+
+class AddonOptions extends HTMLElement {
+ connectedCallback() {
+ if (!this.children.length) {
+ this.render();
+ }
+ }
+
+ get panel() {
+ return this.querySelector("panel-list");
+ }
+
+ updateSeparatorsVisibility() {
+ let lastSeparator;
+ let elWasVisible = false;
+
+ // Collect the panel-list children that are not already hidden.
+ const children = Array.from(this.panel.children).filter(el => !el.hidden);
+
+ for (let child of children) {
+ if (child.localName == "hr") {
+ child.hidden = !elWasVisible;
+ if (!child.hidden) {
+ lastSeparator = child;
+ }
+ elWasVisible = false;
+ } else {
+ elWasVisible = true;
+ }
+ }
+ if (!elWasVisible && lastSeparator) {
+ lastSeparator.hidden = true;
+ }
+ }
+
+ get template() {
+ return "addon-options";
+ }
+
+ render() {
+ this.appendChild(importTemplate(this.template));
+ }
+
+ setElementState(el, card, addon, updateInstall) {
+ switch (el.getAttribute("action")) {
+ case "remove":
+ if (hasPermission(addon, "uninstall")) {
+ // Regular add-on that can be uninstalled.
+ el.disabled = false;
+ el.hidden = false;
+ document.l10n.setAttributes(el, "remove-addon-button");
+ } else if (addon.isBuiltin) {
+ // Likely the built-in themes, can't be removed, that's fine.
+ el.hidden = true;
+ } else {
+ // Likely sideloaded, mention that it can't be removed with a link.
+ el.hidden = false;
+ el.disabled = true;
+ if (!el.querySelector('[slot="support-link"]')) {
+ let link = document.createElement("a", { is: "moz-support-link" });
+ link.setAttribute("data-l10n-name", "link");
+ link.setAttribute("support-page", "cant-remove-addon");
+ link.setAttribute("slot", "support-link");
+ el.appendChild(link);
+ document.l10n.setAttributes(el, "remove-addon-disabled-button");
+ }
+ }
+ break;
+ case "report":
+ el.hidden = !isAbuseReportSupported(addon);
+ break;
+ case "install-update":
+ el.hidden = !updateInstall;
+ break;
+ case "expand":
+ el.hidden = card.expanded;
+ break;
+ case "preferences":
+ el.hidden =
+ getOptionsType(addon) !== "tab" &&
+ (getOptionsType(addon) !== "inline" || card.expanded);
+ if (!el.hidden) {
+ isAddonOptionsUIAllowed(addon).then(allowed => {
+ el.hidden = !allowed;
+ });
+ }
+ break;
+ }
+ }
+
+ update(card, addon, updateInstall) {
+ for (let el of this.items) {
+ this.setElementState(el, card, addon, updateInstall);
+ }
+
+ // Update the separators visibility based on the updated visibility
+ // of the actions in the panel-list.
+ this.updateSeparatorsVisibility();
+ }
+
+ get items() {
+ return this.querySelectorAll("panel-item");
+ }
+
+ get visibleItems() {
+ return Array.from(this.items).filter(item => !item.hidden);
+ }
+}
+customElements.define("addon-options", AddonOptions);
+
+class PluginOptions extends AddonOptions {
+ get template() {
+ return "plugin-options";
+ }
+
+ setElementState(el, card, addon) {
+ const userDisabledStates = {
+ "always-activate": false,
+ "never-activate": true,
+ };
+ const action = el.getAttribute("action");
+ if (action in userDisabledStates) {
+ let userDisabled = userDisabledStates[action];
+ el.checked = addon.userDisabled === userDisabled;
+ el.disabled = !(el.checked || hasPermission(addon, action));
+ } else {
+ super.setElementState(el, card, addon);
+ }
+ }
+}
+customElements.define("plugin-options", PluginOptions);
+
+class FiveStarRating extends HTMLElement {
+ static get observedAttributes() {
+ return ["rating"];
+ }
+
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ this.shadowRoot.append(importTemplate("five-star-rating"));
+ }
+
+ set rating(v) {
+ this.setAttribute("rating", v);
+ }
+
+ get rating() {
+ let v = parseFloat(this.getAttribute("rating"), 10);
+ if (v >= 0 && v <= 5) {
+ return v;
+ }
+ return 0;
+ }
+
+ get ratingBuckets() {
+ // 0 <= x < 0.25 = empty
+ // 0.25 <= x < 0.75 = half
+ // 0.75 <= x <= 1 = full
+ // ... et cetera, until x <= 5.
+ let { rating } = this;
+ return [0, 1, 2, 3, 4].map(ratingStart => {
+ let distanceToFull = rating - ratingStart;
+ if (distanceToFull < 0.25) {
+ return "empty";
+ }
+ if (distanceToFull < 0.75) {
+ return "half";
+ }
+ return "full";
+ });
+ }
+
+ connectedCallback() {
+ this.renderRating();
+ }
+
+ attributeChangedCallback() {
+ this.renderRating();
+ }
+
+ renderRating() {
+ let starElements = this.shadowRoot.querySelectorAll(".rating-star");
+ for (let [i, part] of this.ratingBuckets.entries()) {
+ starElements[i].setAttribute("fill", part);
+ }
+ document.l10n.setAttributes(this, "five-star-rating", {
+ rating: this.rating,
+ });
+ }
+}
+customElements.define("five-star-rating", FiveStarRating);
+
+class ProxyContextMenu extends HTMLElement {
+ openPopupAtScreen(...args) {
+ // prettier-ignore
+ const parentContextMenuPopup =
+ windowRoot.ownerGlobal.document.getElementById("contentAreaContextMenu");
+ return parentContextMenuPopup.openPopupAtScreen(...args);
+ }
+}
+customElements.define("proxy-context-menu", ProxyContextMenu);
+
+class InlineOptionsBrowser extends HTMLElement {
+ constructor() {
+ super();
+ // Force the options_ui remote browser to recompute window.mozInnerScreenX
+ // and window.mozInnerScreenY when the "addon details" page has been
+ // scrolled (See Bug 1390445 for rationale).
+ // Also force a repaint to fix an issue where the click location was
+ // getting out of sync (see bug 1548687).
+ this.updatePositionTask = new DeferredTask(() => {
+ if (this.browser && this.browser.isRemoteBrowser) {
+ // Select boxes can appear in the wrong spot after scrolling, this will
+ // clear that up. Bug 1390445.
+ this.browser.frameLoader.requestUpdatePosition();
+ }
+ }, 100);
+
+ this._embedderElement = null;
+ this._promiseDisconnected = new Promise(
+ resolve => (this._resolveDisconnected = resolve)
+ );
+ }
+
+ connectedCallback() {
+ window.addEventListener("scroll", this, true);
+ const { embedderElement } = top.browsingContext;
+ this._embedderElement = embedderElement;
+ embedderElement.addEventListener("FullZoomChange", this);
+ embedderElement.addEventListener("TextZoomChange", this);
+ }
+
+ disconnectedCallback() {
+ this._resolveDisconnected();
+ window.removeEventListener("scroll", this, true);
+ this._embedderElement?.removeEventListener("FullZoomChange", this);
+ this._embedderElement?.removeEventListener("TextZoomChange", this);
+ this._embedderElement = null;
+ }
+
+ handleEvent(e) {
+ switch (e.type) {
+ case "scroll":
+ return this.updatePositionTask.arm();
+ case "FullZoomChange":
+ case "TextZoomChange":
+ return this.maybeUpdateZoom();
+ }
+ return undefined;
+ }
+
+ maybeUpdateZoom() {
+ let bc = this.browser?.browsingContext;
+ let topBc = top.browsingContext;
+ if (!bc || !topBc) {
+ return;
+ }
+ // Use the same full-zoom as our top window.
+ bc.fullZoom = topBc.fullZoom;
+ bc.textZoom = topBc.textZoom;
+ }
+
+ setAddon(addon) {
+ this.addon = addon;
+ }
+
+ destroyBrowser() {
+ this.textContent = "";
+ }
+
+ ensureBrowserCreated() {
+ if (this.childElementCount === 0) {
+ this.render();
+ }
+ }
+
+ async render() {
+ let { addon } = this;
+ if (!addon) {
+ throw new Error("addon required to create inline options");
+ }
+
+ let browser = document.createXULElement("browser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("disableglobalhistory", "true");
+ browser.setAttribute("messagemanagergroup", "webext-browsers");
+ browser.setAttribute("id", "addon-inline-options");
+ browser.setAttribute("transparent", "true");
+ browser.setAttribute("forcemessagemanager", "true");
+ browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+
+ let { optionsURL, optionsBrowserStyle } = addon;
+ if (addon.isWebExtension) {
+ let policy = ExtensionParent.WebExtensionPolicy.getByID(addon.id);
+ browser.setAttribute(
+ "initialBrowsingContextGroupId",
+ policy.browsingContextGroupId
+ );
+ }
+
+ let readyPromise;
+ let remoteSubframes = window.docShell.QueryInterface(
+ Ci.nsILoadContext
+ ).useRemoteSubframes;
+ // For now originAttributes have no effect, which will change if the
+ // optionsURL becomes anything but moz-extension* or we start considering
+ // OA for extensions.
+ var oa = E10SUtils.predictOriginAttributes({ browser });
+ let loadRemote = E10SUtils.canLoadURIInRemoteType(
+ optionsURL,
+ remoteSubframes,
+ E10SUtils.EXTENSION_REMOTE_TYPE,
+ oa
+ );
+ if (loadRemote) {
+ browser.setAttribute("remote", "true");
+ browser.setAttribute("remoteType", E10SUtils.EXTENSION_REMOTE_TYPE);
+
+ readyPromise = promiseEvent("XULFrameLoaderCreated", browser);
+ } else {
+ readyPromise = promiseEvent("load", browser, true);
+ }
+
+ let stack = document.createXULElement("stack");
+ stack.classList.add("inline-options-stack");
+ stack.appendChild(browser);
+ this.appendChild(stack);
+ this.browser = browser;
+
+ // Force bindings to apply synchronously.
+ browser.clientTop;
+
+ await readyPromise;
+
+ this.maybeUpdateZoom();
+
+ if (!browser.messageManager) {
+ // If the browser.messageManager is undefined, the browser element has
+ // been removed from the document in the meantime (e.g. due to a rapid
+ // sequence of addon reload), return null.
+ return;
+ }
+
+ ExtensionParent.apiManager.emit("extension-browser-inserted", browser);
+
+ await new Promise(resolve => {
+ let messageListener = {
+ receiveMessage({ name, data }) {
+ if (name === "Extension:BrowserResized") {
+ browser.style.height = `${data.height}px`;
+ } else if (name === "Extension:BrowserContentLoaded") {
+ resolve();
+ }
+ },
+ };
+
+ let mm = browser.messageManager;
+
+ if (!mm) {
+ // If the browser.messageManager is undefined, the browser element has
+ // been removed from the document in the meantime (e.g. due to a rapid
+ // sequence of addon reload), return null.
+ resolve();
+ return;
+ }
+
+ mm.loadFrameScript(
+ "chrome://extensions/content/ext-browser-content.js",
+ false,
+ true
+ );
+ mm.addMessageListener("Extension:BrowserContentLoaded", messageListener);
+ mm.addMessageListener("Extension:BrowserResized", messageListener);
+
+ let browserOptions = {
+ fixedWidth: true,
+ isInline: true,
+ };
+
+ if (optionsBrowserStyle) {
+ browserOptions.stylesheets = extensionStylesheets;
+ }
+
+ mm.sendAsyncMessage("Extension:InitBrowser", browserOptions);
+
+ if (browser.isConnectedAndReady) {
+ this.fixupAndLoadURIString(optionsURL);
+ } else {
+ // browser custom element does opt-in the delayConnectedCallback
+ // behavior (see connectedCallback in the custom element definition
+ // from browser-custom-element.js) and so calling browser.loadURI
+ // would fail if the about:addons document is not yet fully loaded.
+ Promise.race([
+ promiseEvent("DOMContentLoaded", document),
+ this._promiseDisconnected,
+ ]).then(() => {
+ this.fixupAndLoadURIString(optionsURL);
+ });
+ }
+ });
+ }
+
+ fixupAndLoadURIString(uriString) {
+ if (!this.browser || !this.browser.isConnectedAndReady) {
+ throw new Error("Fail to loadURI");
+ }
+
+ this.browser.fixupAndLoadURIString(uriString, {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ }
+}
+customElements.define("inline-options-browser", InlineOptionsBrowser);
+
+class UpdateReleaseNotes extends HTMLElement {
+ connectedCallback() {
+ this.addEventListener("click", this);
+ }
+
+ disconnectedCallback() {
+ this.removeEventListener("click", this);
+ }
+
+ handleEvent(e) {
+ // We used to strip links, but ParserUtils.parseFragment() leaves them in,
+ // so just make sure we open them using the null principal in a new tab.
+ if (e.type == "click" && e.target.localName == "a" && e.target.href) {
+ e.preventDefault();
+ e.stopPropagation();
+ windowRoot.ownerGlobal.openWebLinkIn(e.target.href, "tab");
+ }
+ }
+
+ async loadForUri(uri) {
+ // Can't load the release notes without a URL to load.
+ if (!uri || !uri.spec) {
+ this.setErrorMessage();
+ this.dispatchEvent(new CustomEvent("release-notes-error"));
+ return;
+ }
+
+ // Don't try to load for the same update a second time.
+ if (this.url == uri.spec) {
+ this.dispatchEvent(new CustomEvent("release-notes-cached"));
+ return;
+ }
+
+ // Store the URL to skip the network if loaded again.
+ this.url = uri.spec;
+
+ // Set the loading message before hitting the network.
+ this.setLoadingMessage();
+ this.dispatchEvent(new CustomEvent("release-notes-loading"));
+
+ try {
+ // loadReleaseNotes will fetch and sanitize the release notes.
+ let fragment = await loadReleaseNotes(uri);
+ this.textContent = "";
+ this.appendChild(fragment);
+ this.dispatchEvent(new CustomEvent("release-notes-loaded"));
+ } catch (e) {
+ this.setErrorMessage();
+ this.dispatchEvent(new CustomEvent("release-notes-error"));
+ }
+ }
+
+ setMessage(id) {
+ this.textContent = "";
+ let message = document.createElement("p");
+ document.l10n.setAttributes(message, id);
+ this.appendChild(message);
+ }
+
+ setLoadingMessage() {
+ this.setMessage("release-notes-loading");
+ }
+
+ setErrorMessage() {
+ this.setMessage("release-notes-error");
+ }
+}
+customElements.define("update-release-notes", UpdateReleaseNotes);
+
+class AddonPermissionsList extends HTMLElement {
+ setAddon(addon) {
+ this.addon = addon;
+ this.render();
+ }
+
+ async render() {
+ let empty = { origins: [], permissions: [] };
+ let requiredPerms = { ...(this.addon.userPermissions ?? empty) };
+ let optionalPerms = { ...(this.addon.optionalPermissions ?? empty) };
+ let grantedPerms = await ExtensionPermissions.get(this.addon.id);
+
+ if (manifestV3enabled) {
+ // If optional permissions include <all_urls>, extension can request and
+ // be granted permission for individual sites not listed in the manifest.
+ // Include them as well in the optional origins list.
+ optionalPerms.origins = [
+ ...optionalPerms.origins,
+ ...grantedPerms.origins.filter(o => !requiredPerms.origins.includes(o)),
+ ];
+ }
+
+ let permissions = Extension.formatPermissionStrings(
+ {
+ permissions: requiredPerms,
+ optionalPermissions: optionalPerms,
+ },
+ { buildOptionalOrigins: manifestV3enabled }
+ );
+ let optionalEntries = [
+ ...Object.entries(permissions.optionalPermissions),
+ ...Object.entries(permissions.optionalOrigins),
+ ];
+
+ this.textContent = "";
+ let frag = importTemplate("addon-permissions-list");
+
+ if (permissions.msgs.length) {
+ let section = frag.querySelector(".addon-permissions-required");
+ section.hidden = false;
+ let list = section.querySelector(".addon-permissions-list");
+
+ for (let msg of permissions.msgs) {
+ let item = document.createElement("li");
+ item.classList.add("permission-info", "permission-checked");
+ item.appendChild(document.createTextNode(msg));
+ list.appendChild(item);
+ }
+ }
+
+ if (optionalEntries.length) {
+ let section = frag.querySelector(".addon-permissions-optional");
+ section.hidden = false;
+ let list = section.querySelector(".addon-permissions-list");
+
+ for (let id = 0; id < optionalEntries.length; id++) {
+ let [perm, msg] = optionalEntries[id];
+
+ let type = "permission";
+ if (permissions.optionalOrigins[perm]) {
+ type = "origin";
+ }
+ let item = document.createElement("li");
+ item.classList.add("permission-info");
+
+ let toggle = document.createElement("moz-toggle");
+ toggle.setAttribute("label", msg);
+ toggle.id = `permission-${id}`;
+ toggle.setAttribute("permission-type", type);
+
+ let checked =
+ grantedPerms.permissions.includes(perm) ||
+ grantedPerms.origins.includes(perm);
+
+ // If this is one of the "all sites" permissions
+ if (Extension.isAllSitesPermission(perm)) {
+ // mark it as checked if ANY of the "all sites" permission is granted.
+ checked = await AddonCard.optionalAllSitesGranted(this.addon.id);
+ toggle.toggleAttribute("permission-all-sites", true);
+ }
+
+ toggle.pressed = checked;
+ item.classList.toggle("permission-checked", checked);
+
+ toggle.setAttribute("permission-key", perm);
+ toggle.setAttribute("action", "toggle-permission");
+ item.appendChild(toggle);
+ list.appendChild(item);
+ }
+ }
+ if (!permissions.msgs.length && !optionalEntries.length) {
+ let row = frag.querySelector(".addon-permissions-empty");
+ row.hidden = false;
+ }
+
+ this.appendChild(frag);
+ }
+}
+customElements.define("addon-permissions-list", AddonPermissionsList);
+
+class AddonSitePermissionsList extends HTMLElement {
+ setAddon(addon) {
+ this.addon = addon;
+ this.render();
+ }
+
+ async render() {
+ let permissions = Extension.formatPermissionStrings({
+ sitePermissions: this.addon.sitePermissions,
+ siteOrigin: this.addon.siteOrigin,
+ });
+
+ this.textContent = "";
+ let frag = importTemplate("addon-sitepermissions-list");
+
+ if (permissions.msgs.length) {
+ let section = frag.querySelector(".addon-permissions-required");
+ section.hidden = false;
+ let list = section.querySelector(".addon-permissions-list");
+ let header = section.querySelector(".permission-header");
+ document.l10n.setAttributes(header, "addon-sitepermissions-required", {
+ hostname: new URL(this.addon.siteOrigin).hostname,
+ });
+
+ for (let msg of permissions.msgs) {
+ let item = document.createElement("li");
+ item.classList.add("permission-info", "permission-checked");
+ item.appendChild(document.createTextNode(msg));
+ list.appendChild(item);
+ }
+ }
+
+ this.appendChild(frag);
+ }
+}
+customElements.define("addon-sitepermissions-list", AddonSitePermissionsList);
+
+class AddonDetails extends HTMLElement {
+ connectedCallback() {
+ if (!this.children.length) {
+ this.render();
+ }
+ this.deck.addEventListener("view-changed", this);
+ this.descriptionShowMoreButton.addEventListener("click", this);
+ }
+
+ disconnectedCallback() {
+ this.inlineOptions.destroyBrowser();
+ this.deck.removeEventListener("view-changed", this);
+ this.descriptionShowMoreButton.removeEventListener("click", this);
+ }
+
+ handleEvent(e) {
+ if (e.type == "view-changed" && e.target == this.deck) {
+ switch (this.deck.selectedViewName) {
+ case "release-notes":
+ let releaseNotes = this.querySelector("update-release-notes");
+ let uri = this.releaseNotesUri;
+ if (uri) {
+ releaseNotes.loadForUri(uri);
+ }
+ break;
+ case "preferences":
+ if (getOptionsType(this.addon) == "inline") {
+ this.inlineOptions.ensureBrowserCreated();
+ }
+ break;
+ }
+
+ // When a details view is rendered again, the default details view is
+ // unconditionally shown. So if any other tab is selected, do not save
+ // the current scroll offset, but start at the top of the page instead.
+ ScrollOffsets.canRestore = this.deck.selectedViewName === "details";
+ } else if (
+ e.type == "click" &&
+ e.target == this.descriptionShowMoreButton
+ ) {
+ this.toggleDescription();
+ }
+ }
+
+ onInstalled() {
+ let policy = WebExtensionPolicy.getByID(this.addon.id);
+ let extension = policy && policy.extension;
+ if (extension && extension.startupReason === "ADDON_UPGRADE") {
+ // Ensure the options browser is recreated when a new version starts.
+ this.extensionShutdown();
+ this.extensionStartup();
+ }
+ }
+
+ onDisabled(addon) {
+ this.extensionShutdown();
+ }
+
+ onEnabled(addon) {
+ this.extensionStartup();
+ }
+
+ extensionShutdown() {
+ this.inlineOptions.destroyBrowser();
+ }
+
+ extensionStartup() {
+ if (this.deck.selectedViewName === "preferences") {
+ this.inlineOptions.ensureBrowserCreated();
+ }
+ }
+
+ toggleDescription() {
+ this.descriptionCollapsed = !this.descriptionCollapsed;
+
+ this.descriptionWrapper.classList.toggle(
+ "addon-detail-description-collapse",
+ this.descriptionCollapsed
+ );
+
+ this.descriptionShowMoreButton.hidden = false;
+ document.l10n.setAttributes(
+ this.descriptionShowMoreButton,
+ this.descriptionCollapsed
+ ? "addon-detail-description-expand"
+ : "addon-detail-description-collapse"
+ );
+ }
+
+ get releaseNotesUri() {
+ let { releaseNotesURI } = getUpdateInstall(this.addon) || this.addon;
+ return releaseNotesURI;
+ }
+
+ setAddon(addon) {
+ this.addon = addon;
+ }
+
+ update() {
+ let { addon } = this;
+
+ // Hide tab buttons that won't have any content.
+ let getButtonByName = name =>
+ this.tabGroup.querySelector(`[name="${name}"]`);
+ let permsBtn = getButtonByName("permissions");
+ permsBtn.hidden = addon.type != "extension";
+ let notesBtn = getButtonByName("release-notes");
+ notesBtn.hidden = !this.releaseNotesUri;
+ let prefsBtn = getButtonByName("preferences");
+ prefsBtn.hidden = getOptionsType(addon) !== "inline";
+ if (prefsBtn.hidden) {
+ if (this.deck.selectedViewName === "preferences") {
+ this.deck.selectedViewName = "details";
+ }
+ } else {
+ isAddonOptionsUIAllowed(addon).then(allowed => {
+ prefsBtn.hidden = !allowed;
+ });
+ }
+
+ // Hide the tab group if "details" is the only visible button.
+ let tabGroupButtons = this.tabGroup.querySelectorAll(".tab-button");
+ this.tabGroup.hidden = Array.from(tabGroupButtons).every(button => {
+ return button.name == "details" || button.hidden;
+ });
+
+ // Show the update check button if necessary. The button might not exist if
+ // the add-on doesn't support updates.
+ let updateButton = this.querySelector('[action="update-check"]');
+ if (updateButton) {
+ updateButton.hidden =
+ this.addon.updateInstall || AddonManager.shouldAutoUpdate(this.addon);
+ }
+
+ // Set the value for auto updates.
+ let inputs = this.querySelectorAll(".addon-detail-row-updates input");
+ for (let input of inputs) {
+ input.checked = input.value == addon.applyBackgroundUpdates;
+ }
+ }
+
+ renderDescription(addon) {
+ this.descriptionWrapper = this.querySelector(
+ ".addon-detail-description-wrapper"
+ );
+ this.descriptionContents = this.querySelector(".addon-detail-description");
+ this.descriptionShowMoreButton = this.querySelector(
+ ".addon-detail-description-toggle"
+ );
+
+ if (addon.getFullDescription) {
+ this.descriptionContents.appendChild(addon.getFullDescription(document));
+ } else if (addon.fullDescription) {
+ this.descriptionContents.appendChild(nl2br(addon.fullDescription));
+ }
+
+ this.descriptionCollapsed = false;
+
+ requestAnimationFrame(() => {
+ const remSize = parseFloat(
+ getComputedStyle(document.documentElement).fontSize
+ );
+ const { height } = this.descriptionContents.getBoundingClientRect();
+
+ // collapse description if there are too many lines,i.e. height > (20 rem)
+ if (height > 20 * remSize) {
+ this.toggleDescription();
+ }
+ });
+ }
+
+ async render() {
+ let { addon } = this;
+ if (!addon) {
+ throw new Error("addon-details must be initialized by setAddon");
+ }
+
+ this.textContent = "";
+ this.appendChild(importTemplate("addon-details"));
+
+ this.deck = this.querySelector("named-deck");
+ this.tabGroup = this.querySelector(".tab-group");
+
+ // Set the add-on for the permissions section.
+ this.permissionsList = this.querySelector("addon-permissions-list");
+ this.permissionsList.setAddon(addon);
+
+ // Set the add-on for the sitepermissions section.
+ this.sitePermissionsList = this.querySelector("addon-sitepermissions-list");
+ if (addon.type == "sitepermission") {
+ this.sitePermissionsList.setAddon(addon);
+ }
+ this.querySelector(".addon-detail-sitepermissions").hidden =
+ addon.type !== "sitepermission";
+
+ // Set the add-on for the preferences section.
+ this.inlineOptions = this.querySelector("inline-options-browser");
+ this.inlineOptions.setAddon(addon);
+
+ // Full description.
+ this.renderDescription(addon);
+ this.querySelector(".addon-detail-contribute").hidden =
+ !addon.contributionURL;
+ this.querySelector(".addon-detail-row-updates").hidden = !hasPermission(
+ addon,
+ "upgrade"
+ );
+
+ if (addon.type != "extension") {
+ // Don't show any private browsing related section for non-extension
+ // addon types, because not relevant or they are either always allowed
+ // (e.g. static themes).
+ //
+ // TODO(Bug 1799090): introduce ad-hoc UI for "sitepermission" addon type.
+ } else if (addon.incognito == "not_allowed") {
+ let pbRowNotAllowed = this.querySelector(
+ ".addon-detail-row-private-browsing-disallowed"
+ );
+ pbRowNotAllowed.hidden = false;
+ pbRowNotAllowed.nextElementSibling.hidden = false;
+ } else if (!hasPermission(addon, "change-privatebrowsing")) {
+ let pbRowRequired = this.querySelector(
+ ".addon-detail-row-private-browsing-required"
+ );
+ pbRowRequired.hidden = false;
+ pbRowRequired.nextElementSibling.hidden = false;
+ } else {
+ let pbRow = this.querySelector(".addon-detail-row-private-browsing");
+ pbRow.hidden = false;
+ pbRow.nextElementSibling.hidden = false;
+ let isAllowed = await isAllowedInPrivateBrowsing(addon);
+ pbRow.querySelector(`[value="${isAllowed ? 1 : 0}"]`).checked = true;
+ }
+
+ // Author.
+ let creatorRow = this.querySelector(".addon-detail-row-author");
+ if (addon.creator) {
+ let link = creatorRow.querySelector("a");
+ link.hidden = !addon.creator.url;
+ if (link.hidden) {
+ creatorRow.appendChild(new Text(addon.creator.name));
+ } else {
+ link.href = formatUTMParams(
+ "addons-manager-user-profile-link",
+ addon.creator.url
+ );
+ link.target = "_blank";
+ link.textContent = addon.creator.name;
+ }
+ } else {
+ creatorRow.hidden = true;
+ }
+
+ // Version. Don't show a version for LWTs.
+ let version = this.querySelector(".addon-detail-row-version");
+ if (addon.version && !/@personas\.mozilla\.org/.test(addon.id)) {
+ version.appendChild(new Text(addon.version));
+ } else {
+ version.hidden = true;
+ }
+
+ // Last updated.
+ let updateDate = this.querySelector(".addon-detail-row-lastUpdated");
+ if (addon.updateDate) {
+ let lastUpdated = addon.updateDate.toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ updateDate.appendChild(new Text(lastUpdated));
+ } else {
+ updateDate.hidden = true;
+ }
+
+ // Homepage.
+ let homepageRow = this.querySelector(".addon-detail-row-homepage");
+ if (addon.homepageURL) {
+ let homepageURL = homepageRow.querySelector("a");
+ homepageURL.href = addon.homepageURL;
+ homepageURL.textContent = addon.homepageURL;
+ } else {
+ homepageRow.hidden = true;
+ }
+
+ // Rating.
+ let ratingRow = this.querySelector(".addon-detail-row-rating");
+ if (addon.averageRating) {
+ ratingRow.querySelector("five-star-rating").rating = addon.averageRating;
+ let reviews = ratingRow.querySelector("a");
+ reviews.href = formatUTMParams(
+ "addons-manager-reviews-link",
+ addon.reviewURL
+ );
+ document.l10n.setAttributes(reviews, "addon-detail-reviews-link", {
+ numberOfReviews: addon.reviewCount,
+ });
+ } else {
+ ratingRow.hidden = true;
+ }
+
+ this.update();
+ }
+
+ showPrefs() {
+ if (getOptionsType(this.addon) == "inline") {
+ this.deck.selectedViewName = "preferences";
+ this.inlineOptions.ensureBrowserCreated();
+ }
+ }
+}
+customElements.define("addon-details", AddonDetails);
+
+/**
+ * A card component for managing an add-on. It should be initialized by setting
+ * the add-on with `setAddon()` before being connected to the document.
+ *
+ * let card = document.createElement("addon-card");
+ * card.setAddon(addon);
+ * document.body.appendChild(card);
+ */
+class AddonCard extends HTMLElement {
+ connectedCallback() {
+ // If we've already rendered we can just update, otherwise render.
+ if (this.children.length) {
+ this.update();
+ } else {
+ this.render();
+ }
+ this.registerListeners();
+ }
+
+ disconnectedCallback() {
+ this.removeListeners();
+ }
+
+ get expanded() {
+ return this.hasAttribute("expanded");
+ }
+
+ set expanded(val) {
+ if (val) {
+ this.setAttribute("expanded", "true");
+ } else {
+ this.removeAttribute("expanded");
+ }
+ }
+
+ get updateInstall() {
+ return this._updateInstall;
+ }
+
+ set updateInstall(install) {
+ this._updateInstall = install;
+ if (this.children.length) {
+ this.update();
+ }
+ }
+
+ get reloading() {
+ return this.hasAttribute("reloading");
+ }
+
+ set reloading(val) {
+ this.toggleAttribute("reloading", val);
+ }
+
+ /**
+ * Set the add-on for this card. The card will be populated based on the
+ * add-on when it is connected to the DOM.
+ *
+ * @param {AddonWrapper} addon The add-on to use.
+ */
+ setAddon(addon) {
+ this.addon = addon;
+ let install = getUpdateInstall(addon);
+ if (
+ install &&
+ (isInState(install, "available") || isInState(install, "postponed"))
+ ) {
+ this.updateInstall = install;
+ } else {
+ this.updateInstall = null;
+ }
+ if (this.children.length) {
+ this.render();
+ }
+ }
+
+ async setAddonPermission(permission, type, action) {
+ let { addon } = this;
+ let origins = [],
+ permissions = [];
+ if (!["add", "remove"].includes(action)) {
+ throw new Error("invalid action for permission change");
+ }
+ if (type == "permission") {
+ if (
+ action == "add" &&
+ !addon.optionalPermissions.permissions.includes(permission)
+ ) {
+ throw new Error("permission missing from manifest");
+ }
+ permissions = [permission];
+ } else if (type == "origin") {
+ if (action === "add") {
+ let { origins } = addon.optionalPermissions;
+ let patternSet = new MatchPatternSet(origins, { ignorePath: true });
+ if (!patternSet.subsumes(new MatchPattern(permission))) {
+ throw new Error("origin missing from manifest");
+ }
+ }
+ origins = [permission];
+
+ // If this is one of the "all sites" permissions
+ if (Extension.isAllSitesPermission(permission)) {
+ // Grant/revoke ALL "all sites" optional permissions from the manifest.
+ origins = addon.optionalPermissions.origins.filter(perm =>
+ Extension.isAllSitesPermission(perm)
+ );
+ }
+ } else {
+ throw new Error("unknown permission type changed");
+ }
+ let policy = WebExtensionPolicy.getByID(addon.id);
+ ExtensionPermissions[action](
+ addon.id,
+ { origins, permissions },
+ policy?.extension
+ );
+ }
+
+ async handleEvent(e) {
+ let { addon } = this;
+ let action = e.target.getAttribute("action");
+
+ if (e.type == "click") {
+ switch (action) {
+ case "toggle-disabled":
+ // Keep the checked state the same until the add-on's state changes.
+ e.target.checked = !addon.userDisabled;
+ if (addon.userDisabled) {
+ if (shouldShowPermissionsPrompt(addon)) {
+ await showPermissionsPrompt(addon);
+ } else {
+ await addon.enable();
+ }
+ } else {
+ await addon.disable();
+ }
+ break;
+ case "always-activate":
+ addon.userDisabled = false;
+ break;
+ case "never-activate":
+ addon.userDisabled = true;
+ break;
+ case "update-check": {
+ let { found } = await checkForUpdate(addon);
+ if (!found) {
+ this.sendEvent("no-update");
+ }
+ break;
+ }
+ case "install-postponed": {
+ const { updateInstall } = this;
+ if (updateInstall && isInState(updateInstall, "postponed")) {
+ updateInstall.continuePostponedInstall();
+ }
+ break;
+ }
+ case "install-update":
+ // Make sure that an update handler is attached to the install object
+ // before starting the update installation (otherwise the user would
+ // not be prompted for the new permissions requested if necessary),
+ // and also make sure that a prompt handler attached from a closed
+ // about:addons tab is replaced by the one attached by the currently
+ // active about:addons tab.
+ attachUpdateHandler(this.updateInstall);
+ this.updateInstall.install().then(
+ () => {
+ detachUpdateHandler(this.updateInstall);
+ // The card will update with the new add-on when it gets
+ // installed.
+ this.sendEvent("update-installed");
+ },
+ () => {
+ detachUpdateHandler(this.updateInstall);
+ // Update our state if the install is cancelled.
+ this.update();
+ this.sendEvent("update-cancelled");
+ }
+ );
+ // Clear the install since it will be removed from the global list of
+ // available updates (whether it succeeds or fails).
+ this.updateInstall = null;
+ break;
+ case "contribute":
+ windowRoot.ownerGlobal.openWebLinkIn(addon.contributionURL, "tab");
+ break;
+ case "preferences":
+ if (getOptionsType(addon) == "tab") {
+ openOptionsInTab(addon.optionsURL);
+ } else if (getOptionsType(addon) == "inline") {
+ gViewController.loadView(`detail/${this.addon.id}/preferences`);
+ }
+ break;
+ case "remove":
+ {
+ this.panel.hide();
+ if (!hasPermission(addon, "uninstall")) {
+ this.sendEvent("remove-disabled");
+ return;
+ }
+ let { BrowserAddonUI } = windowRoot.ownerGlobal;
+ let { remove, report } = await BrowserAddonUI.promptRemoveExtension(
+ addon
+ );
+ if (remove) {
+ await addon.uninstall(true);
+ this.sendEvent("remove");
+ if (report) {
+ openAbuseReport({
+ addonId: addon.id,
+ reportEntryPoint: "uninstall",
+ });
+ }
+ } else {
+ this.sendEvent("remove-cancelled");
+ }
+ }
+ break;
+ case "expand":
+ gViewController.loadView(`detail/${this.addon.id}`);
+ break;
+ case "more-options":
+ // Open panel on click from the keyboard.
+ if (e.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
+ this.panel.toggle(e);
+ }
+ break;
+ case "report":
+ this.panel.hide();
+ openAbuseReport({ addonId: addon.id, reportEntryPoint: "menu" });
+ break;
+ case "link":
+ if (e.target.getAttribute("url")) {
+ windowRoot.ownerGlobal.openWebLinkIn(
+ e.target.getAttribute("url"),
+ "tab"
+ );
+ }
+ break;
+ default:
+ // Handle a click on the card itself.
+ if (
+ !this.expanded &&
+ (e.target === this.addonNameEl || !e.target.closest("a"))
+ ) {
+ e.preventDefault();
+ gViewController.loadView(`detail/${this.addon.id}`);
+ }
+ break;
+ }
+ } else if (e.type == "toggle" && action == "toggle-permission") {
+ let permission = e.target.getAttribute("permission-key");
+ let type = e.target.getAttribute("permission-type");
+ let fname = e.target.pressed ? "add" : "remove";
+ this.setAddonPermission(permission, type, fname);
+ } else if (e.type == "change") {
+ let { name } = e.target;
+ if (name == "autoupdate") {
+ addon.applyBackgroundUpdates = e.target.value;
+ } else if (name == "private-browsing") {
+ let policy = WebExtensionPolicy.getByID(addon.id);
+ let extension = policy && policy.extension;
+
+ if (e.target.value == "1") {
+ await ExtensionPermissions.add(
+ addon.id,
+ PRIVATE_BROWSING_PERMS,
+ extension
+ );
+ } else {
+ await ExtensionPermissions.remove(
+ addon.id,
+ PRIVATE_BROWSING_PERMS,
+ extension
+ );
+ }
+ // Reload the extension if it is already enabled. This ensures any
+ // change on the private browsing permission is properly handled.
+ if (addon.isActive) {
+ this.reloading = true;
+ // Reloading will trigger an enable and update the card.
+ addon.reload();
+ } else {
+ // Update the card if the add-on isn't active.
+ this.update();
+ }
+ }
+ } else if (e.type == "mousedown") {
+ // Open panel on mousedown when the mouse is used.
+ if (action == "more-options" && e.button == 0) {
+ this.panel.toggle(e);
+ }
+ } else if (e.type === "shown" || e.type === "hidden") {
+ let panelOpen = e.type === "shown";
+ // The card will be dimmed if it's disabled, but when the panel is open
+ // that should be reverted so the menu items can be easily read.
+ this.toggleAttribute("panelopen", panelOpen);
+ this.optionsButton.setAttribute("aria-expanded", panelOpen);
+ }
+ }
+
+ get panel() {
+ return this.card.querySelector("panel-list");
+ }
+
+ get postponedMessageBar() {
+ return this.card.querySelector(".update-postponed-bar");
+ }
+
+ registerListeners() {
+ this.addEventListener("change", this);
+ this.addEventListener("click", this);
+ this.addEventListener("mousedown", this);
+ this.addEventListener("toggle", this);
+ this.panel.addEventListener("shown", this);
+ this.panel.addEventListener("hidden", this);
+ }
+
+ removeListeners() {
+ this.removeEventListener("change", this);
+ this.removeEventListener("click", this);
+ this.removeEventListener("mousedown", this);
+ this.removeEventListener("toggle", this);
+ this.panel.removeEventListener("shown", this);
+ this.panel.removeEventListener("hidden", this);
+ }
+
+ /**
+ * Update the card's contents based on the previously set add-on. This should
+ * be called if there has been a change to the add-on.
+ */
+ update() {
+ let { addon, card } = this;
+
+ card.setAttribute("active", addon.isActive);
+
+ // Set the icon or theme preview.
+ let iconEl = card.querySelector(".addon-icon");
+ let preview = card.querySelector(".card-heading-image");
+ if (addon.type == "theme") {
+ iconEl.hidden = true;
+ let screenshotUrl = getScreenshotUrlForAddon(addon);
+ if (screenshotUrl) {
+ preview.src = screenshotUrl;
+ }
+ preview.hidden = !screenshotUrl;
+ } else {
+ preview.hidden = true;
+ iconEl.hidden = false;
+ if (addon.type == "plugin") {
+ iconEl.src = PLUGIN_ICON_URL;
+ } else {
+ iconEl.src =
+ AddonManager.getPreferredIconURL(addon, 32, window) ||
+ EXTENSION_ICON_URL;
+ }
+ }
+
+ // Update the name.
+ let name = this.addonNameEl;
+ let setDisabledStyle = !(addon.isActive || addon.type === "theme");
+ if (!setDisabledStyle) {
+ name.textContent = addon.name;
+ name.removeAttribute("data-l10n-id");
+ } else {
+ document.l10n.setAttributes(name, "addon-name-disabled", {
+ name: addon.name,
+ });
+ }
+ name.title = `${addon.name} ${addon.version}`;
+
+ let toggleDisabledButton = card.querySelector('[action="toggle-disabled"]');
+ if (toggleDisabledButton) {
+ let toggleDisabledAction = addon.userDisabled ? "enable" : "disable";
+ toggleDisabledButton.hidden = !hasPermission(addon, toggleDisabledAction);
+ if (addon.type === "theme") {
+ document.l10n.setAttributes(
+ toggleDisabledButton,
+ `${toggleDisabledAction}-addon-button`
+ );
+ } else if (
+ addon.type === "extension" ||
+ addon.type === "sitepermission"
+ ) {
+ toggleDisabledButton.pressed = !addon.userDisabled;
+ }
+ }
+
+ // Set the items in the more options menu.
+ this.options.update(this, addon, this.updateInstall);
+
+ // Badge the more options button if there's an update.
+ let moreOptionsButton = card.querySelector(".more-options-button");
+ moreOptionsButton.classList.toggle(
+ "more-options-button-badged",
+ !!(this.updateInstall && isInState(this.updateInstall, "available"))
+ );
+
+ // Postponed update addon card message bar.
+ const hasPostponedInstall =
+ this.updateInstall && isInState(this.updateInstall, "postponed");
+ this.postponedMessageBar.hidden = !hasPostponedInstall;
+
+ // Hide the more options button if it's empty.
+ moreOptionsButton.hidden = this.options.visibleItems.length === 0;
+
+ // Ensure all badges are initially hidden.
+ for (let node of card.querySelectorAll(".addon-badge")) {
+ node.hidden = true;
+ }
+
+ // Set the private browsing badge visibility.
+ // TODO: We don't show the badge for SitePermsAddon for now, but this should
+ // be handled in Bug 1799090.
+ if (addon.incognito != "not_allowed" && addon.type == "extension") {
+ // Keep update synchronous, the badge can appear later.
+ isAllowedInPrivateBrowsing(addon).then(isAllowed => {
+ card.querySelector(".addon-badge-private-browsing-allowed").hidden =
+ !isAllowed;
+ });
+ }
+
+ // Show the recommended badges if needed.
+ // Plugins don't have recommendationStates, so ensure a default.
+ let states = addon.recommendationStates || [];
+ for (let badgeName of states) {
+ let badge = card.querySelector(`.addon-badge-${badgeName}`);
+ if (badge) {
+ badge.hidden = false;
+ }
+ }
+
+ // Update description.
+ card.querySelector(".addon-description").textContent = addon.description;
+
+ this.updateMessage();
+
+ // Update the details if they're shown.
+ if (this.details) {
+ this.details.update();
+ }
+
+ this.sendEvent("update");
+ }
+
+ async updateMessage() {
+ const messageBar = this.card.querySelector(".addon-card-message");
+
+ const {
+ linkUrl,
+ messageId,
+ messageArgs,
+ type = "",
+ } = await getAddonMessageInfo(this.addon);
+
+ if (messageId) {
+ document.l10n.pauseObserving();
+ document.l10n.setAttributes(
+ messageBar.querySelector("span"),
+ messageId,
+ messageArgs
+ );
+
+ const link = messageBar.querySelector("button");
+ if (linkUrl) {
+ document.l10n.setAttributes(link, `${messageId}-link`);
+ link.setAttribute("url", linkUrl);
+ link.hidden = false;
+ } else {
+ link.hidden = true;
+ }
+
+ document.l10n.resumeObserving();
+ await document.l10n.translateFragment(messageBar);
+ messageBar.setAttribute("type", type);
+ messageBar.hidden = false;
+ } else {
+ messageBar.hidden = true;
+ }
+ }
+
+ showPrefs() {
+ this.details.showPrefs();
+ }
+
+ expand() {
+ if (!this.children.length) {
+ this.expanded = true;
+ } else {
+ throw new Error("expand() is only supported before render()");
+ }
+ }
+
+ render() {
+ this.textContent = "";
+
+ let { addon } = this;
+ if (!addon) {
+ throw new Error("addon-card must be initialized with setAddon()");
+ }
+
+ this.setAttribute("addon-id", addon.id);
+
+ this.card = importTemplate("card").firstElementChild;
+ let headingId = ExtensionCommon.makeWidgetId(`${addon.id}-heading`);
+ this.card.setAttribute("aria-labelledby", headingId);
+
+ // Remove the toggle-disabled button(s) based on type.
+ if (addon.type != "theme") {
+ this.card.querySelector(".theme-enable-button").remove();
+ }
+ if (addon.type != "extension" && addon.type != "sitepermission") {
+ this.card.querySelector(".extension-enable-button").remove();
+ }
+
+ let nameContainer = this.card.querySelector(".addon-name-container");
+ let headingLevel = this.expanded ? "h1" : "h3";
+ let nameHeading = document.createElement(headingLevel);
+ nameHeading.classList.add("addon-name");
+ nameHeading.id = headingId;
+ if (!this.expanded) {
+ let name = document.createElement("a");
+ name.classList.add("addon-name-link");
+ name.href = `addons://detail/${addon.id}`;
+ nameHeading.appendChild(name);
+ this.addonNameEl = name;
+ } else {
+ this.addonNameEl = nameHeading;
+ }
+ nameContainer.prepend(nameHeading);
+
+ let panelType = addon.type == "plugin" ? "plugin-options" : "addon-options";
+ this.options = document.createElement(panelType);
+ this.options.render();
+ this.card.appendChild(this.options);
+ this.optionsButton = this.card.querySelector(".more-options-button");
+
+ // Set the contents.
+ this.update();
+
+ let doneRenderPromise = Promise.resolve();
+ if (this.expanded) {
+ if (!this.details) {
+ this.details = document.createElement("addon-details");
+ }
+ this.details.setAddon(this.addon);
+ doneRenderPromise = this.details.render();
+
+ // If we're re-rendering we still need to append the details since the
+ // entire card was emptied at the beginning of the render.
+ this.card.appendChild(this.details);
+ }
+
+ this.appendChild(this.card);
+
+ if (this.expanded) {
+ requestAnimationFrame(() => this.optionsButton.focus());
+ }
+
+ // Return the promise of details rendering to wait on in DetailView.
+ return doneRenderPromise;
+ }
+
+ sendEvent(name, detail) {
+ this.dispatchEvent(new CustomEvent(name, { detail }));
+ }
+
+ /**
+ * AddonManager listener events.
+ */
+
+ onNewInstall(install) {
+ this.updateInstall = install;
+ this.sendEvent("update-found");
+ }
+
+ onInstallEnded(install) {
+ this.setAddon(install.addon);
+ }
+
+ onInstallPostponed(install) {
+ this.updateInstall = install;
+ this.sendEvent("update-postponed");
+ }
+
+ onDisabled(addon) {
+ if (!this.reloading) {
+ this.update();
+ }
+ }
+
+ onEnabled(addon) {
+ this.reloading = false;
+ this.update();
+ }
+
+ onInstalled(addon) {
+ // When a temporary addon is reloaded, onInstalled is triggered instead of
+ // onEnabled.
+ this.reloading = false;
+ this.update();
+ }
+
+ onUninstalling() {
+ // Dispatch a remove event, the DetailView is listening for this to get us
+ // back to the list view when the current add-on is removed.
+ this.sendEvent("remove");
+ }
+
+ onUpdateModeChanged() {
+ this.update();
+ }
+
+ onPropertyChanged(addon, changed) {
+ if (this.details && changed.includes("applyBackgroundUpdates")) {
+ this.details.update();
+ } else if (addon.type == "plugin" && changed.includes("userDisabled")) {
+ this.update();
+ }
+ }
+
+ /* Extension Permission change listener */
+ async onChangePermissions(data) {
+ let perms = data.added || data.removed;
+ let hasAllSites = false;
+ for (let permission of perms.permissions.concat(perms.origins)) {
+ if (Extension.isAllSitesPermission(permission)) {
+ hasAllSites = true;
+ continue;
+ }
+ let target = document.querySelector(`[permission-key="${permission}"]`);
+ let checked = !data.removed;
+ if (target) {
+ target.closest("li").classList.toggle("permission-checked", checked);
+ target.pressed = checked;
+ }
+ }
+ if (hasAllSites) {
+ // special-case for finding the all-sites target by attribute.
+ let target = document.querySelector("[permission-all-sites]");
+ let checked = await AddonCard.optionalAllSitesGranted(this.addon.id);
+ target.closest("li").classList.toggle("permission-checked", checked);
+ target.pressed = checked;
+ }
+ }
+
+ // Only covers optional_permissions in MV2 and all host permissions in MV3.
+ static async optionalAllSitesGranted(addonId) {
+ let granted = await ExtensionPermissions.get(addonId);
+ return granted.origins.some(perm => Extension.isAllSitesPermission(perm));
+ }
+}
+customElements.define("addon-card", AddonCard);
+
+/**
+ * A child element of `<recommended-addon-list>`. It should be initialized
+ * by calling `setDiscoAddon()` first. Call `setAddon(addon)` if it has been
+ * installed, and call `setAddon(null)` upon uninstall.
+ *
+ * let discoAddon = new DiscoAddonWrapper({ ... });
+ * let card = document.createElement("recommended-addon-card");
+ * card.setDiscoAddon(discoAddon);
+ * document.body.appendChild(card);
+ *
+ * AddonManager.getAddonsByID(discoAddon.id)
+ * .then(addon => card.setAddon(addon));
+ */
+class RecommendedAddonCard extends HTMLElement {
+ /**
+ * @param {DiscoAddonWrapper} addon
+ * The details of the add-on that should be rendered in the card.
+ */
+ setDiscoAddon(addon) {
+ this.addonId = addon.id;
+
+ // Save the information so we can install.
+ this.discoAddon = addon;
+
+ let card = importTemplate("card").firstElementChild;
+ let heading = card.querySelector(".addon-name-container");
+ heading.textContent = "";
+ heading.append(importTemplate("addon-name-container-in-disco-card"));
+
+ this.setCardContent(card, addon);
+ if (addon.type != "theme") {
+ card
+ .querySelector(".addon-description")
+ .append(importTemplate("addon-description-in-disco-card"));
+ this.setCardDescription(card, addon);
+ }
+ this.registerButtons(card, addon);
+
+ this.textContent = "";
+ this.append(card);
+
+ // We initially assume that the add-on is not installed.
+ this.setAddon(null);
+ }
+
+ /**
+ * Fills in all static parts of the card.
+ *
+ * @param {HTMLElement} card
+ * The primary content of this card.
+ * @param {DiscoAddonWrapper} addon
+ */
+ setCardContent(card, addon) {
+ // Set the icon.
+ if (addon.type == "theme") {
+ card.querySelector(".addon-icon").hidden = true;
+ } else {
+ card.querySelector(".addon-icon").src = AddonManager.getPreferredIconURL(
+ addon,
+ 32,
+ window
+ );
+ }
+
+ // Set the theme preview.
+ let preview = card.querySelector(".card-heading-image");
+ if (addon.type == "theme") {
+ let screenshotUrl = getScreenshotUrlForAddon(addon);
+ if (screenshotUrl) {
+ preview.src = screenshotUrl;
+ preview.hidden = false;
+ }
+ } else {
+ preview.hidden = true;
+ }
+
+ // Set the name.
+ card.querySelector(".disco-addon-name").textContent = addon.name;
+
+ // Set the author name and link to AMO.
+ if (addon.creator) {
+ let authorInfo = card.querySelector(".disco-addon-author");
+ document.l10n.setAttributes(authorInfo, "created-by-author", {
+ author: addon.creator.name,
+ });
+ // This is intentionally a link to the add-on listing instead of the
+ // author page, because the add-on listing provides more relevant info.
+ authorInfo.querySelector("a").href = formatUTMParams(
+ "discopane-entry-link",
+ addon.amoListingUrl
+ );
+ authorInfo.hidden = false;
+ }
+ }
+
+ setCardDescription(card, addon) {
+ // Set the description. Note that this is the editorial description, not
+ // the add-on's original description that would normally appear on a card.
+ card.querySelector(".disco-description-main").textContent =
+ addon.editorialDescription;
+
+ let hasStats = false;
+ if (addon.averageRating) {
+ hasStats = true;
+ card.querySelector("five-star-rating").rating = addon.averageRating;
+ } else {
+ card.querySelector("five-star-rating").hidden = true;
+ }
+
+ if (addon.dailyUsers) {
+ hasStats = true;
+ let userCountElem = card.querySelector(".disco-user-count");
+ document.l10n.setAttributes(userCountElem, "user-count", {
+ dailyUsers: addon.dailyUsers,
+ });
+ }
+
+ card.querySelector(".disco-description-statistics").hidden = !hasStats;
+ }
+
+ registerButtons(card, addon) {
+ let installButton = card.querySelector("[action='install-addon']");
+ if (addon.type == "theme") {
+ document.l10n.setAttributes(installButton, "install-theme-button");
+ } else {
+ document.l10n.setAttributes(installButton, "install-extension-button");
+ }
+
+ this.addEventListener("click", this);
+ }
+
+ handleEvent(event) {
+ let action = event.target.getAttribute("action");
+ switch (action) {
+ case "install-addon":
+ this.installDiscoAddon();
+ break;
+ case "manage-addon":
+ gViewController.loadView(`detail/${this.addonId}`);
+ break;
+ }
+ }
+
+ async installDiscoAddon() {
+ let addon = this.discoAddon;
+ let url = addon.sourceURI.spec;
+ let install = await AddonManager.getInstallForURL(url, {
+ name: addon.name,
+ telemetryInfo: {
+ source: "disco",
+ taarRecommended: addon.taarRecommended,
+ },
+ });
+ // We are hosted in a <browser> in about:addons, but we can just use the
+ // main tab's browser since all of it is using the system principal.
+ let browser = window.docShell.chromeEventHandler;
+ AddonManager.installAddonFromWebpage(
+ "application/x-xpinstall",
+ browser,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ install
+ );
+ }
+
+ /**
+ * @param {AddonWrapper|null} addon
+ * The add-on that has been installed; null if it has been removed.
+ */
+ setAddon(addon) {
+ let card = this.firstElementChild;
+ card.querySelector("[action='install-addon']").hidden = !!addon;
+ card.querySelector("[action='manage-addon']").hidden = !addon;
+
+ this.dispatchEvent(new CustomEvent("disco-card-updated")); // For testing.
+ }
+}
+customElements.define("recommended-addon-card", RecommendedAddonCard);
+
+/**
+ * A list view for add-ons of a certain type. It should be initialized with the
+ * type of add-on to render and have section data set before being connected to
+ * the document.
+ *
+ * let list = document.createElement("addon-list");
+ * list.type = "plugin";
+ * list.setSections([{
+ * headingId: "plugin-section-heading",
+ * filterFn: addon => !addon.isSystem,
+ * }]);
+ * document.body.appendChild(list);
+ */
+class AddonList extends HTMLElement {
+ constructor() {
+ super();
+ this.sections = [];
+ this.pendingUninstallAddons = new Set();
+ this._addonsToUpdate = new Set();
+ this._userFocusListenersAdded = false;
+ }
+
+ async connectedCallback() {
+ // Register the listener and get the add-ons, these operations should
+ // happpen as close to each other as possible.
+ this.registerListener();
+ // Don't render again if we were rendered prior to being inserted.
+ if (!this.children.length) {
+ // Render the initial view.
+ this.render();
+ }
+ }
+
+ disconnectedCallback() {
+ // Remove content and stop listening until this is connected again.
+ this.textContent = "";
+ this.removeListener();
+
+ // Process any pending uninstall related to this list.
+ for (const addon of this.pendingUninstallAddons) {
+ if (isPending(addon, "uninstall")) {
+ addon.uninstall();
+ }
+ }
+ this.pendingUninstallAddons.clear();
+ }
+
+ /**
+ * Configure the sections in the list.
+ *
+ * @param {object[]} sections
+ * The options for the section. Each entry in the array should have:
+ * headingId: The fluent id for the section's heading.
+ * filterFn: A function that determines if an add-on belongs in
+ * the section.
+ */
+ setSections(sections) {
+ this.sections = sections.map(section => Object.assign({}, section));
+ }
+
+ /**
+ * Set the add-on type for this list. This will be used to filter the add-ons
+ * that are displayed.
+ *
+ * @param {string} val The type to filter on.
+ */
+ set type(val) {
+ this.setAttribute("type", val);
+ }
+
+ get type() {
+ return this.getAttribute("type");
+ }
+
+ getSection(index) {
+ return this.sections[index].node;
+ }
+
+ getCards(section) {
+ return section.querySelectorAll("addon-card");
+ }
+
+ getCard(addon) {
+ return this.querySelector(`addon-card[addon-id="${addon.id}"]`);
+ }
+
+ getPendingUninstallBar(addon) {
+ return this.querySelector(`message-bar[addon-id="${addon.id}"]`);
+ }
+
+ sortByFn(aAddon, bAddon) {
+ return aAddon.name.localeCompare(bAddon.name);
+ }
+
+ async getAddons() {
+ if (!this.type) {
+ throw new Error(`type must be set to find add-ons`);
+ }
+
+ // Find everything matching our type, null will find all types.
+ let type = this.type == "all" ? null : [this.type];
+ let addons = await AddonManager.getAddonsByTypes(type);
+
+ if (type == "theme") {
+ await BuiltInThemes.ensureBuiltInThemes();
+ }
+
+ // Put the add-ons into the sections, an add-on goes in the first section
+ // that it matches the filterFn for. It might not go in any section.
+ let sectionedAddons = this.sections.map(() => []);
+ for (let addon of addons) {
+ let index = this.sections.findIndex(({ filterFn }) => filterFn(addon));
+ if (index != -1) {
+ sectionedAddons[index].push(addon);
+ } else if (isPending(addon, "uninstall")) {
+ // A second tab may be opened on "about:addons" (or Firefox may
+ // have crashed) while there are still "pending uninstall" add-ons.
+ // Ensure to list them in the pendingUninstall message-bar-stack
+ // when the AddonList is initially rendered.
+ this.pendingUninstallAddons.add(addon);
+ }
+ }
+
+ // Sort the add-ons in each section.
+ for (let [index, section] of sectionedAddons.entries()) {
+ let sortByFn = this.sections[index].sortByFn || this.sortByFn;
+ section.sort(sortByFn);
+ }
+
+ return sectionedAddons;
+ }
+
+ createPendingUninstallStack() {
+ const stack = document.createElement("message-bar-stack");
+ stack.setAttribute("class", "pending-uninstall");
+ stack.setAttribute("reverse", "");
+ return stack;
+ }
+
+ addPendingUninstallBar(addon) {
+ const stack = this.pendingUninstallStack;
+ const mb = document.createElement("message-bar");
+ mb.setAttribute("addon-id", addon.id);
+ mb.setAttribute("type", "generic");
+
+ const addonName = document.createElement("span");
+ addonName.setAttribute("data-l10n-name", "addon-name");
+ const message = document.createElement("span");
+ message.append(addonName);
+ const undo = document.createElement("button");
+ undo.setAttribute("action", "undo");
+ undo.addEventListener("click", () => {
+ addon.cancelUninstall();
+ });
+
+ document.l10n.setAttributes(message, "pending-uninstall-description", {
+ addon: addon.name,
+ });
+ document.l10n.setAttributes(undo, "pending-uninstall-undo-button");
+
+ mb.append(message, undo);
+ stack.append(mb);
+ }
+
+ removePendingUninstallBar(addon) {
+ const messagebar = this.getPendingUninstallBar(addon);
+ if (messagebar) {
+ messagebar.remove();
+ }
+ }
+
+ createSectionHeading(headingIndex) {
+ let { headingId, subheadingId } = this.sections[headingIndex];
+ let frag = document.createDocumentFragment();
+ let heading = document.createElement("h2");
+ heading.classList.add("list-section-heading");
+ document.l10n.setAttributes(heading, headingId);
+ frag.append(heading);
+
+ if (subheadingId) {
+ heading.className = "header-name";
+ let subheading = document.createElement("h3");
+ subheading.classList.add("list-section-subheading");
+ document.l10n.setAttributes(subheading, subheadingId);
+ frag.append(subheading);
+ }
+
+ return frag;
+ }
+
+ createEmptyListMessage() {
+ let emptyMessage = "list-empty-get-extensions-message";
+ let linkPref = "extensions.getAddons.link.url";
+
+ if (this.sections && this.sections.length) {
+ if (this.sections[0].headingId == "locale-enabled-heading") {
+ emptyMessage = "list-empty-get-language-packs-message";
+ linkPref = "browser.dictionaries.download.url";
+ } else if (this.sections[0].headingId == "dictionary-enabled-heading") {
+ emptyMessage = "list-empty-get-dictionaries-message";
+ linkPref = "browser.dictionaries.download.url";
+ }
+ }
+
+ let messageContainer = document.createElement("p");
+ messageContainer.id = "empty-addons-message";
+ let a = document.createElement("a");
+ a.href = Services.urlFormatter.formatURLPref(linkPref);
+ a.setAttribute("target", "_blank");
+ a.setAttribute("data-l10n-name", "get-extensions");
+ document.l10n.setAttributes(messageContainer, emptyMessage, {
+ domain: a.hostname,
+ });
+ messageContainer.appendChild(a);
+ return messageContainer;
+ }
+
+ updateSectionIfEmpty(section) {
+ // The header is added before any add-on cards, so if there's only one
+ // child then it's the header. In that case we should empty out the section.
+ if (section.children.length == 1) {
+ section.textContent = "";
+ }
+ }
+
+ insertCardInto(card, sectionIndex) {
+ let section = this.getSection(sectionIndex);
+ let sectionCards = this.getCards(section);
+
+ // If this is the first card in the section, create the heading.
+ if (!sectionCards.length) {
+ section.appendChild(this.createSectionHeading(sectionIndex));
+ }
+
+ // Find where to insert the card.
+ let insertBefore = Array.from(sectionCards).find(
+ otherCard => this.sortByFn(card.addon, otherCard.addon) < 0
+ );
+ // This will append if insertBefore is null.
+ section.insertBefore(card, insertBefore || null);
+ }
+
+ addAddon(addon) {
+ // Only insert add-ons of the right type.
+ if (addon.type != this.type && this.type != "all") {
+ this.sendEvent("skip-add", "type-mismatch");
+ return;
+ }
+
+ let insertSection = this._addonSectionIndex(addon);
+
+ // Don't add the add-on if it doesn't go in a section.
+ if (insertSection == -1) {
+ return;
+ }
+
+ // Create and insert the card.
+ let card = document.createElement("addon-card");
+ card.setAddon(addon);
+ this.insertCardInto(card, insertSection);
+ this.sendEvent("add", { id: addon.id });
+ }
+
+ sendEvent(name, detail) {
+ this.dispatchEvent(new CustomEvent(name, { detail }));
+ }
+
+ removeAddon(addon) {
+ let card = this.getCard(addon);
+ if (card) {
+ let section = card.parentNode;
+ card.remove();
+ this.updateSectionIfEmpty(section);
+ this.sendEvent("remove", { id: addon.id });
+ }
+ }
+
+ updateAddon(addon) {
+ if (!this.getCard(addon)) {
+ // Try to add the add-on right away.
+ this.addAddon(addon);
+ } else if (this._addonSectionIndex(addon) == -1) {
+ // Try to remove the add-on right away.
+ this._updateAddon(addon);
+ } else if (this.isUserFocused) {
+ // Queue up a change for when the focus is cleared.
+ this.updateLater(addon);
+ } else {
+ // Not currently focused, make the change now.
+ this.withCardAnimation(() => this._updateAddon(addon));
+ }
+ }
+
+ updateLater(addon) {
+ this._addonsToUpdate.add(addon);
+ this._addUserFocusListeners();
+ }
+
+ _addUserFocusListeners() {
+ if (this._userFocusListenersAdded) {
+ return;
+ }
+
+ this._userFocusListenersAdded = true;
+ this.addEventListener("mouseleave", this);
+ this.addEventListener("hidden", this, true);
+ this.addEventListener("focusout", this);
+ }
+
+ _removeUserFocusListeners() {
+ if (!this._userFocusListenersAdded) {
+ return;
+ }
+
+ this.removeEventListener("mouseleave", this);
+ this.removeEventListener("hidden", this, true);
+ this.removeEventListener("focusout", this);
+ this._userFocusListenersAdded = false;
+ }
+
+ get hasMenuOpen() {
+ return !!this.querySelector("panel-list[open]");
+ }
+
+ get isUserFocused() {
+ return this.matches(":hover, :focus-within") || this.hasMenuOpen;
+ }
+
+ update() {
+ if (this._addonsToUpdate.size) {
+ this.withCardAnimation(() => {
+ for (let addon of this._addonsToUpdate) {
+ this._updateAddon(addon);
+ }
+ this._addonsToUpdate = new Set();
+ });
+ }
+ }
+
+ _getChildCoords() {
+ let results = new Map();
+ for (let child of this.querySelectorAll("addon-card")) {
+ results.set(child, child.getBoundingClientRect());
+ }
+ return results;
+ }
+
+ withCardAnimation(changeFn) {
+ if (shouldSkipAnimations()) {
+ changeFn();
+ return;
+ }
+
+ let origChildCoords = this._getChildCoords();
+
+ changeFn();
+
+ let newChildCoords = this._getChildCoords();
+ let cards = this.querySelectorAll("addon-card");
+ let transitionCards = [];
+ for (let card of cards) {
+ let orig = origChildCoords.get(card);
+ let moved = newChildCoords.get(card);
+ let changeY = moved.y - (orig || moved).y;
+ let cardEl = card.firstElementChild;
+
+ if (changeY != 0) {
+ cardEl.style.transform = `translateY(${changeY * -1}px)`;
+ transitionCards.push(card);
+ }
+ }
+ requestAnimationFrame(() => {
+ for (let card of transitionCards) {
+ card.firstElementChild.style.transition = "transform 125ms";
+ }
+
+ requestAnimationFrame(() => {
+ for (let card of transitionCards) {
+ let cardEl = card.firstElementChild;
+ cardEl.style.transform = "";
+ cardEl.addEventListener("transitionend", function handler(e) {
+ if (e.target == cardEl && e.propertyName == "transform") {
+ cardEl.style.transition = "";
+ cardEl.removeEventListener("transitionend", handler);
+ }
+ });
+ }
+ });
+ });
+ }
+
+ _addonSectionIndex(addon) {
+ return this.sections.findIndex(s => s.filterFn(addon));
+ }
+
+ _updateAddon(addon) {
+ let card = this.getCard(addon);
+ if (card) {
+ let sectionIndex = this._addonSectionIndex(addon);
+ if (sectionIndex != -1) {
+ // Move the card, if needed. This will allow an animation between
+ // page sections and provides clearer events for testing.
+ if (card.parentNode.getAttribute("section") != sectionIndex) {
+ let { activeElement } = document;
+ let refocus = card.contains(activeElement);
+ let oldSection = card.parentNode;
+ this.insertCardInto(card, sectionIndex);
+ this.updateSectionIfEmpty(oldSection);
+ if (refocus) {
+ activeElement.focus();
+ }
+ this.sendEvent("move", { id: addon.id });
+ }
+ } else {
+ this.removeAddon(addon);
+ }
+ }
+ }
+
+ renderSection(addons, index) {
+ const { sectionClass } = this.sections[index];
+
+ let section = document.createElement("section");
+ section.setAttribute("section", index);
+ if (sectionClass) {
+ section.setAttribute("class", sectionClass);
+ }
+
+ // Render the heading and add-ons if there are any.
+ if (addons.length) {
+ section.appendChild(this.createSectionHeading(index));
+ }
+
+ for (let addon of addons) {
+ let card = document.createElement("addon-card");
+ card.setAddon(addon);
+ card.render();
+ section.appendChild(card);
+ }
+
+ return section;
+ }
+
+ async render() {
+ this.textContent = "";
+
+ let sectionedAddons = await this.getAddons();
+
+ let frag = document.createDocumentFragment();
+
+ // Render the pending uninstall message-bar-stack.
+ this.pendingUninstallStack = this.createPendingUninstallStack();
+ for (let addon of this.pendingUninstallAddons) {
+ this.addPendingUninstallBar(addon);
+ }
+ frag.appendChild(this.pendingUninstallStack);
+
+ // Render the sections.
+ for (let i = 0; i < sectionedAddons.length; i++) {
+ this.sections[i].node = this.renderSection(sectionedAddons[i], i);
+ frag.appendChild(this.sections[i].node);
+ }
+
+ // Render the placeholder that is shown when all sections are empty.
+ // This call is after rendering the sections, because its visibility
+ // is controlled through the general sibling combinator relative to
+ // the sections (section ~).
+ let message = this.createEmptyListMessage();
+ frag.appendChild(message);
+
+ // Make sure fluent has set all the strings before we render. This will
+ // avoid the height changing as strings go from 0 height to having text.
+ await document.l10n.translateFragment(frag);
+ this.appendChild(frag);
+ }
+
+ registerListener() {
+ AddonManagerListenerHandler.addListener(this);
+ }
+
+ removeListener() {
+ AddonManagerListenerHandler.removeListener(this);
+ }
+
+ handleEvent(e) {
+ if (!this.isUserFocused || (e.type == "mouseleave" && !this.hasMenuOpen)) {
+ this._removeUserFocusListeners();
+ this.update();
+ }
+ }
+
+ /**
+ * AddonManager listener events.
+ */
+
+ onOperationCancelled(addon) {
+ if (
+ this.pendingUninstallAddons.has(addon) &&
+ !isPending(addon, "uninstall")
+ ) {
+ this.pendingUninstallAddons.delete(addon);
+ this.removePendingUninstallBar(addon);
+ }
+ this.updateAddon(addon);
+ }
+
+ onEnabled(addon) {
+ this.updateAddon(addon);
+ }
+
+ onDisabled(addon) {
+ this.updateAddon(addon);
+ }
+
+ onUninstalling(addon) {
+ if (
+ isPending(addon, "uninstall") &&
+ (this.type === "all" || addon.type === this.type)
+ ) {
+ this.pendingUninstallAddons.add(addon);
+ this.addPendingUninstallBar(addon);
+ this.updateAddon(addon);
+ }
+ }
+
+ onInstalled(addon) {
+ if (this.querySelector(`addon-card[addon-id="${addon.id}"]`)) {
+ return;
+ }
+ this.addAddon(addon);
+ }
+
+ onUninstalled(addon) {
+ this.pendingUninstallAddons.delete(addon);
+ this.removePendingUninstallBar(addon);
+ this.removeAddon(addon);
+ }
+}
+customElements.define("addon-list", AddonList);
+
+class RecommendedAddonList extends HTMLElement {
+ connectedCallback() {
+ if (this.isConnected) {
+ this.loadCardsIfNeeded();
+ this.updateCardsWithAddonManager();
+ }
+ AddonManagerListenerHandler.addListener(this);
+ }
+
+ disconnectedCallback() {
+ AddonManagerListenerHandler.removeListener(this);
+ }
+
+ get type() {
+ return this.getAttribute("type");
+ }
+
+ /**
+ * Set the add-on type for this list. This will be used to filter the add-ons
+ * that are displayed.
+ *
+ * Must be set prior to the first render.
+ *
+ * @param {string} val The type to filter on.
+ */
+ set type(val) {
+ this.setAttribute("type", val);
+ }
+
+ get hideInstalled() {
+ return this.hasAttribute("hide-installed");
+ }
+
+ /**
+ * Set whether installed add-ons should be hidden from the list. If false,
+ * installed add-ons will be shown with a "Manage" button, otherwise they
+ * will be hidden.
+ *
+ * Must be set prior to the first render.
+ *
+ * @param {boolean} val Whether to show installed add-ons.
+ */
+ set hideInstalled(val) {
+ this.toggleAttribute("hide-installed", val);
+ }
+
+ getCardById(addonId) {
+ for (let card of this.children) {
+ if (card.addonId === addonId) {
+ return card;
+ }
+ }
+ return null;
+ }
+
+ setAddonForCard(card, addon) {
+ card.setAddon(addon);
+
+ let wasHidden = card.hidden;
+ card.hidden = this.hideInstalled && addon;
+
+ if (wasHidden != card.hidden) {
+ let eventName = card.hidden ? "card-hidden" : "card-shown";
+ this.dispatchEvent(new CustomEvent(eventName, { detail: { card } }));
+ }
+ }
+
+ /**
+ * Whether the client ID should be preferred. This is disabled for themes
+ * since they don't use the telemetry data and don't show the TAAR notice.
+ */
+ get preferClientId() {
+ return !this.type || this.type == "extension";
+ }
+
+ async updateCardsWithAddonManager() {
+ let cards = Array.from(this.children);
+ let addonIds = cards.map(card => card.addonId);
+ let addons = await AddonManager.getAddonsByIDs(addonIds);
+ for (let [i, card] of cards.entries()) {
+ let addon = addons[i];
+ this.setAddonForCard(card, addon);
+ if (addon) {
+ // Already installed, move card to end.
+ this.append(card);
+ }
+ }
+ }
+
+ async loadCardsIfNeeded() {
+ // Use promise as guard. Also used by tests to detect when load completes.
+ if (!this.cardsReady) {
+ this.cardsReady = this._loadCards();
+ }
+ return this.cardsReady;
+ }
+
+ async _loadCards() {
+ let recommendedAddons;
+ try {
+ recommendedAddons = await DiscoveryAPI.getResults(this.preferClientId);
+ } catch (e) {
+ return;
+ }
+
+ let frag = document.createDocumentFragment();
+ for (let addon of recommendedAddons) {
+ if (this.type && addon.type != this.type) {
+ continue;
+ }
+ let card = document.createElement("recommended-addon-card");
+ card.setDiscoAddon(addon);
+ frag.append(card);
+ }
+ this.append(frag);
+ await this.updateCardsWithAddonManager();
+ }
+
+ /**
+ * AddonManager listener events.
+ */
+
+ onInstalled(addon) {
+ let card = this.getCardById(addon.id);
+ if (card) {
+ this.setAddonForCard(card, addon);
+ }
+ }
+
+ onUninstalled(addon) {
+ let card = this.getCardById(addon.id);
+ if (card) {
+ this.setAddonForCard(card, null);
+ }
+ }
+}
+customElements.define("recommended-addon-list", RecommendedAddonList);
+
+class TaarMessageBar extends HTMLElement {
+ connectedCallback() {
+ this.hidden =
+ Services.prefs.getBoolPref(PREF_RECOMMENDATION_HIDE_NOTICE, false) ||
+ !DiscoveryAPI.clientIdDiscoveryEnabled;
+ if (this.childElementCount == 0 && !this.hidden) {
+ this.appendChild(importTemplate("taar-notice"));
+ this.addEventListener("click", this);
+ this.messageBar = this.querySelector("message-bar");
+ this.messageBar.addEventListener("message-bar:user-dismissed", this);
+ }
+ }
+
+ handleEvent(e) {
+ if (e.type == "message-bar:user-dismissed") {
+ Services.prefs.setBoolPref(PREF_RECOMMENDATION_HIDE_NOTICE, true);
+ }
+ }
+}
+customElements.define("taar-notice", TaarMessageBar);
+
+class RecommendedFooter extends HTMLElement {
+ connectedCallback() {
+ if (this.childElementCount == 0) {
+ this.appendChild(importTemplate("recommended-footer"));
+ this.querySelector(".privacy-policy-link").href =
+ Services.prefs.getStringPref(PREF_PRIVACY_POLICY_URL);
+ this.addEventListener("click", this);
+ }
+ }
+
+ handleEvent(event) {
+ let action = event.target.getAttribute("action");
+ switch (action) {
+ case "open-amo":
+ openAmoInTab(this);
+ break;
+ }
+ }
+}
+customElements.define("recommended-footer", RecommendedFooter, {
+ extends: "footer",
+});
+
+class RecommendedThemesFooter extends HTMLElement {
+ connectedCallback() {
+ if (this.childElementCount == 0) {
+ this.appendChild(importTemplate("recommended-themes-footer"));
+ let themeRecommendationRow = this.querySelector(".theme-recommendation");
+ let themeRecommendationUrl = Services.prefs.getStringPref(
+ PREF_THEME_RECOMMENDATION_URL
+ );
+ if (themeRecommendationUrl) {
+ themeRecommendationRow.querySelector("a").href = themeRecommendationUrl;
+ }
+ themeRecommendationRow.hidden = !themeRecommendationUrl;
+ this.addEventListener("click", this);
+ }
+ }
+
+ handleEvent(event) {
+ let action = event.target.getAttribute("action");
+ switch (action) {
+ case "open-amo":
+ openAmoInTab(this, "themes");
+ break;
+ }
+ }
+}
+customElements.define("recommended-themes-footer", RecommendedThemesFooter, {
+ extends: "footer",
+});
+
+/**
+ * This element will handle showing recommendations with a
+ * <recommended-addon-list> and a <footer>. The footer will be hidden until
+ * the <recommended-addon-list> is done making its request so the footer
+ * doesn't move around.
+ *
+ * Subclass this element to use it and define a `template` property to pull
+ * the template from. Expected template:
+ *
+ * <h1>My extra content can go here.</h1>
+ * <p>It can be anything but a footer or recommended-addon-list.</p>
+ * <recommended-addon-list></recommended-addon-list>
+ * <footer>My custom footer</footer>
+ */
+class RecommendedSection extends HTMLElement {
+ connectedCallback() {
+ if (this.childElementCount == 0) {
+ this.render();
+ }
+ }
+
+ get list() {
+ return this.querySelector("recommended-addon-list");
+ }
+
+ get footer() {
+ return this.querySelector("footer");
+ }
+
+ render() {
+ this.appendChild(importTemplate(this.template));
+
+ // Hide footer until the cards are loaded, to prevent the content from
+ // suddenly shifting when the user attempts to interact with it.
+ let { footer } = this;
+ footer.hidden = true;
+ this.list.loadCardsIfNeeded().finally(() => {
+ footer.hidden = false;
+ });
+ }
+}
+
+class RecommendedExtensionsSection extends RecommendedSection {
+ get template() {
+ return "recommended-extensions-section";
+ }
+}
+customElements.define(
+ "recommended-extensions-section",
+ RecommendedExtensionsSection
+);
+
+class RecommendedThemesSection extends RecommendedSection {
+ get template() {
+ return "recommended-themes-section";
+ }
+}
+customElements.define("recommended-themes-section", RecommendedThemesSection);
+
+class DiscoveryPane extends RecommendedSection {
+ get template() {
+ return "discopane";
+ }
+}
+customElements.define("discovery-pane", DiscoveryPane);
+
+// Define views
+gViewController.defineView("list", async type => {
+ if (!AddonManager.hasAddonType(type)) {
+ return null;
+ }
+
+ let frag = document.createDocumentFragment();
+ let list = document.createElement("addon-list");
+ list.type = type;
+
+ let sections = [
+ {
+ headingId: type + "-enabled-heading",
+ sectionClass: `${type}-enabled-section`,
+ filterFn: addon =>
+ !addon.hidden && addon.isActive && !isPending(addon, "uninstall"),
+ },
+ ];
+
+ const disabledAddonsFilterFn = addon =>
+ !addon.hidden && !addon.isActive && !isPending(addon, "uninstall");
+
+ sections.push({
+ headingId: getL10nIdMapping(`${type}-disabled-heading`),
+ sectionClass: `${type}-disabled-section`,
+ filterFn: disabledAddonsFilterFn,
+ });
+
+ list.setSections(sections);
+ frag.appendChild(list);
+
+ // Show recommendations for themes and extensions.
+ if (
+ LIST_RECOMMENDATIONS_ENABLED &&
+ (type == "extension" || type == "theme")
+ ) {
+ let elementName =
+ type == "extension"
+ ? "recommended-extensions-section"
+ : "recommended-themes-section";
+ let recommendations = document.createElement(elementName);
+ // Start loading the recommendations. This can finish after the view load
+ // event is sent.
+ recommendations.render();
+ frag.appendChild(recommendations);
+ }
+
+ await list.render();
+
+ return frag;
+});
+
+gViewController.defineView("detail", async param => {
+ let [id, selectedTab] = param.split("/");
+ let addon = await AddonManager.getAddonByID(id);
+
+ if (!addon) {
+ return null;
+ }
+
+ let card = document.createElement("addon-card");
+
+ // Ensure the category for this add-on type is selected.
+ document.querySelector("categories-box").selectType(addon.type);
+
+ // Go back to the list view when the add-on is removed.
+ card.addEventListener("remove", () =>
+ gViewController.loadView(`list/${addon.type}`)
+ );
+
+ card.setAddon(addon);
+ card.expand();
+ await card.render();
+ if (selectedTab === "preferences" && (await isAddonOptionsUIAllowed(addon))) {
+ card.showPrefs();
+ }
+
+ return card;
+});
+
+gViewController.defineView("updates", async param => {
+ let list = document.createElement("addon-list");
+ list.type = "all";
+ if (param == "available") {
+ list.setSections([
+ {
+ headingId: "available-updates-heading",
+ filterFn: addon => {
+ // Filter the addons visible in the updates view using the same
+ // criteria that is being used to compute the counter on the
+ // available updates category button badge.
+ const install = getUpdateInstall(addon);
+ return install && isManualUpdate(install) && !install.installed;
+ },
+ },
+ ]);
+ } else if (param == "recent") {
+ list.sortByFn = (a, b) => {
+ if (a.updateDate > b.updateDate) {
+ return -1;
+ }
+ if (a.updateDate < b.updateDate) {
+ return 1;
+ }
+ return 0;
+ };
+ let updateLimit = new Date() - UPDATES_RECENT_TIMESPAN;
+ list.setSections([
+ {
+ headingId: "recent-updates-heading",
+ filterFn: addon =>
+ !addon.hidden && addon.updateDate && addon.updateDate > updateLimit,
+ },
+ ]);
+ } else {
+ throw new Error(`Unknown updates view ${param}`);
+ }
+
+ await list.render();
+ return list;
+});
+
+gViewController.defineView("discover", async () => {
+ let discopane = document.createElement("discovery-pane");
+ discopane.render();
+ await document.l10n.translateFragment(discopane);
+ return discopane;
+});
+
+gViewController.defineView("shortcuts", async () => {
+ // Force the extension category to be selected, in the case of a reload,
+ // restart, or if the view was opened from another category's page.
+ document.querySelector("categories-box").selectType("extension");
+
+ let view = document.createElement("addon-shortcuts");
+ await view.render();
+ await document.l10n.translateFragment(view);
+ return view;
+});
+
+/**
+ * @param {Element} el The button element.
+ */
+function openAmoInTab(el, path) {
+ let amoUrl = Services.urlFormatter.formatURLPref(
+ "extensions.getAddons.link.url"
+ );
+
+ if (path) {
+ amoUrl += path;
+ }
+
+ amoUrl = formatUTMParams("find-more-link-bottom", amoUrl);
+ windowRoot.ownerGlobal.openTrustedLinkIn(amoUrl, "tab");
+}
+
+/**
+ * Called when about:addons is loaded.
+ */
+async function initialize() {
+ window.addEventListener(
+ "unload",
+ () => {
+ // Clear out the document so the disconnectedCallback will trigger
+ // properly and all of the custom elements can cleanup.
+ document.body.textContent = "";
+ AddonManagerListenerHandler.shutdown();
+ },
+ { once: true }
+ );
+
+ // Init UI and view management
+ gViewController.initialize(document.getElementById("main"));
+
+ document.querySelector("categories-box").initialize();
+ AddonManagerListenerHandler.startup();
+
+ // browser.js may call loadView here if it expects an EM-loaded notification
+ gViewController.notifyEMLoaded();
+
+ // Select an initial view if no listener has set one so far
+ if (!gViewController.currentViewId) {
+ if (history.state) {
+ // If there is a history state to restore then use that
+ await gViewController.renderState(history.state);
+ } else {
+ // Fallback to the last category or first valid category view otherwise.
+ await gViewController.loadView(
+ Services.prefs.getStringPref(
+ PREF_UI_LASTCATEGORY,
+ gViewController.defaultViewId
+ )
+ );
+ }
+ }
+}
+
+window.promiseInitialized = new Promise(resolve => {
+ window.addEventListener(
+ "load",
+ () => {
+ initialize().then(resolve);
+ },
+ { once: true }
+ );
+});
diff --git a/toolkit/mozapps/extensions/content/aboutaddonsCommon.js b/toolkit/mozapps/extensions/content/aboutaddonsCommon.js
new file mode 100644
index 0000000000..739e7629d7
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/aboutaddonsCommon.js
@@ -0,0 +1,275 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* eslint max-len: ["error", 80] */
+
+"use strict";
+
+/* exported attachUpdateHandler, detachUpdateHandler, gBrowser,
+ * getBrowserElement, installAddonsFromFilePicker,
+ * isCorrectlySigned, isDisabledUnsigned, isDiscoverEnabled,
+ * isPending, loadReleaseNotes, openOptionsInTab, promiseEvent,
+ * shouldShowPermissionsPrompt, showPermissionsPrompt,
+ * PREF_UI_LASTCATEGORY */
+
+const { AddonSettings } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonSettings.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+ChromeUtils.defineESModuleGetters(this, {
+ Extension: "resource://gre/modules/Extension.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "XPINSTALL_ENABLED",
+ "xpinstall.enabled",
+ true
+);
+
+const PREF_DISCOVER_ENABLED = "extensions.getAddons.showPane";
+const PREF_UI_LASTCATEGORY = "extensions.ui.lastCategory";
+
+function isDiscoverEnabled() {
+ try {
+ if (!Services.prefs.getBoolPref(PREF_DISCOVER_ENABLED)) {
+ return false;
+ }
+ } catch (e) {}
+
+ if (!XPINSTALL_ENABLED) {
+ return false;
+ }
+
+ return true;
+}
+
+function getBrowserElement() {
+ return window.docShell.chromeEventHandler;
+}
+
+function promiseEvent(event, target, capture = false) {
+ return new Promise(resolve => {
+ target.addEventListener(event, resolve, { capture, once: true });
+ });
+}
+
+function installPromptHandler(info) {
+ const install = this;
+
+ let oldPerms = info.existingAddon.userPermissions;
+ if (!oldPerms) {
+ // Updating from a legacy add-on, let it proceed
+ return Promise.resolve();
+ }
+
+ let newPerms = info.addon.userPermissions;
+
+ let difference = Extension.comparePermissions(oldPerms, newPerms);
+
+ // If there are no new permissions, just proceed
+ if (!difference.origins.length && !difference.permissions.length) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve, reject) => {
+ let subject = {
+ wrappedJSObject: {
+ target: getBrowserElement(),
+ info: {
+ type: "update",
+ addon: info.addon,
+ icon: info.addon.iconURL,
+ // Reference to the related AddonInstall object (used in
+ // AMTelemetry to link the recorded event to the other events from
+ // the same install flow).
+ install,
+ permissions: difference,
+ resolve,
+ reject,
+ },
+ },
+ };
+ Services.obs.notifyObservers(subject, "webextension-permission-prompt");
+ });
+}
+
+function attachUpdateHandler(install) {
+ install.promptHandler = installPromptHandler;
+}
+
+function detachUpdateHandler(install) {
+ if (install?.promptHandler === installPromptHandler) {
+ install.promptHandler = null;
+ }
+}
+
+async function loadReleaseNotes(uri) {
+ const res = await fetch(uri.spec, { credentials: "omit" });
+
+ if (!res.ok) {
+ throw new Error("Error loading release notes");
+ }
+
+ // Load the content.
+ const text = await res.text();
+
+ // Setup the content sanitizer.
+ const ParserUtils = Cc["@mozilla.org/parserutils;1"].getService(
+ Ci.nsIParserUtils
+ );
+ const flags =
+ ParserUtils.SanitizerDropMedia |
+ ParserUtils.SanitizerDropNonCSSPresentation |
+ ParserUtils.SanitizerDropForms;
+
+ // Sanitize and parse the content to a fragment.
+ const context = document.createElementNS(HTML_NS, "div");
+ return ParserUtils.parseFragment(text, flags, false, uri, context);
+}
+
+function openOptionsInTab(optionsURL) {
+ let mainWindow = window.windowRoot.ownerGlobal;
+ if ("switchToTabHavingURI" in mainWindow) {
+ mainWindow.switchToTabHavingURI(optionsURL, true, {
+ relatedToCurrent: true,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ return true;
+ }
+ return false;
+}
+
+function shouldShowPermissionsPrompt(addon) {
+ if (!addon.isWebExtension || addon.seen) {
+ return false;
+ }
+
+ let perms = addon.userPermissions;
+ return perms?.origins.length || perms?.permissions.length;
+}
+
+function showPermissionsPrompt(addon) {
+ return new Promise(resolve => {
+ const permissions = addon.userPermissions;
+ const target = getBrowserElement();
+
+ const onAddonEnabled = () => {
+ // The user has just enabled a sideloaded extension, if the permission
+ // can be changed for the extension, show the post-install panel to
+ // give the user that opportunity.
+ if (
+ addon.permissions & AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
+ ) {
+ Services.obs.notifyObservers(
+ { addon, target },
+ "webextension-install-notify"
+ );
+ }
+ resolve();
+ };
+
+ const subject = {
+ wrappedJSObject: {
+ target,
+ info: {
+ type: "sideload",
+ addon,
+ icon: addon.iconURL,
+ permissions,
+ resolve() {
+ addon.markAsSeen();
+ addon.enable().then(onAddonEnabled);
+ },
+ reject() {
+ // Ignore a cancelled permission prompt.
+ },
+ },
+ },
+ };
+ Services.obs.notifyObservers(subject, "webextension-permission-prompt");
+ });
+}
+
+// Stub tabbrowser implementation for use by the tab-modal alert code
+// when an alert/prompt/confirm method is called in a WebExtensions options_ui
+// page (See Bug 1385548 for rationale).
+var gBrowser = {
+ getTabModalPromptBox(browser) {
+ const parentWindow = window.docShell.chromeEventHandler.ownerGlobal;
+
+ if (parentWindow.gBrowser) {
+ return parentWindow.gBrowser.getTabModalPromptBox(browser);
+ }
+
+ return null;
+ },
+};
+
+function isCorrectlySigned(addon) {
+ // Add-ons without an "isCorrectlySigned" property are correctly signed as
+ // they aren't the correct type for signing.
+ return addon.isCorrectlySigned !== false;
+}
+
+function isDisabledUnsigned(addon) {
+ let signingRequired =
+ addon.type == "locale"
+ ? AddonSettings.LANGPACKS_REQUIRE_SIGNING
+ : AddonSettings.REQUIRE_SIGNING;
+ return signingRequired && !isCorrectlySigned(addon);
+}
+
+function isPending(addon, action) {
+ const amAction = AddonManager["PENDING_" + action.toUpperCase()];
+ return !!(addon.pendingOperations & amAction);
+}
+
+async function installAddonsFromFilePicker() {
+ let [dialogTitle, filterName] = await document.l10n.formatMessages([
+ { id: "addon-install-from-file-dialog-title" },
+ { id: "addon-install-from-file-filter-name" },
+ ]);
+ const nsIFilePicker = Ci.nsIFilePicker;
+ var fp = Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker);
+ fp.init(window, dialogTitle.value, nsIFilePicker.modeOpenMultiple);
+ try {
+ fp.appendFilter(filterName.value, "*.xpi;*.jar;*.zip");
+ fp.appendFilters(nsIFilePicker.filterAll);
+ } catch (e) {}
+
+ return new Promise(resolve => {
+ fp.open(async result => {
+ if (result != nsIFilePicker.returnOK) {
+ return;
+ }
+
+ let installTelemetryInfo = {
+ source: "about:addons",
+ method: "install-from-file",
+ };
+
+ let browser = getBrowserElement();
+ let installs = [];
+ for (let file of fp.files) {
+ let install = await AddonManager.getInstallForFile(
+ file,
+ null,
+ installTelemetryInfo
+ );
+ AddonManager.installAddonFromAOM(
+ browser,
+ document.documentURIObject,
+ install
+ );
+ installs.push(install);
+ }
+ resolve(installs);
+ });
+ });
+}
diff --git a/toolkit/mozapps/extensions/content/abuse-report-frame.html b/toolkit/mozapps/extensions/content/abuse-report-frame.html
new file mode 100644
index 0000000000..03285fb165
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/abuse-report-frame.html
@@ -0,0 +1,202 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ - You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html>
+ <head>
+ <title></title>
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+ <link
+ rel="stylesheet"
+ href="chrome://mozapps/content/extensions/aboutaddons.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://mozapps/content/extensions/abuse-report-panel.css"
+ />
+
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="toolkit/about/aboutAddons.ftl" />
+ <link rel="localization" href="toolkit/about/abuseReports.ftl" />
+
+ <script
+ defer
+ src="chrome://mozapps/content/extensions/abuse-report-panel.js"
+ ></script>
+ </head>
+
+ <body>
+ <addon-abuse-report></addon-abuse-report>
+
+ <!-- WebComponents Templates -->
+ <template id="tmpl-modal">
+ <div class="modal-overlay-outer"></div>
+ <div class="modal-panel-container"></div>
+ </template>
+
+ <template id="tmpl-abuse-report">
+ <form class="addon-abuse-report" onsubmit="return false;">
+ <div class="abuse-report-header">
+ <img class="card-heading-icon addon-icon" />
+ <div class="card-contents">
+ <span class="addon-name"></span>
+ <span
+ class="addon-author-box"
+ data-l10n-args='{"author-name": "author placeholder"}'
+ data-l10n-id="abuse-report-addon-authored-by"
+ >
+ <a
+ data-l10n-name="author-name"
+ class="author"
+ href="#"
+ target="_blank"
+ ></a>
+ </span>
+ </div>
+ </div>
+ <button class="abuse-report-close-icon" type="button"></button>
+ <div class="abuse-report-contents">
+ <abuse-report-reasons-panel></abuse-report-reasons-panel>
+ <abuse-report-submit-panel hidden></abuse-report-submit-panel>
+ </div>
+ <div class="abuse-report-buttons">
+ <div class="abuse-report-reasons-buttons">
+ <button
+ class="abuse-report-cancel"
+ type="button"
+ data-l10n-id="abuse-report-cancel-button"
+ ></button>
+ <button
+ class="primary abuse-report-next"
+ type="button"
+ data-l10n-id="abuse-report-next-button"
+ ></button>
+ </div>
+ <div class="abuse-report-submit-buttons" hidden>
+ <button
+ class="abuse-report-goback"
+ type="button"
+ data-l10n-id="abuse-report-goback-button"
+ ></button>
+ <button
+ class="primary abuse-report-submit"
+ type="button"
+ data-l10n-id="abuse-report-submit-button"
+ ></button>
+ </div>
+ </div>
+ </form>
+ </template>
+
+ <template id="tmpl-reasons-panel">
+ <h2 class="abuse-report-title"></h2>
+ <hr />
+ <p class="abuse-report-subtitle" data-l10n-id="abuse-report-subtitle"></p>
+ <ul class="abuse-report-reasons">
+ <li is="abuse-report-reason-listitem" report-reason="other"></li>
+ </ul>
+ <p data-l10n-id="abuse-report-learnmore">
+ <a
+ class="abuse-report-learnmore"
+ target="_blank"
+ data-l10n-name="learnmore-link"
+ >
+ </a>
+ </p>
+ </template>
+
+ <template id="tmpl-submit-panel">
+ <h2 class="abuse-reason-title"></h2>
+ <abuse-report-reason-suggestions></abuse-report-reason-suggestions>
+ <hr />
+ <p
+ class="abuse-report-subtitle"
+ data-l10n-id="abuse-report-submit-description"
+ ></p>
+ <textarea name="message" data-l10n-id="abuse-report-textarea"></textarea>
+ <p class="abuse-report-note" data-l10n-id="abuse-report-submit-note"></p>
+ </template>
+
+ <template id="tmpl-reason-listitem">
+ <label>
+ <input type="radio" name="reason" class="radio" />
+ <span class="reason-description"></span>
+ <span hidden class="abuse-report-note reason-example"></span>
+ </label>
+ </template>
+
+ <template id="tmpl-suggestions-settings">
+ <p data-l10n-id="abuse-report-settings-suggestions"></p>
+ <p></p>
+ <ul>
+ <li>
+ <a
+ class="abuse-settings-search-learnmore"
+ target="_blank"
+ data-l10n-id="abuse-report-settings-suggestions-search"
+ >
+ </a>
+ </li>
+ <li>
+ <a
+ class="abuse-settings-homepage-learnmore"
+ target="_blank"
+ data-l10n-id="abuse-report-settings-suggestions-homepage"
+ >
+ </a>
+ </li>
+ </ul>
+ </template>
+
+ <template id="tmpl-suggestions-policy">
+ <p data-l10n-id="abuse-report-policy-suggestions">
+ <a
+ class="abuse-policy-learnmore"
+ target="_blank"
+ data-l10n-name="report-infringement-link"
+ >
+ </a>
+ </p>
+ </template>
+
+ <template id="tmpl-suggestions-broken-extension">
+ <p data-l10n-id="abuse-report-broken-suggestions-extension">
+ <a
+ class="extension-support-link"
+ target="_blank"
+ data-l10n-name="support-link"
+ >
+ </a>
+ </p>
+
+ <p></p
+ ></template>
+
+ <template id="tmpl-suggestions-broken-theme">
+ <p data-l10n-id="abuse-report-broken-suggestions-theme">
+ <a
+ class="extension-support-link"
+ target="_blank"
+ data-l10n-name="support-link"
+ >
+ </a>
+ </p>
+
+ <p></p
+ ></template>
+
+ <template id="tmpl-suggestions-broken-sitepermission">
+ <p data-l10n-id="abuse-report-broken-suggestions-sitepermission">
+ <a
+ class="extension-support-link"
+ target="_blank"
+ data-l10n-name="support-link"
+ >
+ </a>
+ </p>
+
+ <p></p
+ ></template>
+ </body>
+</html>
diff --git a/toolkit/mozapps/extensions/content/abuse-report-panel.css b/toolkit/mozapps/extensions/content/abuse-report-panel.css
new file mode 100644
index 0000000000..977717146e
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/abuse-report-panel.css
@@ -0,0 +1,185 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* Abuse Reports card */
+
+:root {
+ --close-icon-url: url("chrome://global/skin/icons/close.svg");
+ --close-icon-size: 20px;
+
+ --modal-panel-min-width: 60%;
+ --modal-panel-margin-top: 36px;
+ --modal-panel-margin-bottom: 36px;
+ --modal-panel-margin: 20%;
+ --modal-panel-padding: 40px;
+
+ --line-height: 20px;
+ --textarea-height: 220px;
+ --button-padding: 52px;
+ --listitem-padding-bottom: 14px;
+ --list-radio-column-size: 28px;
+ --note-font-size: 14px;
+ --note-font-weight: 400;
+ --subtitle-font-size: 16px;
+ --subtitle-font-weight: 600;
+}
+
+/* Ensure that the document (embedded in the XUL about:addons using a
+ XUL browser) has a transparent background */
+html {
+ background-color: transparent;
+}
+
+.modal-overlay-outer {
+ background: var(--grey-90-a60);
+ width: 100%;
+ height: 100%;
+ position: fixed;
+ z-index: -1;
+}
+
+.modal-panel-container {
+ padding-top: var(--modal-panel-margin-top);
+ padding-bottom: var(--modal-panel-margin-bottom);
+ padding-left: var(--modal-panel-margin);
+ padding-right: var(--modal-panel-margin);
+}
+
+.addon-abuse-report {
+ min-width: var(--modal-panel-min-width);
+ padding: var(--modal-panel-padding);
+ display: flex;
+ flex-direction: column;
+ position: relative;
+}
+
+.addon-abuse-report:hover {
+ /* Avoid the card box highlighting on hover. */
+ box-shadow: none;
+}
+
+.addon-abuse-report button {
+ padding: 0 var(--button-padding);
+}
+
+.abuse-report-close-icon {
+ /* position the close button in the panel upper-right corner */
+ position: absolute;
+ top: 12px;
+ inset-inline-end: 16px;
+}
+
+button.abuse-report-close-icon {
+ background: var(--close-icon-url) no-repeat center center;
+ -moz-context-properties: fill;
+ color: inherit !important;
+ fill: currentColor;
+ min-width: auto;
+ min-height: auto;
+ width: var(--close-icon-size);
+ height: var(--close-icon-size);
+ margin: 0;
+ padding: 0;
+}
+
+button.abuse-report-close-icon:hover {
+ fill-opacity: 0.1;
+}
+
+button.abuse-report-close-icon:hover:active {
+ fill-opacity: 0.2;
+}
+
+.abuse-report-header {
+ display: flex;
+ flex-direction: row;
+}
+
+.abuse-report-contents,
+.abuse-report-contents > hr {
+ width: 100%;
+}
+
+.abuse-report-note {
+ color: var(--text-color-deemphasized);
+ font-size: var(--note-font-size);
+ font-weight: var(--note-font-weight);
+ line-height: var(--line-height);
+}
+
+.abuse-report-subtitle {
+ font-size: var(--subtitle-font-size);
+ font-weight: var(--subtitle-font-weight);
+ line-height: var(--line-height);
+}
+
+ul.abuse-report-reasons {
+ list-style-type: none;
+ padding-inline-start: 0;
+}
+
+ul.abuse-report-reasons > li {
+ display: flex;
+ padding-bottom: var(--listitem-padding-bottom);
+}
+
+ul.abuse-report-reasons > li > label {
+ display: grid;
+ grid-template-columns: var(--list-radio-column-size) auto;
+ grid-template-rows: 50% auto;
+ width: 100%;
+ line-height: var(--line-height);
+ font-size: var(--subtitle-font-size);
+ font-weight: var(--note-font-weight);
+ margin-inline-start: 4px;
+}
+
+ul.abuse-report-reasons > li > label > [type="radio"] {
+ grid-column: 1;
+}
+
+ul.abuse-report-reasons > li > label > span {
+ grid-column: 2;
+}
+
+abuse-report-submit-panel textarea {
+ width: 100%;
+ height: var(--textarea-height);
+ resize: none;
+ box-sizing: border-box;
+}
+
+/* Adapt styles for the panel opened in its own dialog window */
+
+html.dialog-window {
+ background-color: var(--in-content-box-background);
+ height: 100%;
+ min-width: 740px;
+}
+
+html.dialog-window body {
+ overflow: hidden;
+ min-height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+html.dialog-window .abuse-report-close-icon {
+ display: none;
+}
+
+html.dialog-window addon-abuse-report {
+ flex-grow: 1;
+ display: flex;
+ /* Ensure that the dialog window starts from a reasonable initial size */
+ --modal-panel-min-width: 700px;
+}
+
+html.dialog-window addon-abuse-report form {
+ display: flex;
+}
+
+html.dialog-window addon-abuse-report form .abuse-report-contents {
+ flex-grow: 1;
+}
diff --git a/toolkit/mozapps/extensions/content/abuse-report-panel.js b/toolkit/mozapps/extensions/content/abuse-report-panel.js
new file mode 100644
index 0000000000..d1647ae184
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/abuse-report-panel.js
@@ -0,0 +1,886 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* eslint max-len: ["error", 80] */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AbuseReporter: "resource://gre/modules/AbuseReporter.sys.mjs",
+});
+
+const IS_DIALOG_WINDOW = window.arguments && window.arguments.length;
+
+let openWebLink = IS_DIALOG_WINDOW
+ ? window.arguments[0].wrappedJSObject.openWebLink
+ : url => {
+ window.windowRoot.ownerGlobal.openWebLinkIn(url, "tab", {
+ relatedToCurrent: true,
+ });
+ };
+
+const showOnAnyType = () => false;
+const hideOnAnyType = () => true;
+const hideOnAddonTypes = hideForTypes => {
+ return addonType => hideForTypes.includes(addonType);
+};
+
+// The reasons string used as a key in this Map is expected to stay in sync
+// with the reasons string used in the "abuseReports.ftl" locale file and
+// the suggestions templates included in abuse-report-frame.html.
+const ABUSE_REASONS = (window.ABUSE_REPORT_REASONS = {
+ damage: {
+ isExampleHidden: showOnAnyType,
+ isReasonHidden: hideOnAddonTypes(["theme"]),
+ },
+ spam: {
+ isExampleHidden: showOnAnyType,
+ isReasonHidden: hideOnAddonTypes(["sitepermission"]),
+ },
+ settings: {
+ hasSuggestions: true,
+ isExampleHidden: hideOnAnyType,
+ isReasonHidden: hideOnAddonTypes(["theme", "sitepermission"]),
+ },
+ deceptive: {
+ isExampleHidden: showOnAnyType,
+ isReasonHidden: hideOnAddonTypes(["sitepermission"]),
+ },
+ broken: {
+ hasAddonTypeL10nId: true,
+ hasAddonTypeSuggestionTemplate: true,
+ hasSuggestions: true,
+ isExampleHidden: hideOnAddonTypes(["theme"]),
+ isReasonHidden: showOnAnyType,
+ requiresSupportURL: true,
+ },
+ policy: {
+ hasSuggestions: true,
+ isExampleHidden: hideOnAnyType,
+ isReasonHidden: hideOnAddonTypes(["sitepermission"]),
+ },
+ unwanted: {
+ isExampleHidden: showOnAnyType,
+ isReasonHidden: hideOnAddonTypes(["theme"]),
+ },
+ other: {
+ isExampleHidden: hideOnAnyType,
+ isReasonHidden: showOnAnyType,
+ },
+});
+
+// Maps the reason id to the last version of the related fluent id.
+// NOTE: when changing the localized string, increase the `-vN` suffix
+// in the abuseReports.ftl fluent file and update this mapping table.
+const REASON_L10N_STRING_MAPPING = {
+ "abuse-report-damage-reason": "abuse-report-damage-reason-v2",
+ "abuse-report-spam-reason": "abuse-report-spam-reason-v2",
+ "abuse-report-settings-reason": "abuse-report-settings-reason-v2",
+ "abuse-report-deceptive-reason": "abuse-report-deceptive-reason-v2",
+ "abuse-report-broken-reason-extension":
+ "abuse-report-broken-reason-extension-v2",
+ "abuse-report-broken-reason-sitepermission":
+ "abuse-report-broken-reason-sitepermission-v2",
+ "abuse-report-broken-reason-theme": "abuse-report-broken-reason-theme-v2",
+ "abuse-report-policy-reason": "abuse-report-policy-reason-v2",
+ "abuse-report-unwanted-reason": "abuse-report-unwanted-reason-v2",
+};
+
+function getReasonL10nId(reason, addonType) {
+ let reasonId = `abuse-report-${reason}-reason`;
+ // Special case reasons that have a addonType-specific
+ // l10n id.
+ if (ABUSE_REASONS[reason].hasAddonTypeL10nId) {
+ reasonId += `-${addonType}`;
+ }
+ // Map the reason to the corresponding versionized fluent string, using the
+ // mapping table above, if available.
+ return REASON_L10N_STRING_MAPPING[reasonId] || reasonId;
+}
+
+function getSuggestionsTemplate({ addonType, reason, supportURL }) {
+ const reasonInfo = ABUSE_REASONS[reason];
+
+ if (
+ !addonType ||
+ !reasonInfo.hasSuggestions ||
+ (reasonInfo.requiresSupportURL && !supportURL)
+ ) {
+ return null;
+ }
+
+ let templateId = `tmpl-suggestions-${reason}`;
+ // Special case reasons that have a addonType-specific
+ // suggestion template.
+ if (reasonInfo.hasAddonTypeSuggestionTemplate) {
+ templateId += `-${addonType}`;
+ }
+
+ return document.getElementById(templateId);
+}
+
+// Map of the learnmore links metadata, keyed by link element class.
+const LEARNMORE_LINKS = {
+ ".abuse-report-learnmore": {
+ path: "reporting-extensions-and-themes-abuse",
+ },
+ ".abuse-settings-search-learnmore": {
+ path: "prefs-search",
+ },
+ ".abuse-settings-homepage-learnmore": {
+ path: "prefs-homepage",
+ },
+ ".abuse-policy-learnmore": {
+ baseURL: "https://www.mozilla.org/%LOCALE%/",
+ path: "about/legal/report-infringement/",
+ },
+};
+
+// Format links that match the selector in the LEARNMORE_LINKS map
+// found in a given container element.
+function formatLearnMoreURLs(containerEl) {
+ for (const [linkClass, linkInfo] of Object.entries(LEARNMORE_LINKS)) {
+ for (const element of containerEl.querySelectorAll(linkClass)) {
+ const baseURL = linkInfo.baseURL
+ ? Services.urlFormatter.formatURL(linkInfo.baseURL)
+ : Services.urlFormatter.formatURLPref("app.support.baseURL");
+
+ element.href = baseURL + linkInfo.path;
+ }
+ }
+}
+
+// Define a set of getters from a Map<propertyName, selector>.
+function defineElementSelectorsGetters(object, propsMap) {
+ const props = Object.entries(propsMap).reduce((acc, entry) => {
+ const [name, selector] = entry;
+ acc[name] = { get: () => object.querySelector(selector) };
+ return acc;
+ }, {});
+ Object.defineProperties(object, props);
+}
+
+// Define a set of properties getters and setters for a
+// Map<propertyName, attributeName>.
+function defineElementAttributesProperties(object, propsMap) {
+ const props = Object.entries(propsMap).reduce((acc, entry) => {
+ const [name, attr] = entry;
+ acc[name] = {
+ get: () => object.getAttribute(attr),
+ set: value => {
+ object.setAttribute(attr, value);
+ },
+ };
+ return acc;
+ }, {});
+ Object.defineProperties(object, props);
+}
+
+// Return an object with properties associated to elements
+// found using the related selector in the propsMap.
+function getElements(containerEl, propsMap) {
+ return Object.entries(propsMap).reduce((acc, entry) => {
+ const [name, selector] = entry;
+ let elements = containerEl.querySelectorAll(selector);
+ acc[name] = elements.length > 1 ? elements : elements[0];
+ return acc;
+ }, {});
+}
+
+function dispatchCustomEvent(el, eventName, detail) {
+ el.dispatchEvent(new CustomEvent(eventName, { detail }));
+}
+
+// This WebComponent extends the li item to represent an abuse report reason
+// and it is responsible for:
+// - embedding a photon styled radio buttons
+// - localizing the reason list item
+// - optionally embedding a localized example, positioned
+// below the reason label, and adjusts the item height
+// accordingly
+class AbuseReasonListItem extends HTMLLIElement {
+ constructor() {
+ super();
+ defineElementAttributesProperties(this, {
+ addonType: "addon-type",
+ reason: "report-reason",
+ checked: "checked",
+ });
+ }
+
+ connectedCallback() {
+ this.update();
+ }
+
+ async update() {
+ if (this.reason !== "other" && !this.addonType) {
+ return;
+ }
+
+ const { reason, checked, addonType } = this;
+
+ this.textContent = "";
+ const content = document.importNode(this.template.content, true);
+
+ if (reason) {
+ const reasonId = `abuse-reason-${reason}`;
+ const reasonInfo = ABUSE_REASONS[reason] || {};
+
+ const { labelEl, descriptionEl, radioEl } = getElements(content, {
+ labelEl: "label",
+ descriptionEl: ".reason-description",
+ radioEl: "input[type=radio]",
+ });
+
+ labelEl.setAttribute("for", reasonId);
+ radioEl.id = reasonId;
+ radioEl.value = reason;
+ radioEl.checked = !!checked;
+
+ // This reason has a different localized description based on the
+ // addon type.
+ document.l10n.setAttributes(
+ descriptionEl,
+ getReasonL10nId(reason, addonType)
+ );
+
+ // Show the reason example if supported for the addon type.
+ if (!reasonInfo.isExampleHidden(addonType)) {
+ const exampleEl = content.querySelector(".reason-example");
+ document.l10n.setAttributes(
+ exampleEl,
+ `abuse-report-${reason}-example`
+ );
+ exampleEl.hidden = false;
+ }
+ }
+
+ formatLearnMoreURLs(content);
+
+ this.appendChild(content);
+ }
+
+ get template() {
+ return document.getElementById("tmpl-reason-listitem");
+ }
+}
+
+// This WebComponents implements the first step of the abuse
+// report submission and embeds a randomized reasons list.
+class AbuseReasonsPanel extends HTMLElement {
+ constructor() {
+ super();
+ defineElementAttributesProperties(this, {
+ addonType: "addon-type",
+ });
+ }
+
+ connectedCallback() {
+ this.update();
+ }
+
+ update() {
+ if (!this.isConnected || !this.addonType) {
+ return;
+ }
+
+ const { addonType } = this;
+
+ this.textContent = "";
+ const content = document.importNode(this.template.content, true);
+
+ const { titleEl, listEl } = getElements(content, {
+ titleEl: ".abuse-report-title",
+ listEl: "ul.abuse-report-reasons",
+ });
+
+ // Change the title l10n-id if the addon type is theme.
+ document.l10n.setAttributes(titleEl, `abuse-report-title-${addonType}`);
+
+ // Create the randomized list of reasons.
+ const reasons = Object.keys(ABUSE_REASONS)
+ .filter(reason => reason !== "other")
+ .sort(() => Math.random() - 0.5);
+
+ for (const reason of reasons) {
+ const reasonInfo = ABUSE_REASONS[reason];
+ if (!reasonInfo || reasonInfo.isReasonHidden(addonType)) {
+ // Skip an extension only reason while reporting a theme.
+ continue;
+ }
+ const item = document.createElement("li", {
+ is: "abuse-report-reason-listitem",
+ });
+ item.reason = reason;
+ item.addonType = addonType;
+
+ listEl.prepend(item);
+ }
+
+ listEl.firstElementChild.checked = true;
+ formatLearnMoreURLs(content);
+
+ this.appendChild(content);
+ }
+
+ get template() {
+ return document.getElementById("tmpl-reasons-panel");
+ }
+}
+
+// This WebComponent is responsible for the suggestions, which are:
+// - generated based on a template keyed by abuse report reason
+// - localized by assigning fluent ids generated from the abuse report reason
+// - learn more and extension support url are then generated when the
+// specific reason expects it
+class AbuseReasonSuggestions extends HTMLElement {
+ constructor() {
+ super();
+ defineElementAttributesProperties(this, {
+ extensionSupportURL: "extension-support-url",
+ reason: "report-reason",
+ });
+ }
+
+ update() {
+ const { addonType, extensionSupportURL, reason } = this;
+
+ this.textContent = "";
+
+ let template = getSuggestionsTemplate({
+ addonType,
+ reason,
+ supportURL: extensionSupportURL,
+ });
+
+ if (template) {
+ let content = document.importNode(template.content, true);
+
+ formatLearnMoreURLs(content);
+
+ let extSupportLink = content.querySelector("a.extension-support-link");
+ if (extSupportLink) {
+ extSupportLink.href = extensionSupportURL;
+ }
+
+ this.appendChild(content);
+ this.hidden = false;
+ } else {
+ this.hidden = true;
+ }
+ }
+
+ get LEARNMORE_LINKS() {
+ return Object.keys(LEARNMORE_LINKS);
+ }
+}
+
+// This WebComponents implements the last step of the abuse report submission.
+class AbuseSubmitPanel extends HTMLElement {
+ constructor() {
+ super();
+ defineElementAttributesProperties(this, {
+ addonType: "addon-type",
+ reason: "report-reason",
+ extensionSupportURL: "extensionSupportURL",
+ });
+ defineElementSelectorsGetters(this, {
+ _textarea: "textarea",
+ _title: ".abuse-reason-title",
+ _suggestions: "abuse-report-reason-suggestions",
+ });
+ }
+
+ connectedCallback() {
+ this.render();
+ }
+
+ render() {
+ this.textContent = "";
+ this.appendChild(document.importNode(this.template.content, true));
+ }
+
+ update() {
+ if (!this.isConnected || !this.addonType) {
+ return;
+ }
+ const { addonType, reason, _suggestions, _title } = this;
+ document.l10n.setAttributes(_title, getReasonL10nId(reason, addonType));
+ _suggestions.reason = reason;
+ _suggestions.addonType = addonType;
+ _suggestions.extensionSupportURL = this.extensionSupportURL;
+ _suggestions.update();
+ }
+
+ clear() {
+ this._textarea.value = "";
+ }
+
+ get template() {
+ return document.getElementById("tmpl-submit-panel");
+ }
+}
+
+// This WebComponent provides the abuse report
+class AbuseReport extends HTMLElement {
+ constructor() {
+ super();
+ this._report = null;
+ defineElementSelectorsGetters(this, {
+ _form: "form",
+ _textarea: "textarea",
+ _radioCheckedReason: "[type=radio]:checked",
+ _reasonsPanel: "abuse-report-reasons-panel",
+ _submitPanel: "abuse-report-submit-panel",
+ _reasonsPanelButtons: ".abuse-report-reasons-buttons",
+ _submitPanelButtons: ".abuse-report-submit-buttons",
+ _iconClose: ".abuse-report-close-icon",
+ _btnNext: "button.abuse-report-next",
+ _btnCancel: "button.abuse-report-cancel",
+ _btnGoBack: "button.abuse-report-goback",
+ _btnSubmit: "button.abuse-report-submit",
+ _addonAuthorContainer: ".abuse-report-header .addon-author-box",
+ _addonIconElement: ".abuse-report-header img.addon-icon",
+ _addonNameElement: ".abuse-report-header .addon-name",
+ _linkAddonAuthor: ".abuse-report-header .addon-author-box a.author",
+ });
+ }
+
+ connectedCallback() {
+ this.render();
+
+ this.addEventListener("click", this);
+
+ // Start listening to keydown events (to close the modal
+ // when Escape has been pressed and to handling the keyboard
+ // navigation).
+ document.addEventListener("keydown", this);
+ }
+
+ disconnectedCallback() {
+ this.textContent = "";
+ this.removeEventListener("click", this);
+ document.removeEventListener("keydown", this);
+ }
+
+ handleEvent(evt) {
+ if (!this.isConnected || !this.addon) {
+ return;
+ }
+
+ switch (evt.type) {
+ case "keydown":
+ if (evt.key === "Escape") {
+ // Prevent Esc to close the panel if the textarea is
+ // empty.
+ if (this.message && !this._submitPanel.hidden) {
+ return;
+ }
+ this.cancel();
+ }
+ if (!IS_DIALOG_WINDOW) {
+ // Workaround keyboard navigation issues when
+ // the panel is running in its own dialog window.
+ this.handleKeyboardNavigation(evt);
+ }
+ break;
+ case "click":
+ if (evt.target === this._iconClose || evt.target === this._btnCancel) {
+ // NOTE: clear the focus on the clicked element to ensure that
+ // -moz-focusring pseudo class is not still set on the element
+ // when the panel is going to be shown again (See Bug 1560949).
+ evt.target.blur();
+ this.cancel();
+ }
+ if (evt.target === this._btnNext) {
+ this.switchToSubmitMode();
+ }
+ if (evt.target === this._btnGoBack) {
+ this.switchToListMode();
+ }
+ if (evt.target === this._btnSubmit) {
+ this.submit();
+ }
+ if (evt.target.localName === "a") {
+ evt.preventDefault();
+ evt.stopPropagation();
+ const url = evt.target.getAttribute("href");
+ // Ignore if url is empty.
+ if (url) {
+ openWebLink(url);
+ }
+ }
+ break;
+ }
+ }
+
+ handleKeyboardNavigation(evt) {
+ if (
+ evt.keyCode !== evt.DOM_VK_TAB ||
+ evt.altKey ||
+ evt.controlKey ||
+ evt.metaKey
+ ) {
+ return;
+ }
+
+ const fm = Services.focus;
+ const backward = evt.shiftKey;
+
+ const isFirstFocusableElement = el => {
+ // Also consider the document body as a valid first focusable element.
+ if (el === document.body) {
+ return true;
+ }
+ // XXXrpl unfortunately there is no way to get the first focusable element
+ // without asking the focus manager to move focus to it (similar strategy
+ // is also being used in about:prefereces subdialog.js).
+ const rv = el == fm.moveFocus(window, null, fm.MOVEFOCUS_FIRST, 0);
+ fm.setFocus(el, 0);
+ return rv;
+ };
+
+ // If the focus is exiting the panel while navigating
+ // backward, focus the previous element sibling on the
+ // Firefox UI.
+ if (backward && isFirstFocusableElement(evt.target)) {
+ evt.preventDefault();
+ evt.stopImmediatePropagation();
+ const chromeWin = window.windowRoot.ownerGlobal;
+ Services.focus.moveFocus(
+ chromeWin,
+ null,
+ Services.focus.MOVEFOCUS_BACKWARD,
+ Services.focus.FLAG_BYKEY
+ );
+ }
+ }
+
+ render() {
+ this.textContent = "";
+ const formTemplate = document.importNode(this.template.content, true);
+ if (IS_DIALOG_WINDOW) {
+ this.appendChild(formTemplate);
+ } else {
+ // Append the report form inside a modal overlay when the report panel
+ // is a sub-frame of the about:addons tab.
+ const modalTemplate = document.importNode(
+ this.modalTemplate.content,
+ true
+ );
+
+ this.appendChild(modalTemplate);
+ this.querySelector(".modal-panel-container").appendChild(formTemplate);
+
+ // Add the card styles to the form.
+ this.querySelector("form").classList.add("card");
+ }
+ }
+
+ async update() {
+ if (!this.addon) {
+ return;
+ }
+
+ const {
+ addonId,
+ addonType,
+ _addonAuthorContainer,
+ _addonIconElement,
+ _addonNameElement,
+ _linkAddonAuthor,
+ _reasonsPanel,
+ _submitPanel,
+ } = this;
+
+ // Ensure that the first step of the abuse submission is the one
+ // currently visible.
+ this.switchToListMode();
+
+ // Cancel the abuse report if the addon is not an extension or theme.
+ if (!AbuseReporter.isSupportedAddonType(addonType)) {
+ Cu.reportError(
+ new Error(
+ `Closing abuse report panel on unexpected addon type: ${addonType}`
+ )
+ );
+ this.cancel();
+ return;
+ }
+
+ _addonNameElement.textContent = this.addonName;
+
+ if (this.authorName) {
+ _linkAddonAuthor.href = this.authorURL || this.homepageURL;
+ _linkAddonAuthor.textContent = this.authorName;
+ document.l10n.setAttributes(
+ _linkAddonAuthor.parentNode,
+ "abuse-report-addon-authored-by",
+ { "author-name": this.authorName }
+ );
+ _addonAuthorContainer.hidden = false;
+ } else {
+ _addonAuthorContainer.hidden = true;
+ }
+
+ _addonIconElement.setAttribute("src", this.iconURL);
+
+ _reasonsPanel.addonType = this.addonType;
+ _reasonsPanel.update();
+
+ _submitPanel.addonType = this.addonType;
+ _submitPanel.reason = this.reason;
+ _submitPanel.extensionSupportURL = this.supportURL;
+ _submitPanel.update();
+
+ this.focus();
+
+ dispatchCustomEvent(this, "abuse-report:updated", {
+ addonId,
+ panel: "reasons",
+ });
+ }
+
+ setAbuseReport(abuseReport) {
+ this._report = abuseReport;
+ // Clear the textarea from any previously entered content.
+ this._submitPanel.clear();
+
+ if (abuseReport) {
+ this.update();
+ this.hidden = false;
+ } else {
+ this.hidden = true;
+ }
+ }
+
+ focus() {
+ if (!this.isConnected || !this.addon) {
+ return;
+ }
+ if (this._reasonsPanel.hidden) {
+ const { _textarea } = this;
+ _textarea.focus();
+ _textarea.select();
+ } else {
+ const { _radioCheckedReason } = this;
+ if (_radioCheckedReason) {
+ _radioCheckedReason.focus();
+ }
+ }
+ }
+
+ cancel() {
+ if (!this.isConnected || !this.addon) {
+ return;
+ }
+ this._report = null;
+ dispatchCustomEvent(this, "abuse-report:cancel");
+ }
+
+ submit() {
+ if (!this.isConnected || !this.addon) {
+ return;
+ }
+ this._report.setMessage(this.message);
+ this._report.setReason(this.reason);
+ dispatchCustomEvent(this, "abuse-report:submit", {
+ addonId: this.addonId,
+ report: this._report,
+ });
+ }
+
+ switchToSubmitMode() {
+ if (!this.isConnected || !this.addon) {
+ return;
+ }
+ this._submitPanel.reason = this.reason;
+ this._submitPanel.update();
+ this._reasonsPanel.hidden = true;
+ this._reasonsPanelButtons.hidden = true;
+ this._submitPanel.hidden = false;
+ this._submitPanelButtons.hidden = false;
+ // Adjust the focused element when switching to the submit panel.
+ this.focus();
+ dispatchCustomEvent(this, "abuse-report:updated", {
+ addonId: this.addonId,
+ panel: "submit",
+ });
+ }
+
+ switchToListMode() {
+ if (!this.isConnected || !this.addon) {
+ return;
+ }
+ this._submitPanel.hidden = true;
+ this._submitPanelButtons.hidden = true;
+ this._reasonsPanel.hidden = false;
+ this._reasonsPanelButtons.hidden = false;
+ // Adjust the focused element when switching back to the list of reasons.
+ this.focus();
+ dispatchCustomEvent(this, "abuse-report:updated", {
+ addonId: this.addonId,
+ panel: "reasons",
+ });
+ }
+
+ get addon() {
+ return this._report?.addon;
+ }
+
+ get addonId() {
+ return this.addon?.id;
+ }
+
+ get addonName() {
+ return this.addon?.name;
+ }
+
+ get addonType() {
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based
+ // implementation is also removed.
+ if (this.addon?.type === "sitepermission-deprecated") {
+ return "sitepermission";
+ }
+ return this.addon?.type;
+ }
+
+ get addonCreator() {
+ return this.addon?.creator;
+ }
+
+ get homepageURL() {
+ return this.addon?.homepageURL || this.authorURL || "";
+ }
+
+ get authorName() {
+ // The author name may be missing on some of the test extensions
+ // (or for temporarily installed add-ons).
+ return this.addonCreator?.name || "";
+ }
+
+ get authorURL() {
+ return this.addonCreator?.url || "";
+ }
+
+ get iconURL() {
+ if (this.addonType === "sitepermission") {
+ return "chrome://mozapps/skin/extensions/category-sitepermission.svg";
+ }
+ return (
+ this.addon?.iconURL ||
+ // Some extensions (e.g. static theme addons) may not have an icon,
+ // and so we fallback to use the generic extension icon.
+ "chrome://mozapps/skin/extensions/extensionGeneric.svg"
+ );
+ }
+
+ get supportURL() {
+ let url = this.addon?.supportURL || this.homepageURL || "";
+ if (!url && this.addonType === "sitepermission" && this.addon?.siteOrigin) {
+ return this.addon.siteOrigin;
+ }
+ return url;
+ }
+
+ get message() {
+ return this._form.elements.message.value;
+ }
+
+ get reason() {
+ return this._form.elements.reason.value;
+ }
+
+ get modalTemplate() {
+ return document.getElementById("tmpl-modal");
+ }
+
+ get template() {
+ return document.getElementById("tmpl-abuse-report");
+ }
+}
+
+customElements.define("abuse-report-reason-listitem", AbuseReasonListItem, {
+ extends: "li",
+});
+customElements.define(
+ "abuse-report-reason-suggestions",
+ AbuseReasonSuggestions
+);
+customElements.define("abuse-report-reasons-panel", AbuseReasonsPanel);
+customElements.define("abuse-report-submit-panel", AbuseSubmitPanel);
+customElements.define("addon-abuse-report", AbuseReport);
+
+// The panel has been opened in a new dialog window.
+if (IS_DIALOG_WINDOW) {
+ // CSS customizations when panel is in its own window
+ // (vs. being an about:addons subframe).
+ document.documentElement.className = "dialog-window";
+
+ const { report, deferredReport, deferredReportPanel } =
+ window.arguments[0].wrappedJSObject;
+
+ window.addEventListener(
+ "unload",
+ () => {
+ // If the window has been closed resolve the deferredReport
+ // promise and reject the deferredReportPanel one, in case
+ // they haven't been resolved yet.
+ deferredReport.resolve({ userCancelled: true });
+ deferredReportPanel.reject(new Error("report dialog closed"));
+ },
+ { once: true }
+ );
+
+ document.l10n.setAttributes(
+ document.querySelector("head > title"),
+ "abuse-report-dialog-title",
+ {
+ "addon-name": report.addon.name,
+ }
+ );
+
+ const el = document.querySelector("addon-abuse-report");
+ el.addEventListener("abuse-report:submit", () => {
+ deferredReport.resolve({
+ userCancelled: false,
+ report,
+ });
+ });
+ el.addEventListener(
+ "abuse-report:cancel",
+ () => {
+ // Resolve the report panel deferred (in case the report
+ // has been cancelled automatically before it has been fully
+ // rendered, e.g. in case of non-supported addon types).
+ deferredReportPanel.resolve(el);
+ // Resolve the deferred report as cancelled.
+ deferredReport.resolve({ userCancelled: true });
+ },
+ { once: true }
+ );
+
+ // Adjust window size (if needed) once the fluent strings have been
+ // added to the document and the document has been flushed.
+ el.addEventListener(
+ "abuse-report:updated",
+ async () => {
+ const form = document.querySelector("form");
+ await document.l10n.translateFragment(form);
+ const { clientWidth, clientHeight } = await window.promiseDocumentFlushed(
+ () => form
+ );
+ // Resolve promiseReportPanel once the panel completed the initial render
+ // (used in tests).
+ deferredReportPanel.resolve(el);
+ if (
+ window.innerWidth !== clientWidth ||
+ window.innerheight !== clientHeight
+ ) {
+ window.resizeTo(clientWidth, clientHeight);
+ }
+ },
+ { once: true }
+ );
+ el.setAbuseReport(report);
+}
diff --git a/toolkit/mozapps/extensions/content/abuse-reports.js b/toolkit/mozapps/extensions/content/abuse-reports.js
new file mode 100644
index 0000000000..cf5fe27ee5
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/abuse-reports.js
@@ -0,0 +1,317 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint max-len: ["error", 80] */
+/* import-globals-from aboutaddonsCommon.js */
+/* exported openAbuseReport */
+
+/**
+ * This script is part of the HTML about:addons page and it provides some
+ * helpers used for the Abuse Reporting submission (and related message bars).
+ */
+
+const { AbuseReporter } = ChromeUtils.importESModule(
+ "resource://gre/modules/AbuseReporter.sys.mjs"
+);
+
+// Message Bars definitions.
+const ABUSE_REPORT_MESSAGE_BARS = {
+ // Idle message-bar (used while the submission is still ongoing).
+ submitting: { id: "submitting", actions: ["cancel"] },
+ // Submitted report message-bar.
+ submitted: {
+ id: "submitted",
+ actionAddonTypeSuffix: true,
+ actions: ["remove", "keep"],
+ dismissable: true,
+ },
+ // Submitted report message-bar (with no remove actions).
+ "submitted-no-remove-action": {
+ id: "submitted-noremove",
+ dismissable: true,
+ },
+ // Submitted report and remove addon message-bar.
+ "submitted-and-removed": {
+ id: "removed",
+ addonTypeSuffix: true,
+ dismissable: true,
+ },
+ // The "aborted report" message bar is rendered as a generic informative one,
+ // because aborting a report is triggered by a user choice.
+ ERROR_ABORTED_SUBMIT: {
+ id: "aborted",
+ type: "generic",
+ dismissable: true,
+ },
+ // Errors message bars.
+ ERROR_ADDON_NOTFOUND: {
+ id: "error",
+ type: "error",
+ dismissable: true,
+ },
+ ERROR_CLIENT: {
+ id: "error",
+ type: "error",
+ dismissable: true,
+ },
+ ERROR_NETWORK: {
+ id: "error",
+ actions: ["retry", "cancel"],
+ type: "error",
+ },
+ ERROR_RECENT_SUBMIT: {
+ id: "error-recent-submit",
+ actions: ["retry", "cancel"],
+ type: "error",
+ },
+ ERROR_SERVER: {
+ id: "error",
+ actions: ["retry", "cancel"],
+ type: "error",
+ },
+ ERROR_UNKNOWN: {
+ id: "error",
+ actions: ["retry", "cancel"],
+ type: "error",
+ },
+};
+
+async function openAbuseReport({ addonId, reportEntryPoint }) {
+ try {
+ const reportDialog = await AbuseReporter.openDialog(
+ addonId,
+ reportEntryPoint,
+ window.docShell.chromeEventHandler
+ );
+
+ // Warn the user before the about:addons tab while an
+ // abuse report dialog is still open, and close the
+ // report dialog if the user choose to close the related
+ // about:addons tab.
+ const beforeunloadListener = evt => evt.preventDefault();
+ const unloadListener = () => reportDialog.close();
+ const clearUnloadListeners = () => {
+ window.removeEventListener("beforeunload", beforeunloadListener);
+ window.removeEventListener("unload", unloadListener);
+ };
+ window.addEventListener("beforeunload", beforeunloadListener);
+ window.addEventListener("unload", unloadListener);
+
+ reportDialog.promiseReport
+ .then(
+ report => {
+ if (report) {
+ submitReport({ report });
+ }
+ },
+ err => {
+ Cu.reportError(
+ `Unexpected abuse report panel error: ${err} :: ${err.stack}`
+ );
+ reportDialog.close();
+ }
+ )
+ .then(clearUnloadListeners);
+ } catch (err) {
+ // Log the detailed error to the browser console.
+ Cu.reportError(err);
+ document.dispatchEvent(
+ new CustomEvent("abuse-report:create-error", {
+ detail: {
+ addonId,
+ addon: err.addon,
+ errorType: err.errorType,
+ },
+ })
+ );
+ }
+}
+
+window.openAbuseReport = openAbuseReport;
+
+// Helper function used to create abuse report message bars in the
+// HTML about:addons page.
+function createReportMessageBar(
+ definitionId,
+ { addonId, addonName, addonType },
+ { onclose, onaction } = {}
+) {
+ const getMessageL10n = id => `abuse-report-messagebar-${id}`;
+ const getActionL10n = action => getMessageL10n(`action-${action}`);
+
+ const barInfo = ABUSE_REPORT_MESSAGE_BARS[definitionId];
+ if (!barInfo) {
+ throw new Error(`message-bar definition not found: ${definitionId}`);
+ }
+ const { id, dismissable, actions, type } = barInfo;
+ const messageEl = document.createElement("span");
+
+ // The message element includes an addon-name span (also filled by
+ // Fluent), which can be used to apply custom styles to the addon name
+ // included in the message bar (if needed).
+ const addonNameEl = document.createElement("span");
+ addonNameEl.setAttribute("data-l10n-name", "addon-name");
+ messageEl.append(addonNameEl);
+
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based
+ // implementation is also removed.
+ const mappingAddonType =
+ addonType === "sitepermission-deprecated" ? "sitepermission" : addonType;
+
+ document.l10n.setAttributes(
+ messageEl,
+ getMessageL10n(barInfo.addonTypeSuffix ? `${id}-${mappingAddonType}` : id),
+ { "addon-name": addonName || addonId }
+ );
+
+ const barActions = actions
+ ? actions.map(action => {
+ // Some of the message bars require a different per addonType
+ // Fluent id for their actions.
+ const actionId = barInfo.actionAddonTypeSuffix
+ ? `${action}-${mappingAddonType}`
+ : action;
+ const buttonEl = document.createElement("button");
+ buttonEl.addEventListener("click", () => onaction && onaction(action));
+ document.l10n.setAttributes(buttonEl, getActionL10n(actionId));
+ return buttonEl;
+ })
+ : [];
+
+ const messagebar = document.createElement("message-bar");
+ messagebar.setAttribute("type", type || "generic");
+ if (dismissable) {
+ messagebar.setAttribute("dismissable", "");
+ }
+ messagebar.append(messageEl, ...barActions);
+ messagebar.addEventListener("message-bar:close", onclose, { once: true });
+
+ document.getElementById("abuse-reports-messages").append(messagebar);
+
+ document.dispatchEvent(
+ new CustomEvent("abuse-report:new-message-bar", {
+ detail: { definitionId, messagebar },
+ })
+ );
+ return messagebar;
+}
+
+async function submitReport({ report }) {
+ const { addon } = report;
+ const addonId = addon.id;
+ const addonName = addon.name;
+ const addonType = addon.type;
+
+ // Ensure that the tab that originated the report dialog is selected
+ // when the user is submitting the report.
+ const { gBrowser } = window.windowRoot.ownerGlobal;
+ if (gBrowser && gBrowser.getTabForBrowser) {
+ let tab = gBrowser.getTabForBrowser(window.docShell.chromeEventHandler);
+ gBrowser.selectedTab = tab;
+ }
+
+ // Create a message bar while we are still submitting the report.
+ const mbSubmitting = createReportMessageBar(
+ "submitting",
+ { addonId, addonName, addonType },
+ {
+ onaction: action => {
+ if (action === "cancel") {
+ report.abort();
+ mbSubmitting.remove();
+ }
+ },
+ }
+ );
+
+ try {
+ await report.submit();
+ mbSubmitting.remove();
+
+ // Create a submitted message bar when the submission has been
+ // successful.
+ let barId;
+ if (
+ !(addon.permissions & AddonManager.PERM_CAN_UNINSTALL) &&
+ !isPending(addon, "uninstall")
+ ) {
+ // Do not offer remove action if the addon can't be uninstalled.
+ barId = "submitted-no-remove-action";
+ } else if (report.reportEntryPoint === "uninstall") {
+ // With reportEntryPoint "uninstall" a specific message bar
+ // is going to be used.
+ barId = "submitted-and-removed";
+ } else {
+ // All the other reportEntryPoint ("menu" and "toolbar_context_menu")
+ // use the same kind of message bar.
+ barId = "submitted";
+ }
+
+ const mbInfo = createReportMessageBar(
+ barId,
+ {
+ addonId,
+ addonName,
+ addonType,
+ },
+ {
+ onaction: action => {
+ mbInfo.remove();
+ // action "keep" doesn't require any further action,
+ // just handle "remove".
+ if (action === "remove") {
+ report.addon.uninstall(true);
+ }
+ },
+ }
+ );
+ } catch (err) {
+ // Log the complete error in the console.
+ console.error("Error submitting abuse report for", addonId, err);
+ mbSubmitting.remove();
+ // The report has a submission error, create a error message bar which
+ // may optionally allow the user to retry to submit the same report.
+ const barId =
+ err.errorType in ABUSE_REPORT_MESSAGE_BARS
+ ? err.errorType
+ : "ERROR_UNKNOWN";
+
+ const mbError = createReportMessageBar(
+ barId,
+ {
+ addonId,
+ addonName,
+ addonType,
+ },
+ {
+ onaction: action => {
+ mbError.remove();
+ switch (action) {
+ case "retry":
+ submitReport({ report });
+ break;
+ case "cancel":
+ report.abort();
+ break;
+ }
+ },
+ }
+ );
+ }
+}
+
+document.addEventListener("abuse-report:submit", ({ detail }) => {
+ submitReport(detail);
+});
+
+document.addEventListener("abuse-report:create-error", ({ detail }) => {
+ const { addonId, addon, errorType } = detail;
+ const barId =
+ errorType in ABUSE_REPORT_MESSAGE_BARS ? errorType : "ERROR_UNKNOWN";
+ createReportMessageBar(barId, {
+ addonId,
+ addonName: addon && addon.name,
+ addonType: addon && addon.type,
+ });
+});
diff --git a/toolkit/mozapps/extensions/content/drag-drop-addon-installer.js b/toolkit/mozapps/extensions/content/drag-drop-addon-installer.js
new file mode 100644
index 0000000000..0a4f3f749c
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/drag-drop-addon-installer.js
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* import-globals-from aboutaddonsCommon.js */
+
+"use strict";
+
+class DragDropAddonInstaller extends HTMLElement {
+ connectedCallback() {
+ window.addEventListener("drop", this);
+ }
+
+ disconnectedCallback() {
+ window.removeEventListener("drop", this);
+ }
+
+ canInstallFromEvent(e) {
+ let types = e.dataTransfer.types;
+ return (
+ types.includes("text/uri-list") ||
+ types.includes("text/x-moz-url") ||
+ types.includes("application/x-moz-file")
+ );
+ }
+
+ handleEvent(e) {
+ if (!XPINSTALL_ENABLED) {
+ // Nothing to do if we can't install add-ons.
+ return;
+ }
+
+ if (e.type == "drop" && this.canInstallFromEvent(e)) {
+ this.onDrop(e);
+ }
+ }
+
+ async onDrop(e) {
+ e.preventDefault();
+
+ let dataTransfer = e.dataTransfer;
+ let browser = getBrowserElement();
+ let urls = [];
+
+ // Convert every dropped item into a url, without this should be sync.
+ for (let i = 0; i < dataTransfer.mozItemCount; i++) {
+ let url = dataTransfer.mozGetDataAt("text/uri-list", i);
+ if (!url) {
+ url = dataTransfer.mozGetDataAt("text/x-moz-url", i);
+ }
+ if (url) {
+ url = url.split("\n")[0];
+ } else {
+ let file = dataTransfer.mozGetDataAt("application/x-moz-file", i);
+ if (file) {
+ url = Services.io.newFileURI(file).spec;
+ }
+ }
+
+ if (url) {
+ urls.push(url);
+ }
+ }
+
+ // Install the add-ons, the await clears the event data so we do this last.
+ for (let url of urls) {
+ let install = await AddonManager.getInstallForURL(url, {
+ telemetryInfo: {
+ source: "about:addons",
+ method: "drag-and-drop",
+ },
+ });
+
+ AddonManager.installAddonFromAOM(
+ browser,
+ document.documentURIObject,
+ install
+ );
+ }
+ }
+}
+customElements.define("drag-drop-addon-installer", DragDropAddonInstaller);
diff --git a/toolkit/mozapps/extensions/content/rating-star.css b/toolkit/mozapps/extensions/content/rating-star.css
new file mode 100644
index 0000000000..b3a463b61a
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/rating-star.css
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:host {
+ --rating-star-size: 1em;
+ --rating-star-spacing: 0.3ch;
+
+ display: inline-grid;
+ grid-template-columns: repeat(5, var(--rating-star-size));
+ grid-column-gap: var(--rating-star-spacing);
+ align-content: center;
+}
+
+:host([hidden]) {
+ display: none;
+}
+
+.rating-star {
+ display: inline-block;
+ width: var(--rating-star-size);
+ height: var(--rating-star-size);
+ background-image: url("chrome://mozapps/skin/extensions/rating-star.svg#empty");
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: 100%;
+
+ fill: currentColor;
+ -moz-context-properties: fill;
+}
+
+.rating-star[fill="half"] {
+ background-image: url("chrome://mozapps/skin/extensions/rating-star.svg#half");
+}
+.rating-star[fill="full"] {
+ background-image: url("chrome://mozapps/skin/extensions/rating-star.svg#full");
+}
+
+.rating-star[fill="half"]:dir(rtl) {
+ transform: scaleX(-1);
+}
diff --git a/toolkit/mozapps/extensions/content/shortcuts.css b/toolkit/mozapps/extensions/content/shortcuts.css
new file mode 100644
index 0000000000..d96845f9f9
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/shortcuts.css
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.shortcut.card {
+ margin-bottom: 16px;
+}
+
+.shortcut.card:first-of-type {
+ margin-top: 8px;
+}
+
+.shortcut.card:hover {
+ box-shadow: var(--card-shadow);
+}
+
+.shortcut.card .card-heading-icon {
+ width: 24px;
+ height: 24px;
+ margin-inline-end: 16px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.card-heading {
+ display: flex;
+ font-weight: 600;
+}
+
+.shortcuts-empty-label {
+ margin-top: 16px;
+}
+
+.shortcut-row {
+ display: flex;
+ align-items: center;
+ margin-top: 10px;
+}
+
+.shortcut.card:not([expanded]) > .shortcut-row[hide-before-expand] {
+ display: none;
+}
+
+.shortcut-label {
+ flex-grow: 1;
+}
+
+.shortcut-remove-button {
+ background-image: url("chrome://global/skin/icons/delete.svg");
+ background-position: center;
+ background-repeat: no-repeat;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ min-width: 32px;
+}
+
+.shortcut-input[shortcut=""] + .shortcut-remove-button {
+ visibility: hidden;
+}
+
+.expand-row {
+ display: flex;
+ justify-content: center;
+}
+
+.expand-button {
+ margin: 8px 0 0;
+}
+
+.expand-button[warning]:not(:focus) {
+ outline: 2px solid var(--yellow-60);
+ outline-offset: -1px;
+ box-shadow: 0 0 0 4px var(--yellow-60-a30);
+}
+
+.shortcut-input {
+ /* Shortcuts should always be left-to-right. */
+ direction: ltr;
+ text-align: match-parent;
+}
+
+.extension-heading {
+ display: flex;
+}
+
+.error-message {
+ --error-background: var(--red-60);
+ --warning-background: var(--yellow-50);
+ --warning-text-color: var(--yellow-90);
+ color: white;
+ display: flex;
+ flex-direction: column;
+ position: absolute;
+ visibility: hidden;
+}
+
+.error-message-icon {
+ margin-inline-start: 10px;
+ width: 14px;
+ height: 8px;
+ fill: var(--error-background);
+ stroke: var(--error-background);
+ -moz-context-properties: fill, stroke;
+}
+
+.error-message[type="warning"] > .error-message-icon {
+ fill: var(--warning-background);
+ stroke: var(--warning-background);
+}
+
+.error-message-label {
+ background-color: var(--error-background);
+ border-radius: 2px;
+ margin: 0;
+ margin-inline-end: 8px;
+ max-width: 300px;
+ padding: 5px 10px;
+ word-wrap: break-word;
+}
+
+.error-message[type="warning"] > .error-message-label {
+ background-color: var(--warning-background);
+ color: var(--warning-text-color);
+}
+
+.error-message-arrow {
+ background-color: var(--error-background);
+ content: "";
+ max-height: 8px;
+ width: 8px;
+ transform: translate(4px, -6px) rotate(45deg);
+ position: absolute;
+}
+
+/* The margin between message bars. */
+message-bar-stack > * {
+ margin-bottom: 8px;
+}
diff --git a/toolkit/mozapps/extensions/content/shortcuts.js b/toolkit/mozapps/extensions/content/shortcuts.js
new file mode 100644
index 0000000000..516ed21088
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/shortcuts.js
@@ -0,0 +1,659 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* import-globals-from aboutaddonsCommon.js */
+
+"use strict";
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionShortcutKeyMap: "resource://gre/modules/ExtensionShortcuts.sys.mjs",
+ ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs",
+});
+
+{
+ const FALLBACK_ICON = "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+ const COLLAPSE_OPTIONS = {
+ limit: 5, // We only want to show 5 when collapsed.
+ allowOver: 1, // Avoid collapsing to hide 1 row.
+ };
+
+ let templatesLoaded = false;
+ let shortcutKeyMap = new ExtensionShortcutKeyMap();
+ const templates = {};
+
+ function loadTemplates() {
+ if (templatesLoaded) {
+ return;
+ }
+ templatesLoaded = true;
+
+ templates.view = document.getElementById("shortcut-view");
+ templates.card = document.getElementById("shortcut-card-template");
+ templates.row = document.getElementById("shortcut-row-template");
+ templates.noAddons = document.getElementById("shortcuts-no-addons");
+ templates.expandRow = document.getElementById("expand-row-template");
+ templates.noShortcutAddons = document.getElementById(
+ "shortcuts-no-commands-template"
+ );
+ }
+
+ function extensionForAddonId(id) {
+ let policy = WebExtensionPolicy.getByID(id);
+ return policy && policy.extension;
+ }
+
+ let builtInNames = new Map([
+ ["_execute_action", "shortcuts-browserAction2"],
+ ["_execute_browser_action", "shortcuts-browserAction2"],
+ ["_execute_page_action", "shortcuts-pageAction"],
+ ["_execute_sidebar_action", "shortcuts-sidebarAction"],
+ ]);
+ let getCommandDescriptionId = command => {
+ if (!command.description && builtInNames.has(command.name)) {
+ return builtInNames.get(command.name);
+ }
+ return null;
+ };
+
+ const _functionKeys = [
+ "F1",
+ "F2",
+ "F3",
+ "F4",
+ "F5",
+ "F6",
+ "F7",
+ "F8",
+ "F9",
+ "F10",
+ "F11",
+ "F12",
+ ];
+ const functionKeys = new Set(_functionKeys);
+ const validKeys = new Set([
+ "Home",
+ "End",
+ "PageUp",
+ "PageDown",
+ "Insert",
+ "Delete",
+ "0",
+ "1",
+ "2",
+ "3",
+ "4",
+ "5",
+ "6",
+ "7",
+ "8",
+ "9",
+ ..._functionKeys,
+ "MediaNextTrack",
+ "MediaPlayPause",
+ "MediaPrevTrack",
+ "MediaStop",
+ "A",
+ "B",
+ "C",
+ "D",
+ "E",
+ "F",
+ "G",
+ "H",
+ "I",
+ "J",
+ "K",
+ "L",
+ "M",
+ "N",
+ "O",
+ "P",
+ "Q",
+ "R",
+ "S",
+ "T",
+ "U",
+ "V",
+ "W",
+ "X",
+ "Y",
+ "Z",
+ "Up",
+ "Down",
+ "Left",
+ "Right",
+ "Comma",
+ "Period",
+ "Space",
+ ]);
+
+ /**
+ * Trim a valid prefix from an event string.
+ *
+ * "Digit3" ~> "3"
+ * "ArrowUp" ~> "Up"
+ * "W" ~> "W"
+ *
+ * @param {string} string The input string.
+ * @returns {string} The trimmed string, or unchanged.
+ */
+ function trimPrefix(string) {
+ return string.replace(/^(?:Digit|Numpad|Arrow)/, "");
+ }
+
+ const remapKeys = {
+ ",": "Comma",
+ ".": "Period",
+ " ": "Space",
+ };
+ /**
+ * Map special keys to their shortcut name.
+ *
+ * "," ~> "Comma"
+ * " " ~> "Space"
+ *
+ * @param {string} string The input string.
+ * @returns {string} The remapped string, or unchanged.
+ */
+ function remapKey(string) {
+ if (remapKeys.hasOwnProperty(string)) {
+ return remapKeys[string];
+ }
+ return string;
+ }
+
+ const keyOptions = [
+ e => String.fromCharCode(e.which), // A letter?
+ e => e.code.toUpperCase(), // A letter.
+ e => trimPrefix(e.code), // Digit3, ArrowUp, Numpad9.
+ e => trimPrefix(e.key), // Digit3, ArrowUp, Numpad9.
+ e => remapKey(e.key), // Comma, Period, Space.
+ ];
+ /**
+ * Map a DOM event to a shortcut string character.
+ *
+ * For example:
+ *
+ * "a" ~> "A"
+ * "Digit3" ~> "3"
+ * "," ~> "Comma"
+ *
+ * @param {object} event A KeyboardEvent.
+ * @returns {string} A string corresponding to the pressed key.
+ */
+ function getStringForEvent(event) {
+ for (let option of keyOptions) {
+ let value = option(event);
+ if (validKeys.has(value)) {
+ return value;
+ }
+ }
+
+ return "";
+ }
+
+ function getShortcutValue(shortcut) {
+ if (!shortcut) {
+ // Ensure the shortcut is a string, even if it is unset.
+ return null;
+ }
+
+ let modifiers = shortcut.split("+");
+ let key = modifiers.pop();
+
+ if (modifiers.length) {
+ let modifiersAttribute = ShortcutUtils.getModifiersAttribute(modifiers);
+ let displayString =
+ ShortcutUtils.getModifierString(modifiersAttribute) + key;
+ return displayString;
+ }
+
+ if (functionKeys.has(key)) {
+ return key;
+ }
+
+ return null;
+ }
+
+ let error;
+
+ function setError(...args) {
+ setInputMessage("error", ...args);
+ }
+
+ function setWarning(...args) {
+ setInputMessage("warning", ...args);
+ }
+
+ function setInputMessage(type, input, messageId, args) {
+ let { x, y, height, right } = input.getBoundingClientRect();
+ error.style.top = `${y + window.scrollY + height - 5}px`;
+
+ if (document.dir == "ltr") {
+ error.style.left = `${x}px`;
+ error.style.right = null;
+ } else {
+ error.style.right = `${document.documentElement.clientWidth - right}px`;
+ error.style.left = null;
+ }
+
+ error.setAttribute("type", type);
+ document.l10n.setAttributes(
+ error.querySelector(".error-message-label"),
+ messageId,
+ args
+ );
+ error.style.visibility = "visible";
+ }
+
+ function inputBlurred(e) {
+ error.style.visibility = "hidden";
+ e.target.value = getShortcutValue(e.target.getAttribute("shortcut"));
+ }
+
+ function onFocus(e) {
+ e.target.value = "";
+
+ let warning = e.target.getAttribute("warning");
+ if (warning) {
+ setWarning(e.target, warning);
+ }
+ }
+
+ function getShortcutForEvent(e) {
+ let modifierMap;
+
+ if (AppConstants.platform == "macosx") {
+ modifierMap = {
+ MacCtrl: e.ctrlKey,
+ Alt: e.altKey,
+ Command: e.metaKey,
+ Shift: e.shiftKey,
+ };
+ } else {
+ modifierMap = {
+ Ctrl: e.ctrlKey,
+ Alt: e.altKey,
+ Shift: e.shiftKey,
+ };
+ }
+
+ return Object.entries(modifierMap)
+ .filter(([key, isDown]) => isDown)
+ .map(([key]) => key)
+ .concat(getStringForEvent(e))
+ .join("+");
+ }
+
+ async function buildDuplicateShortcutsMap(addons) {
+ await shortcutKeyMap.buildForAddonIds(addons.map(addon => addon.id));
+ }
+
+ function recordShortcut(shortcut, addonName, commandName) {
+ shortcutKeyMap.recordShortcut(shortcut, addonName, commandName);
+ }
+
+ function removeShortcut(shortcut, addonName, commandName) {
+ shortcutKeyMap.removeShortcut(shortcut, addonName, commandName);
+ }
+
+ function getAddonName(shortcut) {
+ return shortcutKeyMap.getFirstAddonName(shortcut);
+ }
+
+ function setDuplicateWarnings() {
+ let warningHolder = document.getElementById("duplicate-warning-messages");
+ clearWarnings(warningHolder);
+ for (let [shortcut, addons] of shortcutKeyMap) {
+ if (addons.size > 1) {
+ warningHolder.appendChild(createDuplicateWarningBar(shortcut));
+ markDuplicates(shortcut);
+ }
+ }
+ }
+
+ function clearWarnings(warningHolder) {
+ warningHolder.textContent = "";
+ let inputs = document.querySelectorAll(".shortcut-input[warning]");
+ for (let input of inputs) {
+ input.removeAttribute("warning");
+ let row = input.closest(".shortcut-row");
+ if (row.hasAttribute("hide-before-expand")) {
+ row
+ .closest(".card")
+ .querySelector(".expand-button")
+ .removeAttribute("warning");
+ }
+ }
+ }
+
+ function createDuplicateWarningBar(shortcut) {
+ let messagebar = document.createElement("message-bar");
+ messagebar.setAttribute("type", "warning");
+
+ let message = document.createElement("span");
+ document.l10n.setAttributes(
+ message,
+ "shortcuts-duplicate-warning-message",
+ { shortcut }
+ );
+
+ messagebar.append(message);
+ return messagebar;
+ }
+
+ function markDuplicates(shortcut) {
+ let inputs = document.querySelectorAll(
+ `.shortcut-input[shortcut="${shortcut}"]`
+ );
+ for (let input of inputs) {
+ input.setAttribute("warning", "shortcuts-duplicate");
+ let row = input.closest(".shortcut-row");
+ if (row.hasAttribute("hide-before-expand")) {
+ row
+ .closest(".card")
+ .querySelector(".expand-button")
+ .setAttribute("warning", "shortcuts-duplicate");
+ }
+ }
+ }
+
+ function onShortcutChange(e) {
+ let input = e.target;
+
+ if (e.key == "Escape") {
+ input.blur();
+ return;
+ }
+
+ if (e.key == "Tab") {
+ return;
+ }
+
+ if (!e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
+ if (e.key == "Delete" || e.key == "Backspace") {
+ // Avoid triggering back-navigation.
+ e.preventDefault();
+ assignShortcutToInput(input, "");
+ return;
+ }
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Some system actions aren't in the keyset, handle them independantly.
+ if (ShortcutUtils.getSystemActionForEvent(e)) {
+ e.defaultCancelled = true;
+ setError(input, "shortcuts-system");
+ return;
+ }
+
+ let shortcutString = getShortcutForEvent(e);
+ input.value = getShortcutValue(shortcutString);
+
+ if (e.type == "keyup" || !shortcutString.length) {
+ return;
+ }
+
+ let validation = ShortcutUtils.validate(shortcutString);
+ switch (validation) {
+ case ShortcutUtils.IS_VALID:
+ // Show an error if this is already a system shortcut.
+ let chromeWindow = window.windowRoot.ownerGlobal;
+ if (ShortcutUtils.isSystem(chromeWindow, shortcutString)) {
+ setError(input, "shortcuts-system");
+ break;
+ }
+
+ // Check if shortcut is already assigned.
+ if (shortcutKeyMap.has(shortcutString)) {
+ setError(input, "shortcuts-exists", {
+ addon: getAddonName(shortcutString),
+ });
+ } else {
+ // Update the shortcut if it isn't reserved or assigned.
+ assignShortcutToInput(input, shortcutString);
+ }
+ break;
+ case ShortcutUtils.MODIFIER_REQUIRED:
+ if (AppConstants.platform == "macosx") {
+ setError(input, "shortcuts-modifier-mac");
+ } else {
+ setError(input, "shortcuts-modifier-other");
+ }
+ break;
+ case ShortcutUtils.INVALID_COMBINATION:
+ setError(input, "shortcuts-invalid");
+ break;
+ case ShortcutUtils.INVALID_KEY:
+ setError(input, "shortcuts-letter");
+ break;
+ }
+ }
+
+ function onShortcutRemove(e) {
+ let removeButton = e.target;
+ let input = removeButton.parentNode.querySelector(".shortcut-input");
+ if (input.getAttribute("shortcut")) {
+ input.value = "";
+ assignShortcutToInput(input, "");
+ }
+ }
+
+ function assignShortcutToInput(input, shortcutString) {
+ let addonId = input.closest(".card").getAttribute("addon-id");
+ let extension = extensionForAddonId(addonId);
+
+ let oldShortcut = input.getAttribute("shortcut");
+ let addonName = input.closest(".card").getAttribute("addon-name");
+ let commandName = input.getAttribute("name");
+
+ removeShortcut(oldShortcut, addonName, commandName);
+ recordShortcut(shortcutString, addonName, commandName);
+
+ // This is async, but we're not awaiting it to keep the handler sync.
+ extension.shortcuts.updateCommand({
+ name: commandName,
+ shortcut: shortcutString,
+ });
+ input.setAttribute("shortcut", shortcutString);
+ input.blur();
+ setDuplicateWarnings();
+ }
+
+ function renderNoShortcutAddons(addons) {
+ let fragment = document.importNode(
+ templates.noShortcutAddons.content,
+ true
+ );
+ let list = fragment.querySelector(".shortcuts-no-commands-list");
+ for (let addon of addons) {
+ let addonItem = document.createElement("li");
+ addonItem.textContent = addon.name;
+ addonItem.setAttribute("addon-id", addon.id);
+ list.appendChild(addonItem);
+ }
+
+ return fragment;
+ }
+
+ async function renderAddons(addons) {
+ let frag = document.createDocumentFragment();
+ let noShortcutAddons = [];
+
+ await buildDuplicateShortcutsMap(addons);
+
+ let isDuplicate = command => {
+ if (command.shortcut) {
+ let dupes = shortcutKeyMap.get(command.shortcut);
+ return dupes.size > 1;
+ }
+ return false;
+ };
+
+ for (let addon of addons) {
+ let extension = extensionForAddonId(addon.id);
+
+ // Skip this extension if it isn't a webextension.
+ if (!extension) {
+ continue;
+ }
+
+ if (extension.shortcuts) {
+ let card = document.importNode(
+ templates.card.content,
+ true
+ ).firstElementChild;
+ let icon = AddonManager.getPreferredIconURL(addon, 24, window);
+ card.setAttribute("addon-id", addon.id);
+ card.setAttribute("addon-name", addon.name);
+ card.querySelector(".addon-icon").src = icon || FALLBACK_ICON;
+ card.querySelector(".addon-name").textContent = addon.name;
+
+ let commands = await extension.shortcuts.allCommands();
+
+ // Sort the commands so the ones with shortcuts are at the top.
+ commands.sort((a, b) => {
+ if (isDuplicate(a) && isDuplicate(b)) {
+ return 0;
+ }
+ if (isDuplicate(a)) {
+ return -1;
+ }
+ if (isDuplicate(b)) {
+ return 1;
+ }
+ // Boolean compare the shortcuts to see if they're both set or unset.
+ if (!a.shortcut == !b.shortcut) {
+ return 0;
+ }
+ if (a.shortcut) {
+ return -1;
+ }
+ return 1;
+ });
+
+ let { limit, allowOver } = COLLAPSE_OPTIONS;
+ let willHideCommands = commands.length > limit + allowOver;
+ let firstHiddenInput;
+
+ for (let i = 0; i < commands.length; i++) {
+ let command = commands[i];
+
+ let row = document.importNode(
+ templates.row.content,
+ true
+ ).firstElementChild;
+
+ if (willHideCommands && i >= limit) {
+ row.setAttribute("hide-before-expand", "true");
+ }
+
+ let label = row.querySelector(".shortcut-label");
+ let descriptionId = getCommandDescriptionId(command);
+ if (descriptionId) {
+ document.l10n.setAttributes(label, descriptionId);
+ } else {
+ label.textContent = command.description || command.name;
+ }
+ let input = row.querySelector(".shortcut-input");
+ input.value = getShortcutValue(command.shortcut);
+ input.setAttribute("name", command.name);
+ input.setAttribute("shortcut", command.shortcut);
+ input.addEventListener("keydown", onShortcutChange);
+ input.addEventListener("keyup", onShortcutChange);
+ input.addEventListener("blur", inputBlurred);
+ input.addEventListener("focus", onFocus);
+
+ let removeButton = row.querySelector(".shortcut-remove-button");
+ removeButton.addEventListener("click", onShortcutRemove);
+
+ if (willHideCommands && i == limit) {
+ firstHiddenInput = input;
+ }
+
+ card.appendChild(row);
+ }
+
+ // Add an expand button, if needed.
+ if (willHideCommands) {
+ let row = document.importNode(templates.expandRow.content, true);
+ let button = row.querySelector(".expand-button");
+ let numberToShow = commands.length - limit;
+ let setLabel = type => {
+ document.l10n.setAttributes(
+ button,
+ `shortcuts-card-${type}-button`,
+ {
+ numberToShow,
+ }
+ );
+ };
+
+ setLabel("expand");
+ button.addEventListener("click", event => {
+ let expanded = card.hasAttribute("expanded");
+ if (expanded) {
+ card.removeAttribute("expanded");
+ setLabel("expand");
+ } else {
+ card.setAttribute("expanded", "true");
+ setLabel("collapse");
+ // If this as a keyboard event then focus the next input.
+ if (event.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
+ firstHiddenInput.focus();
+ }
+ }
+ });
+ card.appendChild(row);
+ }
+
+ frag.appendChild(card);
+ } else if (!addon.hidden) {
+ noShortcutAddons.push({ id: addon.id, name: addon.name });
+ }
+ }
+
+ if (noShortcutAddons.length) {
+ frag.appendChild(renderNoShortcutAddons(noShortcutAddons));
+ }
+
+ return frag;
+ }
+
+ class AddonShortcuts extends HTMLElement {
+ connectedCallback() {
+ setDuplicateWarnings();
+ }
+
+ disconnectedCallback() {
+ error = null;
+ }
+
+ async render() {
+ loadTemplates();
+ let allAddons = await AddonManager.getAddonsByTypes(["extension"]);
+ let addons = allAddons
+ .filter(addon => addon.isActive)
+ .sort((a, b) => a.name.localeCompare(b.name));
+ let frag;
+
+ if (addons.length) {
+ frag = await renderAddons(addons);
+ } else {
+ frag = document.importNode(templates.noAddons.content, true);
+ }
+
+ this.textContent = "";
+ this.appendChild(document.importNode(templates.view.content, true));
+ error = this.querySelector(".error-message");
+ this.appendChild(frag);
+ }
+ }
+ customElements.define("addon-shortcuts", AddonShortcuts);
+}
diff --git a/toolkit/mozapps/extensions/content/view-controller.js b/toolkit/mozapps/extensions/content/view-controller.js
new file mode 100644
index 0000000000..7e9516024c
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/view-controller.js
@@ -0,0 +1,201 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* import-globals-from /toolkit/content/customElements.js */
+/* import-globals-from aboutaddonsCommon.js */
+/* exported loadView */
+
+// Used by external callers to load a specific view into the manager
+function loadView(viewId) {
+ if (!gViewController.readyForLoadView) {
+ throw new Error("loadView called before about:addons is initialized");
+ }
+ gViewController.loadView(viewId);
+}
+
+/**
+ * Helper for saving and restoring the scroll offsets when a previously loaded
+ * view is accessed again.
+ */
+var ScrollOffsets = {
+ _key: null,
+ _offsets: new Map(),
+ canRestore: true,
+
+ setView(historyEntryId) {
+ this._key = historyEntryId;
+ this.canRestore = true;
+ },
+
+ getPosition() {
+ if (!this.canRestore) {
+ return { top: 0, left: 0 };
+ }
+ let { scrollTop: top, scrollLeft: left } = document.documentElement;
+ return { top, left };
+ },
+
+ save() {
+ if (this._key) {
+ this._offsets.set(this._key, this.getPosition());
+ }
+ },
+
+ restore() {
+ let { top = 0, left = 0 } = this._offsets.get(this._key) || {};
+ window.scrollTo({ top, left, behavior: "auto" });
+ },
+};
+
+var gViewController = {
+ currentViewId: null,
+ readyForLoadView: false,
+ get defaultViewId() {
+ if (!isDiscoverEnabled()) {
+ return "addons://list/extension";
+ }
+ return "addons://discover/";
+ },
+ isLoading: true,
+ // All historyEntryId values must be unique within one session, because the
+ // IDs are used to map history entries to page state. It is not possible to
+ // see whether a historyEntryId was used in history entries before this page
+ // was loaded, so start counting from a random value to avoid collisions.
+ // This is used for scroll offsets in aboutaddons.js
+ nextHistoryEntryId: Math.floor(Math.random() * 2 ** 32),
+ views: {},
+
+ initialize(container) {
+ this.container = container;
+
+ window.addEventListener("popstate", this);
+ window.addEventListener("unload", this, { once: true });
+ Services.obs.addObserver(this, "EM-ping");
+ },
+
+ handleEvent(e) {
+ if (e.type == "popstate") {
+ this.renderState(e.state);
+ return;
+ }
+
+ if (e.type == "unload") {
+ Services.obs.removeObserver(this, "EM-ping");
+ // eslint-disable-next-line no-useless-return
+ return;
+ }
+ },
+
+ observe(subject, topic, data) {
+ if (topic == "EM-ping") {
+ this.readyForLoadView = true;
+ Services.obs.notifyObservers(window, "EM-pong");
+ }
+ },
+
+ notifyEMLoaded() {
+ this.readyForLoadView = true;
+ Services.obs.notifyObservers(window, "EM-loaded");
+ },
+
+ notifyEMUpdateCheckFinished() {
+ // Notify the observer about a completed update check (currently only used in tests).
+ Services.obs.notifyObservers(null, "EM-update-check-finished");
+ },
+
+ defineView(viewName, renderFunction) {
+ if (this.views[viewName]) {
+ throw new Error(
+ `about:addons view ${viewName} should not be defined twice`
+ );
+ }
+ this.views[viewName] = renderFunction;
+ },
+
+ parseViewId(viewId) {
+ const matchRegex = /^addons:\/\/([^\/]+)\/(.*)$/;
+ const [, viewType, viewParam] = viewId.match(matchRegex) || [];
+ return { type: viewType, param: decodeURIComponent(viewParam) };
+ },
+
+ loadView(viewId, replace = false) {
+ viewId = viewId.startsWith("addons://") ? viewId : `addons://${viewId}`;
+ if (viewId == this.currentViewId) {
+ return Promise.resolve();
+ }
+
+ // Always rewrite history state instead of pushing incorrect state for initial load.
+ replace = replace || !this.currentViewId;
+
+ const state = {
+ view: viewId,
+ previousView: replace ? null : this.currentViewId,
+ historyEntryId: ++this.nextHistoryEntryId,
+ };
+ if (replace) {
+ history.replaceState(state, "");
+ } else {
+ history.pushState(state, "");
+ }
+ return this.renderState(state);
+ },
+
+ async renderState(state) {
+ let { param, type } = this.parseViewId(state.view);
+
+ if (!type || this.views[type] == null) {
+ console.warn(`No view for ${type} ${param}, switching to default`);
+ this.resetState();
+ return;
+ }
+
+ this.currentViewId = state.view;
+ this.isLoading = true;
+
+ // Perform tasks before view load
+ document.dispatchEvent(
+ new CustomEvent("view-selected", {
+ detail: { id: state.view, param, type },
+ })
+ );
+
+ // Render the fragment
+ this.container.setAttribute("current-view", type);
+ let fragment = await this.views[type](param);
+
+ // Clear and append the fragment
+ if (fragment) {
+ ScrollOffsets.save();
+ ScrollOffsets.setView(state.historyEntryId);
+
+ this.container.textContent = "";
+ this.container.append(fragment);
+
+ // Most content has been rendered at this point. The only exception are
+ // recommendations in the discovery pane and extension/theme list, because
+ // they rely on remote data. If loaded before, then these may be rendered
+ // within one tick, so wait a frame before restoring scroll offsets.
+ await new Promise(resolve => {
+ window.requestAnimationFrame(() => {
+ ScrollOffsets.restore();
+ resolve();
+ });
+ });
+ } else {
+ // Reset to default view if no given content
+ this.resetState();
+ return;
+ }
+
+ this.isLoading = false;
+
+ document.dispatchEvent(new CustomEvent("view-loaded"));
+ },
+
+ resetState() {
+ return this.loadView(this.defaultViewId, true);
+ },
+};
diff --git a/toolkit/mozapps/extensions/default-theme/icon.svg b/toolkit/mozapps/extensions/default-theme/icon.svg
new file mode 100644
index 0000000000..ecf787db73
--- /dev/null
+++ b/toolkit/mozapps/extensions/default-theme/icon.svg
@@ -0,0 +1,7 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="63" height="63" viewBox="0 0 63 63" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M53.8717 54C66.0976 41.5959 66.0425 21.6295 53.7065 9.29348C41.3705 -3.04253 21.4041 -3.09758 9 9.12833L53.8717 54Z" fill="#23222B"/>
+ <path d="M9.12833 9C-3.09758 21.4041 -3.04253 41.3705 9.29348 53.7065C21.6295 66.0425 41.5959 66.0976 54 53.8717L9.12833 9Z" fill="#F0F0F4"/>
+</svg>
diff --git a/toolkit/mozapps/extensions/default-theme/manifest.json b/toolkit/mozapps/extensions/default-theme/manifest.json
new file mode 100644
index 0000000000..007424510d
--- /dev/null
+++ b/toolkit/mozapps/extensions/default-theme/manifest.json
@@ -0,0 +1,95 @@
+{
+ "manifest_version": 2,
+
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "default-theme@mozilla.org"
+ }
+ },
+
+ "name": "System theme — auto",
+ "description": "Follow the operating system setting for buttons, menus, and windows.",
+ "author": "Mozilla",
+ "version": "1.3",
+
+ "icons": { "32": "icon.svg" },
+
+ "theme": {},
+
+ "dark_theme": {
+ "colors": {
+ "tab_background_text": "#fbfbfe",
+ "tab_selected": "rgb(66,65,77)",
+ "tab_text": "rgb(251,251,254)",
+ "icons": "rgb(251,251,254)",
+ "frame": "#1c1b22",
+ "popup": "rgb(66,65,77)",
+ "popup_text": "rgb(251,251,254)",
+ "popup_border": "rgb(82,82,94)",
+ "popup_highlight": "rgb(43,42,51)",
+ "tab_line": "transparent",
+ "toolbar": "#2b2a33",
+ "toolbar_top_separator": "transparent",
+ "toolbar_bottom_separator": "hsl(240, 5%, 5%)",
+ "toolbar_field": "rgb(28,27,34)",
+ "toolbar_field_border": "transparent",
+ "toolbar_field_text": "rgb(251,251,254)",
+ "toolbar_field_focus": "rgb(66,65,77)",
+ "toolbar_text": "rgb(251, 251, 254)",
+ "ntp_background": "rgb(43, 42, 51)",
+ "ntp_text": "rgb(251, 251, 254)",
+ "sidebar": "#38383D",
+ "sidebar_text": "rgb(249, 249, 250)",
+ "sidebar_border": "rgba(255, 255, 255, 0.1)",
+ "button": "rgb(43,42,51)",
+ "button_hover": "rgb(82,82,94)",
+ "button_active": "rgb(91,91,102)",
+ "button_primary": "rgb(0, 221, 255)",
+ "button_primary_hover": "rgb(128, 235, 255)",
+ "button_primary_active": "rgb(170, 242, 255)",
+ "button_primary_color": "rgb(43, 42, 51)",
+ "input_background": "#42414D",
+ "input_color": "rgb(251,251,254)",
+ "input_border": "#8f8f9d",
+ "autocomplete_popup_separator": "rgb(82,82,94)",
+ "appmenu_update_icon_color": "#54FFBD",
+ "appmenu_info_icon_color": "#80EBFF",
+ "tab_icon_overlay_stroke": "rgb(66,65,77)",
+ "tab_icon_overlay_fill": "rgb(251,251,254)"
+ },
+ "properties": {
+ "panel_hover": "color-mix(in srgb, currentColor 9%, transparent)",
+ "panel_active": "color-mix(in srgb, currentColor 14%, transparent)",
+ "panel_active_darker": "color-mix(in srgb, currentColor 25%, transparent)",
+ "toolbar_field_icon_opacity": "1",
+ "zap_gradient": "linear-gradient(90deg, #9059FF 0%, #FF4AA2 52.08%, #FFBD4F 100%)"
+ }
+ },
+
+ "theme_experiment": {
+ "colors": {
+ "button": "--button-bgcolor",
+ "button_hover": "--button-hover-bgcolor",
+ "button_active": "--button-active-bgcolor",
+ "button_primary": "--button-primary-bgcolor",
+ "button_primary_hover": "--button-primary-hover-bgcolor",
+ "button_primary_active": "--button-primary-active-bgcolor",
+ "button_primary_color": "--button-primary-color",
+ "input_background": "--input-bgcolor",
+ "input_color": "--input-color",
+ "input_border": "--input-border-color",
+ "autocomplete_popup_separator": "--autocomplete-popup-separator-color",
+ "appmenu_update_icon_color": "--panel-banner-item-update-supported-bgcolor",
+ "appmenu_info_icon_color": "--panel-banner-item-info-icon-bgcolor",
+ "tab_icon_overlay_stroke": "--tab-icon-overlay-stroke",
+ "tab_icon_overlay_fill": "--tab-icon-overlay-fill"
+ },
+ "properties": {
+ "panel_hover": "--panel-item-hover-bgcolor",
+ "panel_active": "--arrowpanel-dimmed-further",
+ "panel_active_darker": "--panel-item-active-bgcolor",
+ "toolbar_field_icon_opacity": "--urlbar-icon-fill-opacity",
+ "zap_gradient": "--panel-separator-zap-gradient"
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/default-theme/preview.svg b/toolkit/mozapps/extensions/default-theme/preview.svg
new file mode 100644
index 0000000000..4d59fdcc6f
--- /dev/null
+++ b/toolkit/mozapps/extensions/default-theme/preview.svg
@@ -0,0 +1,46 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="680" height="92" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <rect width="680" height="92" fill="#F0F0F4" />
+ <g filter="url(#filter0_dd)">
+ <rect x="28" y="5" width="166" height="34" rx="4" fill="white" />
+ </g>
+ <rect x="51" y="20" width="121" height="4" rx="2" fill="#15141A" />
+ <rect x="221" y="20" width="121" height="4" rx="2" fill="#15141A" />
+ <rect y="44" width="680" height="48" fill="#F9F9FB" />
+ <circle cx="24" cy="68" r="6.25" stroke="#5B5B66" stroke-width="1.5" />
+ <circle cx="60" cy="68" r="6.25" stroke="#5B5B66" stroke-width="1.5" />
+ <rect x="114" y="52" width="488" height="32" rx="4" fill="#F0F0F4" />
+ <circle cx="130" cy="68" r="6.25" stroke="#5B5B66" stroke-width="1.5" />
+ <rect x="146" y="66" width="308" height="4" rx="2" fill="#5B5B66" />
+ <mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="680" height="92">
+ <path d="M680 92V0H334C321.85 0 312 9.84974 312 22C312 34.1503 302.15 44 290 44H252C238.745 44 228 54.7452 228 68C228 81.2548 217.255 92 204 92H680Z" fill="#C4C4C4" />
+ </mask>
+ <g mask="url(#mask0)">
+ <rect width="680" height="92" fill="#1C1B22" />
+ <rect x="221" y="20" width="121" height="4" rx="2" fill="#B8B7BB" />
+ <rect y="44" width="680" height="48" fill="#2B2A33" />
+ <line x1="663" y1="73.75" x2="649" y2="73.75" stroke="#FBFBFE" stroke-width="1.5" />
+ <line x1="663" y1="67.75" x2="649" y2="67.75" stroke="#FBFBFE" stroke-width="1.5" />
+ <line x1="663" y1="61.75" x2="649" y2="61.75" stroke="#FBFBFE" stroke-width="1.5" />
+ <rect x="114" y="52" width="488" height="32" rx="4" fill="#1C1B22" />
+ <rect x="146" y="66" width="308" height="4" rx="2" fill="white" />
+ </g>
+ <defs>
+ <filter id="filter0_dd" x="24" y="1" width="174" height="42" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+ <feFlood flood-opacity="0" result="BackgroundImageFix" />
+ <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
+ <feOffset />
+ <feGaussianBlur stdDeviation="2" />
+ <feColorMatrix type="matrix" values="0 0 0 0 0.501961 0 0 0 0 0.501961 0 0 0 0 0.556863 0 0 0 0.5 0" />
+ <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow" />
+ <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
+ <feOffset />
+ <feGaussianBlur stdDeviation="0.5" />
+ <feColorMatrix type="matrix" values="0 0 0 0 0.501961 0 0 0 0 0.501961 0 0 0 0 0.556863 0 0 0 0.9 0" />
+ <feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow" />
+ <feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow" result="shape" />
+ </filter>
+ </defs>
+</svg>
diff --git a/toolkit/mozapps/extensions/docs/AMRemoteSettings-JSONSchema.json b/toolkit/mozapps/extensions/docs/AMRemoteSettings-JSONSchema.json
new file mode 100644
index 0000000000..46d15bc56e
--- /dev/null
+++ b/toolkit/mozapps/extensions/docs/AMRemoteSettings-JSONSchema.json
@@ -0,0 +1,56 @@
+{
+ "type": "object",
+ "required": ["id"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "default": "AddonManagerSettings",
+ "description": "The default id should NOT be changed, unless there is a specific need to create separate collection entries which target or exclude specific Firefox versions."
+ },
+ "installTriggerDeprecation": {
+ "$ref": "#/definitions/installTriggerDeprecation",
+ "optional": true
+ },
+ "quarantinedDomains": {
+ "$ref": "#/definitions/quarantinedDomains",
+ "optional": true
+ },
+ "filter_expression": {
+ "type": "string",
+ "description": "This is NOT directly used by AMRemoteSettings, but has special functionality in Remote Settings.\nSee https://remote-settings.readthedocs.io/en/latest/target-filters.html#how",
+ "optional": true
+ }
+ },
+ "definitions": {
+ "installTriggerDeprecation": {
+ "type": "object",
+ "properties": {
+ "extensions.InstallTrigger.enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Show/Hide the InstallTrigger global completely (both the global and its methods will not be accessible anymore). IMPORTANT: The webcompat team should be consulted before turning this to false, because it may also potentially impact UA detection for some websites."
+ },
+ "extensions.InstallTriggerImpl.enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Show/Hide the InstallTrigger methods. The InstallTrigger global will remain visible but set to null."
+ }
+ },
+ "description": "These settings control the visibility of the InstallTrigger global and its methods.",
+ "additionalProperties": false
+ },
+ "quarantinedDomains": {
+ "type": "object",
+ "properties": {
+ "extensions.quarantinedDomains.list": {
+ "type": "string",
+ "default": "",
+ "maxLength": 1048576,
+ "description": "Set of domains to be quarantined separated by a comma (e.g. 'domain1.org,domain2.com'). NOTE: this pref value should be set to a ASCII encoded string and its size smaller than 1MB"
+ }
+ },
+ "description": "These settings provide a set of domain names to be quarantined (restricted by default to unverified extensions, which only the app or the user may grant back). IMPORTANT: The add-ons team should be consulted before introducing any new entry of this type.",
+ "additionalProperties": false
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/docs/AMRemoteSettings-UISchema.json b/toolkit/mozapps/extensions/docs/AMRemoteSettings-UISchema.json
new file mode 100644
index 0000000000..513449da92
--- /dev/null
+++ b/toolkit/mozapps/extensions/docs/AMRemoteSettings-UISchema.json
@@ -0,0 +1,10 @@
+{
+ "installTriggerDeprecation": {
+ "extensions.InstallTrigger.enabled": {
+ "ui:widget": "radio"
+ },
+ "extensions.InstallTriggerImpl.enabled": {
+ "ui:widget": "radio"
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/docs/AMRemoteSettings-overview.rst b/toolkit/mozapps/extensions/docs/AMRemoteSettings-overview.rst
new file mode 100644
index 0000000000..90a0445922
--- /dev/null
+++ b/toolkit/mozapps/extensions/docs/AMRemoteSettings-overview.rst
@@ -0,0 +1,173 @@
+AMRemoteSettings Overview
+=========================
+
+``AMRemoteSettings`` is a singleton that is responsible for fetching data from a RemoteSettings collection named
+``"main/addons-manager-settings"`` to remotely control a set of Add-ons related preferences (hardcoded inside
+the AMRemoteSettings definition part of a given Firefox release).
+
+``AMRemoteSettings`` will process the collection data when the RemoteSettings ``"sync"`` event is fired and on
+late browser startup if there is already some data stored locally in the collection (and so if the pref value is
+manually changed, then it would be set again to the value stored in the RemoteSettings collection data, either
+on the next browser startup or on the next time the ``"sync"`` event is fired again).
+
+.. warning::
+ Any about:config preference that AMRemoteSettings singleton sets while processing the RemoteSettings collection data
+ are **never cleared automatically** (e.g. if in a future Firefox version AMRemoteSettings does not handle a particular
+ settings anymore, or if the RemoteSettings entry is removed from the collection on the RemoteSettings service side).
+
+ In practice this makes this feature a good choice when the remotely controlled about:config preferences is related to:
+
+ * Firefox Add-ons features in the process of being deprecated and then fully removed in a future Firefox release
+
+ * or controlled settings that are never removed from the ``"main/addons-manager-settings"`` collection.
+
+ In general before changing controlled settings in the ``"main/addons-manager-settings"`` collection it is advisable to:
+
+ * make sure to default value for the related about:config preferences has been changed in mozilla-central first,
+ and to let it ride the release train and get some backing time on release;
+
+ * only after that consider changing the value controlled by ``"main/addons-manager-settings"`` collection,
+ to set the same value on older Firefox releases where the previous default value was set.
+
+The ``AMRemoteSettings`` singleton queries RemoteSettings and processes all the entries got from the
+``"main/addons-manager-settings"`` collection, besides entries that may be filtered out by RemoteSettings based on
+the ``"filter_expression"`` property (See https://remote-settings.readthedocs.io/en/latest/target-filters.html#how).
+
+Each of the entries created in the ``"main/addons-manager-settings"`` collection and is expected to match the JSONSchema
+described in the :ref:`JSON Schema section below<JSON Schema>`.
+
+For each entry found in the collection, only the properties that are explicitly included in the
+``AMRemoteSettings.RS_ENTRIES_MAP`` object are going to be processed (e.g. new Firefox versions may have introduced new
+ones that older Firefox version will just ignore):
+
+* Each of the ``AMRemoteSettings.RS_ENTRIES_MAP`` properties:
+
+ * represents a group of settings (e.g. the property named ``"installTriggerDeprecation"``) is responsible of controlling
+ about:config preferences related to the InstallTrigger deprecation
+
+ * is set to an array of string, which lists the about:config preferences names that can actually be controlled by the
+ related group of settings (e.g. ``"installTriggerDeprecation"`` can only control two preferences,
+ ``"extensions.InstallTrigger.enabled"`` and ``"extensions.InstallTriggerImpl.enabled"``, that are controlling the
+ InstallTrigger and InstallTrigger's methods availability).
+
+.. warning::
+ Any other about:config preference names that are not listed explicitly in the ``AMRemoteSettings.RS_ENTRIES_MAP`` config
+ is expected to be ignored, even if allowed by a new version of the collection's JSONSchema (this is the expected behavior
+ and prevents the introduction of unexpected behaviors on older Firefox versions that may not be expecting new settings groups
+ that may be introduced in Firefox releases that followed it).
+
+ Any about:config preference with an unexpected value type is going to be ignored as well (look to the ``AMRemoteSettings.processEntries``
+ to confirm which preferences values types are already expected and handled accordingly).
+
+.. _Controlled Settings Groups:
+
+AMRemoteSettings - Controlled Settings Groups
+=============================================
+
+installTriggerDeprecation
+-------------------------
+
+Group of settings to control InstallTrigger deprecation (Bug 1754441)
+
+- **extensions.InstallTrigger.enabled** (boolean), controls the availability of the InstallTrigger global:
+
+ - Turning this to false will be hiding it completely
+
+ .. note::
+ Turning this off can potentially impact UA detection on website being using it to detect
+ Firefox. The WebCompat team should be consulted before setting this to `false` by default in
+ Nightly or across all Firefox >= 100 versions through the ``"addons-manager-settings"``
+ RemoteSettings collection).
+
+- **extensions.InstallTriggerImpl.enabled** (boolean): controls the availability of the InstallTrigger methods:
+
+ - Turning this to false will hide all the InstallTrigger implementation, preventing using it to
+ trigger the addon install flow, while the InstallTrigger global will still exists but be set to null.
+
+quarantinedDomains
+------------------
+
+Group of settings to control the list of quarantined domains (Bug 1832791)
+
+- **extensions.quarantinedDomains.list** (string), controls the list of domains to be quarantined
+
+ .. note::
+ The WebExtensions and Add-ons Operations teams should be consulted before applying changes to
+ the list of quarantined domains.
+
+How to define new remotely controlled settings
+----------------------------------------------
+
+.. note::
+ Please update the :ref:`JSON Schema` and :ref:`UI Schema` in this documentation page updated when the ones used on the
+ RemoteSettings service side have been changed, and document new controlled settings in the section :ref:`Controlled Settings Groups`.
+
+* Confirm that the :ref:`JSON Schema` and :ref:`UI Schema` included in this page are in sync with the one actually used on the
+ RemoteSettings service side, and use it as the starting point to update it to include a new type on remotely controlled setting:
+
+ * choose a new unique string for the group of settings to be used in the ``definitions`` and ``properties``
+ objects (any that isn't already used in the existing JSON Schema ``definitions``), possibly choosing a name
+ that helps to understand what the purpose of the entry.
+
+ * add a new JSONSchema for the new group of settings in the ``definitions`` property
+
+ * each of the properties included in the new definition should be named after the name of the about:config pref
+ being controlled, their types should match the type expected by the pref (e.g. ``"boolean"`` for a boolean preference).
+
+ * make sure to add a description property to the definition and to each of the controlled preferences, which should
+ describe what is the settings group controlling and what is the expected behavior on the values allowed.
+
+* Add a new entry to ``"AMRemoteSettings.RS_ENTRIES_MAP"`` with the choosen ``"id"`` as its key and
+ the array of the about:config preferences names are its value.
+
+* If the value type of a controlled preference isn't yet supported, the method ``AMRemoteSettings.processEntries`` has to be
+ updated to handle the new value type (otherwise the preference value will be just ignored).
+
+* Add a new test to cover the expected behaviors on the new remotely controlled settings, the following RemoteSettings
+ documentation page provides some useful pointers:
+ * https://firefox-source-docs.mozilla.org/services/settings/index.html#unit-tests
+
+* Refer to the RemoteSettings docs for more details about managing the JSONSchema for the ``"main/addons-manager-settings"``
+ collection and how to test it interactively in a Firefox instance:
+ * https://remote-settings.readthedocs.io/en/latest/getting-started.html
+ * https://firefox-source-docs.mozilla.org/services/settings/index.html#create-new-remote-settings
+ * https://firefox-source-docs.mozilla.org/services/settings/index.html#remote-settings-dev-tools
+
+.. _JSON Schema:
+
+AMRemoteSettings - JSON Schema
+==============================
+
+The entries part of the ``"addons-manager-settings"`` collection are validated using a JSON Schema structured as follows:
+
+* The mandatory ``"id"`` property
+ * defaults to `"AddonManagerSettings"` (which enforces only one entry in the collection as the preferred use case)
+ * **should NOT be changed unless there is a specific need to create separate collection entries which target or exclude specific Firefox versions.**
+ * when changed and multiple entries are created in this collection, it is advisable to:
+
+ * set the id to a short string value that make easier to understand the purpose of the additional entry in the collection
+ (eg. `AddonManagerSettings-fx98-99` for an entry created that targets Firefox 98 and 99)
+ * make sure only one applied to each targeted Firefox version ranges, or at least that each entry is controlling a different settings group
+
+* Each supported group of controlled settings is described by its own property (e.g. ``"installTriggerDeprecation"``)
+
+ * JSON Schema for each group of settings is defined in an entry of the ``"definitions"`` property.
+
+ * Each group of settings is contained it its own entry in ``"properties"``, named as the entry added to the ``"definitions"``)
+ and referencing (using ``"$ref"``) the related definition
+
+.. literalinclude :: ./AMRemoteSettings-JSONSchema.json
+ :language: json
+
+UI Schema
+---------
+
+In addition to the JSON Schema, a separate json called ``"UI schema"`` is associated to the ``"addons-manager-settings"`` collection,
+and it can be used to customize the form auto-generated based on the JSONSchema data.
+
+.. note::
+ Extending this schema is only needed if it can help to make the RemoteSettings collection easier to manage
+ and less error prone.
+
+.. literalinclude :: ./AMRemoteSettings-UISchema.json
+ :language: json
diff --git a/toolkit/mozapps/extensions/docs/AMRemoteSettings.rst b/toolkit/mozapps/extensions/docs/AMRemoteSettings.rst
new file mode 100644
index 0000000000..a555f426cc
--- /dev/null
+++ b/toolkit/mozapps/extensions/docs/AMRemoteSettings.rst
@@ -0,0 +1,5 @@
+AMRemoteSettings Reference
+==========================
+
+.. js:autoclass:: AMRemoteSettings
+ :members:
diff --git a/toolkit/mozapps/extensions/docs/AddonManager.rst b/toolkit/mozapps/extensions/docs/AddonManager.rst
new file mode 100644
index 0000000000..9a5db82830
--- /dev/null
+++ b/toolkit/mozapps/extensions/docs/AddonManager.rst
@@ -0,0 +1,4 @@
+AddonManager Reference
+======================
+.. js:autoclass:: AddonManager
+ :members:
diff --git a/toolkit/mozapps/extensions/docs/SystemAddons.rst b/toolkit/mozapps/extensions/docs/SystemAddons.rst
new file mode 100644
index 0000000000..a8524a44b6
--- /dev/null
+++ b/toolkit/mozapps/extensions/docs/SystemAddons.rst
@@ -0,0 +1,275 @@
+System Add-ons Overview
+=======================
+
+System add-ons are a method for shipping extensions to Firefox that:
+
+* are hidden from the about:addons UI
+* cannot be user disabled
+* can be updated restartlessly based on criteria Mozilla sets
+
+Generally these are considered to be built-in features to Firefox, and the
+fact that they are extensions and can be updated restartlessly are implementation
+details as far as users are concerned.
+
+If you'd like to ship an add-on with Firefox or as an update (either to an existing
+feature or as a "hotfix" to patch critical problems in the wild) please contact the
+GoFaster team: https://mail.mozilla.org/listinfo/gofaster
+
+The add-ons themselves are either legacy Firefox add-ons or WebExtensions.
+They must be:
+
+* restartless
+* multi-process compatible
+
+Other than these restrictions there is nothing special or different about
+the extensions themselves.
+
+It is possible to override an installed system add-on by installing a different add-on
+with the same ID into a higher priority location.
+
+Available locations, starting from the highest priority include:
+
+1) temporary install (about:debugging)
+2) normal user install into profile (about:addons or AMO/TestPilot/etc.)
+3) system add-on updates
+4) built-in system add-ons
+
+This makes it possible for a developer or user to override a system add-on
+by installing an add-on with the same ID from AMO or TestPilot or as a temporary
+add-on.
+
+Default, built-in system add-ons
+--------------------------------
+
+The set of **default** system add-ons are checked into `mozilla-central` under
+`./browser/extensions`. These get placed into the `features` directory of the
+application directory at build time.
+
+System add-on updates
+---------------------
+
+System add-on **updates** are served via Mozilla's Automatic Update Service
+(AUS, aka `Balrog`_). These are installed into the users profile under the `features`
+directory.
+
+Updates must be specifically signed by Mozilla - the signature that addons.mozilla.org
+uses will not work for system add-ons.
+
+As noted above, these updates may override a built-in system add-on, or they may
+be a new install. Updates are always served as a set - if any add-on in the set
+fails to install or upgrade, then the whole set fails. This is to leave Firefox
+in a consistent state.
+
+System add-on updates are removed when the Firefox application version changes,
+to avoid compatibility problems - for instance a user downgrading to an earlier
+version of Firefox than the update supports will end up with a disabled update
+rather than falling back to the built-in version.
+
+Firefox System Add-on Update Protocol
+=====================================
+This section describes the protocol that Firefox uses when retrieving updates
+from AUS, and the expected behavior of Firefox based on the updater service's response.
+
+.. _Balrog: https://wiki.mozilla.org/Balrog
+
+Update Request
+--------------
+To determine what updates to install, Firefox makes an HTTP **GET** request to
+AUS once a day via a URL of the form::
+
+ https://aus5.mozilla.org/update/3/SystemAddons/%VERSION%/%BUILD_ID%/%BUILD_TARGET%/%LOCALE%/%CHANNEL%/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/update.xml
+
+The path segments surrounded by ``%`` symbols are variable fields that Firefox
+fills in with information about itself and the environment it's running in:
+
+``VERSION``
+ Firefox version number
+``BUILD_ID``
+ Build ID
+``BUILD_TARGET``
+ Build target
+``LOCALE``
+ Build locale
+``CHANNEL``
+ Update channel
+``OS_VERSION``
+ OS Version
+``DISTRIBUTION``
+ Firefox Distribution
+``DISTRIBUTION_VERSION``
+ Firefox Distribution version
+
+Update Response
+---------------
+AUS should respond with an XML document that looks something like this:
+
+.. code-block:: xml
+
+ <?xml version="1.0"?>
+ <updates>
+ <addons>
+ <addon id="flyweb@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/flyweb/flyweb@mozilla.org-1.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="1.0"/>
+ <addon id="pocket@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/pocket/pocket@mozilla.org-1.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="1.0"/>
+ </addons>
+ </updates>
+
+* The root element is ``<updates>``, used for all updater responses.
+* The only child of ``<updates>`` is ``<addons>``, which represents a list of
+ system add-ons to update.
+* Within ``<addons>`` are several ``<addon>`` tags, each one corresponding to a
+ system add-on to update.
+
+``<addon>`` tags **must** have the following attributes:
+
+``id``
+ The extension ID
+``URL``
+ URL to a signed XPI of the specified add-on version to download
+``hashFunction``
+ Identifier of the hash function used to generate the hashValue attribute.
+``hashValue``
+ Hash of the XPI file linked from the URL attribute, calculated using the function specified in the hashValue attribute.
+``size``
+ Size (in bytes) of the XPI file linked from the URL attribute.
+``version``
+ Version number of the add-on
+
+Update Behavior
+---------------
+After receiving the update response, Firefox modifies the **update** add-ons
+according to the following algorithm:
+
+1. If the ``<addons>`` tag is empty (``<addons></addons>``) in the response,
+ **remove all system add-on updates**.
+2. If no add-ons were specified in the response (i.e. the ``<addons>`` tag
+ is not present), do nothing and finish.
+3. If the **update** add-on set is equal to the set of add-ons specified in the
+ update response, do nothing and finish.
+4. If the set of **default** add-ons is equal to the set of add-ons specified in
+ the update response, remove all the **update** add-ons and finish.
+5. Download each add-on specified in the update response and store them in the
+ "downloaded add-on set". A failed download **must** abort the entire system
+ add-on update.
+6. Validate the downloaded add-ons. The following **must** be true for all
+ downloaded add-ons, or the update process is aborted:
+
+ a. The ID and version of the downloaded add-on must match the specified ID or
+ version in the update response.
+ b. The hash provided in the update response must match the downloaded add-on
+ file.
+ c. The downloaded add-on file size must match the size given in the update
+ response.
+ d. The add-on must be compatible with Firefox (i.e. it must not be for a
+ different application, such as Thunderbird).
+ e. The add-on must be packed (i.e. be an XPI file).
+ f. The add-on must be restartless.
+ g. The add-on must be signed by the system add-on root certificate.
+
+6. Once all downloaded add-ons are validated, install them into the profile
+ directory as part of the **update** set.
+
+Notes on the update process:
+
+* Add-ons are considered "equal" if they have the same ID and version number.
+
+Examples
+--------
+The follow section describes common situations that we have or expect to run
+into and how the protocol described above handles them.
+
+For simplicity, unless otherwise specified, all examples assume that there are
+two system add-ons in existence: **FlyWeb** and **Pocket**.
+
+Basic
+~~~~~
+A user has Firefox 45, which shipped with FlyWeb 1.0 and Pocket 1.0. We want to
+update users to FlyWeb 2.0. AUS sends out the following update response:
+
+.. code-block:: xml
+
+ <updates>
+ <addons>
+ <addon id="flyweb@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/flyweb/flyweb@mozilla.org-2.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="2.0"/>
+ <addon id="pocket@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/pocket/pocket@mozilla.org-1.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="1.0"/>
+ </addons>
+ </updates>
+
+Firefox will download FlyWeb 2.0 and Pocket 1.0 and store them in the profile directory.
+
+Missing Add-on
+~~~~~~~~~~~~~~
+A user has Firefox 45, which shipped with FlyWeb 1.0 and Pocket 1.0. We want to
+update users to FlyWeb 2.0, but accidentally forget to specify Pocket in the
+update response. AUS sends out the following:
+
+.. code-block:: xml
+
+ <updates>
+ <addons>
+ <addon id="flyweb@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/flyweb/flyweb@mozilla.org-2.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="2.0"/>
+ </addons>
+ </updates>
+
+Firefox will download FlyWeb 2.0 and store it in the profile directory. Pocket
+1.0 from the **default** location will be used.
+
+Remove all system add-on updates
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+A response from AUS with an empty add-on set will *remove all system add-on
+updates*:
+
+.. code-block:: xml
+
+ <updates>
+ <addons></addons>
+ </updates>
+
+Rollout
+~~~~~~~
+A user has Firefox 45, which shipped with FlyWeb 1.0 and Pocket 1.0. We want to
+rollout FlyWeb 2.0 at a 10% sample rate. 10% of the time, AUS sends out:
+
+.. code-block:: xml
+
+ <updates>
+ <addons>
+ <addon id="flyweb@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/flyweb/flyweb@mozilla.org-2.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="2.0"/>
+ <addon id="pocket@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/pocket/pocket@mozilla.org-1.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="1.0"/>
+ </addons>
+ </updates>
+
+With this response, Firefox will download Pocket 1.0 and FlyWeb 2.0 and install
+them into the profile directory.
+
+The other 90% of the time, AUS sends out an empty response:
+
+.. code-block:: xml
+
+ <updates></updates>
+
+With the empty response, Firefox will not make any changes. This means users who
+haven’t seen the 10% update response will stay on FlyWeb 1.0, and users who have
+seen it will stay on FlyWeb 2.0.
+
+Once we’re happy with the rollout and want to switch to 100%, AUS will send the
+10% update response to 100% of users, upgrading everyone to FlyWeb 2.0.
+
+Rollback
+~~~~~~~~
+This example continues from the “Rollout” example. If, during the 10% rollout,
+we find a major issue with FlyWeb 2.0, we want to roll all users back to FlyWeb 1.0.
+AUS sends out the following:
+
+.. code-block:: xml
+
+ <updates>
+ <addons>
+ <addon id="flyweb@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/flyweb/flyweb@mozilla.org-1.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="1.0"/>
+ <addon id="pocket@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/pocket/pocket@mozilla.org-1.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="1.0"/>
+ </addons>
+ </updates>
+
+For users who have updated, Firefox will download FlyWeb 1.0 and Pocket 1.0 and
+install them into the profile directory. For users that haven’t yet updated,
+Firefox will see that the **default** add-on set matches the set in the update
+ping and clear the **update** add-on set.
diff --git a/toolkit/mozapps/extensions/docs/index.rst b/toolkit/mozapps/extensions/docs/index.rst
new file mode 100644
index 0000000000..551cdd0ebc
--- /dev/null
+++ b/toolkit/mozapps/extensions/docs/index.rst
@@ -0,0 +1,21 @@
+==============
+Add-on Manager
+==============
+
+Overview docs
+-------------
+
+.. toctree::
+ :maxdepth: 1
+
+ AMRemoteSettings-overview
+ SystemAddons
+
+API References
+--------------
+
+.. toctree::
+ :maxdepth: 1
+
+ AddonManager
+ AMRemoteSettings
diff --git a/toolkit/mozapps/extensions/extensions.manifest b/toolkit/mozapps/extensions/extensions.manifest
new file mode 100644
index 0000000000..cd040a71f1
--- /dev/null
+++ b/toolkit/mozapps/extensions/extensions.manifest
@@ -0,0 +1,9 @@
+#ifndef MOZ_WIDGET_ANDROID
+category update-timer addonManager @mozilla.org/addons/integration;1,getService,addon-background-update-timer,extensions.update.interval,86400
+#endif
+#ifndef MOZ_THUNDERBIRD
+#ifndef MOZ_WIDGET_ANDROID
+category addon-provider-module GMPProvider resource://gre/modules/addons/GMPProvider.sys.mjs
+category addon-provider-module SitePermsAddonProvider resource://gre/modules/addons/SitePermsAddonProvider.sys.mjs
+#endif
+#endif
diff --git a/toolkit/mozapps/extensions/gen_built_in_addons.py b/toolkit/mozapps/extensions/gen_built_in_addons.py
new file mode 100644
index 0000000000..9e8078f2bc
--- /dev/null
+++ b/toolkit/mozapps/extensions/gen_built_in_addons.py
@@ -0,0 +1,99 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.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 argparse
+import json
+import os.path
+import sys
+
+import buildconfig
+import mozpack.path as mozpath
+
+from mozpack.copier import FileRegistry
+from mozpack.manifests import InstallManifest
+
+
+# A list of build manifests, and their relative base paths, from which to
+# extract lists of install files. These vary depending on which backend we're
+# using, so nonexistent manifests are ignored.
+manifest_paths = (
+ ("", "_build_manifests/install/dist_bin"),
+ ("", "faster/install_dist_bin"),
+ ("browser", "faster/install_dist_bin_browser"),
+)
+
+
+def get_registry(paths):
+ used_paths = set()
+
+ registry = FileRegistry()
+ for base, path in paths:
+ full_path = mozpath.join(buildconfig.topobjdir, path)
+ if not os.path.exists(full_path):
+ continue
+
+ used_paths.add(full_path)
+
+ reg = FileRegistry()
+ InstallManifest(full_path).populate_registry(reg)
+
+ for p, f in reg:
+ path = mozpath.join(base, p)
+ try:
+ registry.add(path, f)
+ except Exception:
+ pass
+
+ return registry, used_paths
+
+
+def get_child(base, path):
+ """Returns the nearest parent of `path` which is an immediate child of
+ `base`"""
+
+ dirname = mozpath.dirname(path)
+ while dirname != base:
+ path = dirname
+ dirname = mozpath.dirname(path)
+ return path
+
+
+def main(output, *args):
+ parser = argparse.ArgumentParser(
+ description="Produces a JSON manifest of built-in add-ons"
+ )
+ parser.add_argument(
+ "--features",
+ type=str,
+ dest="featuresdir",
+ action="store",
+ help=("The distribution sub-directory " "containing feature add-ons"),
+ )
+ args = parser.parse_args(args)
+
+ registry, inputs = get_registry(manifest_paths)
+
+ dicts = {}
+ for path in registry.match("dictionaries/*.dic"):
+ base, ext = os.path.splitext(mozpath.basename(path))
+ dicts[base] = path
+
+ listing = {
+ "dictionaries": dicts,
+ }
+
+ if args.featuresdir:
+ features = set()
+ for p in registry.match("%s/*" % args.featuresdir):
+ features.add(mozpath.basename(get_child(args.featuresdir, p)))
+
+ listing["system"] = sorted(features)
+
+ json.dump(listing, output, sort_keys=True)
+
+ return inputs
+
+
+if __name__ == "__main__":
+ main(sys.stdout, *sys.argv[1:])
diff --git a/toolkit/mozapps/extensions/internal/AddonRepository.sys.mjs b/toolkit/mozapps/extensions/internal/AddonRepository.sys.mjs
new file mode 100644
index 0000000000..f906f244b6
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/AddonRepository.sys.mjs
@@ -0,0 +1,1139 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+ ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+// The current platform as specified in the AMO API:
+// http://addons-server.readthedocs.io/en/latest/topics/api/addons.html#addon-detail-platform
+XPCOMUtils.defineLazyGetter(lazy, "PLATFORM", () => {
+ let platform = Services.appinfo.OS;
+ switch (platform) {
+ case "Darwin":
+ return "mac";
+
+ case "Linux":
+ return "linux";
+
+ case "Android":
+ return "android";
+
+ case "WINNT":
+ return "windows";
+ }
+ return platform;
+});
+
+const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "getAddonsCacheEnabled",
+ PREF_GETADDONS_CACHE_ENABLED
+);
+
+const PREF_GETADDONS_CACHE_TYPES = "extensions.getAddons.cache.types";
+const PREF_GETADDONS_CACHE_ID_ENABLED =
+ "extensions.%ID%.getAddons.cache.enabled";
+const PREF_GETADDONS_BROWSEADDONS = "extensions.getAddons.browseAddons";
+const PREF_GETADDONS_BYIDS = "extensions.getAddons.get.url";
+const PREF_GETADDONS_BROWSESEARCHRESULTS =
+ "extensions.getAddons.search.browseURL";
+const PREF_GETADDONS_DB_SCHEMA = "extensions.getAddons.databaseSchema";
+const PREF_GET_LANGPACKS = "extensions.getAddons.langpacks.url";
+
+const PREF_METADATA_LASTUPDATE = "extensions.getAddons.cache.lastUpdate";
+const PREF_METADATA_UPDATETHRESHOLD_SEC =
+ "extensions.getAddons.cache.updateThreshold";
+const DEFAULT_METADATA_UPDATETHRESHOLD_SEC = 172800; // two days
+
+const DEFAULT_CACHE_TYPES = "extension,theme,locale,dictionary";
+
+const FILE_DATABASE = "addons.json";
+const DB_SCHEMA = 6;
+const DB_MIN_JSON_SCHEMA = 5;
+const DB_BATCH_TIMEOUT_MS = 50;
+
+const BLANK_DB = function () {
+ return {
+ addons: new Map(),
+ schema: DB_SCHEMA,
+ };
+};
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+
+const LOGGER_ID = "addons.repository";
+
+// Create a new logger for use by the Addons Repository
+// (Requires AddonManager.jsm)
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+function convertHTMLToPlainText(html) {
+ if (!html) {
+ return html;
+ }
+ var converter = Cc[
+ "@mozilla.org/widget/htmlformatconverter;1"
+ ].createInstance(Ci.nsIFormatConverter);
+
+ var input = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ input.data = html.replace(/\n/g, "<br>");
+
+ var output = {};
+ converter.convert("text/html", input, "text/plain", output);
+
+ if (output.value instanceof Ci.nsISupportsString) {
+ return output.value.data.replace(/\r\n/g, "\n");
+ }
+ return html;
+}
+
+async function getAddonsToCache(aIds) {
+ let types =
+ lazy.Preferences.get(PREF_GETADDONS_CACHE_TYPES) || DEFAULT_CACHE_TYPES;
+
+ types = types.split(",");
+
+ let addons = await lazy.AddonManager.getAddonsByIDs(aIds);
+ let enabledIds = [];
+
+ for (let [i, addon] of addons.entries()) {
+ var preference = PREF_GETADDONS_CACHE_ID_ENABLED.replace("%ID%", aIds[i]);
+ // If the preference doesn't exist caching is enabled by default
+ if (!lazy.Preferences.get(preference, true)) {
+ continue;
+ }
+
+ // The add-ons manager may not know about this ID yet if it is a pending
+ // install. In that case we'll just cache it regardless
+
+ // Don't cache add-ons of the wrong types
+ if (addon && !types.includes(addon.type)) {
+ continue;
+ }
+
+ // Don't cache system add-ons
+ if (addon && addon.isSystem) {
+ continue;
+ }
+
+ enabledIds.push(aIds[i]);
+ }
+
+ return enabledIds;
+}
+
+function AddonSearchResult(aId) {
+ this.id = aId;
+ this.icons = {};
+ this._unsupportedProperties = {};
+}
+
+AddonSearchResult.prototype = {
+ /**
+ * The ID of the add-on
+ */
+ id: null,
+
+ /**
+ * The add-on type (e.g. "extension" or "theme")
+ */
+ type: null,
+
+ /**
+ * The name of the add-on
+ */
+ name: null,
+
+ /**
+ * The version of the add-on
+ */
+ version: null,
+
+ /**
+ * The creator of the add-on
+ */
+ creator: null,
+
+ /**
+ * The developers of the add-on
+ */
+ developers: null,
+
+ /**
+ * A short description of the add-on
+ */
+ description: null,
+
+ /**
+ * The full description of the add-on
+ */
+ fullDescription: null,
+
+ /**
+ * The end-user licensing agreement (EULA) of the add-on
+ */
+ eula: null,
+
+ /**
+ * The url of the add-on's icon
+ */
+ get iconURL() {
+ return this.icons && this.icons[32];
+ },
+
+ /**
+ * The URLs of the add-on's icons, as an object with icon size as key
+ */
+ icons: null,
+
+ /**
+ * An array of screenshot urls for the add-on
+ */
+ screenshots: null,
+
+ /**
+ * The homepage for the add-on
+ */
+ homepageURL: null,
+
+ /**
+ * The support URL for the add-on
+ */
+ supportURL: null,
+
+ /**
+ * The contribution url of the add-on
+ */
+ contributionURL: null,
+
+ /**
+ * The rating of the add-on, 0-5
+ */
+ averageRating: null,
+
+ /**
+ * The number of reviews for this add-on
+ */
+ reviewCount: null,
+
+ /**
+ * The URL to the list of reviews for this add-on
+ */
+ reviewURL: null,
+
+ /**
+ * The number of times the add-on was downloaded the current week
+ */
+ weeklyDownloads: null,
+
+ /**
+ * AddonInstall object generated from the add-on XPI url
+ */
+ install: null,
+
+ /**
+ * nsIURI storing where this add-on was installed from
+ */
+ sourceURI: null,
+
+ /**
+ * The Date that the add-on was most recently updated
+ */
+ updateDate: null,
+
+ toJSON() {
+ let json = {};
+
+ for (let property of Object.keys(this)) {
+ let value = this[property];
+ if (property.startsWith("_") || typeof value === "function") {
+ continue;
+ }
+
+ try {
+ switch (property) {
+ case "sourceURI":
+ json.sourceURI = value ? value.spec : "";
+ break;
+
+ case "updateDate":
+ json.updateDate = value ? value.getTime() : "";
+ break;
+
+ default:
+ json[property] = value;
+ }
+ } catch (ex) {
+ logger.warn("Error writing property value for " + property);
+ }
+ }
+
+ for (let property of Object.keys(this._unsupportedProperties)) {
+ let value = this._unsupportedProperties[property];
+ if (!property.startsWith("_")) {
+ json[property] = value;
+ }
+ }
+
+ return json;
+ },
+};
+
+/**
+ * The add-on repository is a source of add-ons that can be installed. It can
+ * be searched in three ways. The first takes a list of IDs and returns a
+ * list of the corresponding add-ons. The second returns a list of add-ons that
+ * come highly recommended. This list should change frequently. The third is to
+ * search for specific search terms entered by the user. Searches are
+ * asynchronous and results should be passed to the provided callback object
+ * when complete. The results passed to the callback should only include add-ons
+ * that are compatible with the current application and are not already
+ * installed.
+ */
+export var AddonRepository = {
+ /**
+ * The homepage for visiting this repository. If the corresponding preference
+ * is not defined, defaults to about:blank.
+ */
+ get homepageURL() {
+ let url = this._formatURLPref(PREF_GETADDONS_BROWSEADDONS, {});
+ return url != null ? url : "about:blank";
+ },
+
+ /**
+ * Retrieves the url that can be visited to see search results for the given
+ * terms. If the corresponding preference is not defined, defaults to
+ * about:blank.
+ *
+ * @param aSearchTerms
+ * Search terms used to search the repository
+ */
+ getSearchURL(aSearchTerms) {
+ let url = this._formatURLPref(PREF_GETADDONS_BROWSESEARCHRESULTS, {
+ TERMS: aSearchTerms,
+ });
+ return url != null ? url : "about:blank";
+ },
+
+ /**
+ * Whether caching is currently enabled
+ */
+ get cacheEnabled() {
+ return lazy.getAddonsCacheEnabled;
+ },
+
+ /**
+ * Shut down AddonRepository
+ * return: promise{integer} resolves with the result of flushing
+ * the AddonRepository database
+ */
+ shutdown() {
+ return AddonDatabase.shutdown(false);
+ },
+
+ metadataAge() {
+ let now = Math.round(Date.now() / 1000);
+ let lastUpdate = Services.prefs.getIntPref(PREF_METADATA_LASTUPDATE, 0);
+ return Math.max(0, now - lastUpdate);
+ },
+
+ isMetadataStale() {
+ let threshold = Services.prefs.getIntPref(
+ PREF_METADATA_UPDATETHRESHOLD_SEC,
+ DEFAULT_METADATA_UPDATETHRESHOLD_SEC
+ );
+ return this.metadataAge() > threshold;
+ },
+
+ /**
+ * Asynchronously get a cached add-on by id. The add-on (or null if the
+ * add-on is not found) is passed to the specified callback. If caching is
+ * disabled, null is passed to the specified callback.
+ *
+ * The callback variant exists only for existing code in XPIProvider.jsm
+ * and XPIDatabase.jsm that requires a synchronous callback, yuck.
+ *
+ * @param aId
+ * The id of the add-on to get
+ */
+ async getCachedAddonByID(aId, aCallback) {
+ if (!aId || !this.cacheEnabled) {
+ if (aCallback) {
+ aCallback(null);
+ }
+ return null;
+ }
+
+ if (aCallback && AddonDatabase._loaded) {
+ let addon = AddonDatabase.getAddon(aId);
+ aCallback(addon);
+ return addon;
+ }
+
+ await AddonDatabase.openConnection();
+
+ let addon = AddonDatabase.getAddon(aId);
+ if (aCallback) {
+ aCallback(addon);
+ }
+ return addon;
+ },
+
+ /*
+ * Clear and delete the AddonRepository database
+ * @return Promise{null} resolves when the database is deleted
+ */
+ _clearCache() {
+ return AddonDatabase.delete().then(() =>
+ lazy.AddonManagerPrivate.updateAddonRepositoryData()
+ );
+ },
+
+ /**
+ * Fetch data from an API where the results may span multiple "pages".
+ * This function will take care of issuing multiple requests until all
+ * the results have been fetched, and will coalesce them all into a
+ * single return value. The handling here is specific to the way AMO
+ * implements paging (ie a JSON result with a "next" property).
+ *
+ * @param {string} startURL
+ * URL for the first page of results
+ * @param {function} handler
+ * This function will be called once per page of results,
+ * it should return an array of objects (the type depends
+ * on the particular API being called of course).
+ *
+ * @returns Promise{array} An array of all the individual results from
+ * the API call(s).
+ */
+ _fetchPaged(ids, pref, handler) {
+ let startURL = this._formatURLPref(pref, { IDS: ids.join(",") });
+ let results = [];
+ let idCheck = ids.map(id => {
+ if (id.startsWith("rta:")) {
+ return atob(id.split(":")[1]);
+ }
+ return id;
+ });
+
+ const fetchNextPage = url => {
+ return new Promise((resolve, reject) => {
+ let request = new lazy.ServiceRequest({ mozAnon: true });
+ request.mozBackgroundRequest = true;
+ request.open("GET", url, true);
+ request.responseType = "json";
+
+ request.addEventListener("error", aEvent => {
+ reject(new Error(`GET ${url} failed`));
+ });
+ request.addEventListener("timeout", aEvent => {
+ reject(new Error(`GET ${url} timed out`));
+ });
+ request.addEventListener("load", aEvent => {
+ let response = request.response;
+ if (!response || (request.status != 200 && request.status != 0)) {
+ reject(new Error(`GET ${url} failed (status ${request.status})`));
+ return;
+ }
+
+ try {
+ let newResults = handler(response.results).filter(e =>
+ idCheck.includes(e.id)
+ );
+ results.push(...newResults);
+ } catch (err) {
+ reject(err);
+ }
+
+ if (response.next) {
+ resolve(fetchNextPage(response.next));
+ }
+
+ resolve(results);
+ });
+
+ request.send(null);
+ });
+ };
+
+ return fetchNextPage(startURL);
+ },
+
+ /**
+ * Fetch metadata for a given set of addons from AMO.
+ *
+ * @param aIDs
+ * The array of ids to retrieve metadata for.
+ * @returns {array<AddonSearchResult>}
+ */
+ async getAddonsByIDs(aIDs) {
+ return this._fetchPaged(aIDs, PREF_GETADDONS_BYIDS, results =>
+ results.map(entry => this._parseAddon(entry))
+ );
+ },
+
+ /**
+ * Fetch addon metadata for a set of addons.
+ *
+ * @param {array<string>} aIDs
+ * A list of addon IDs to fetch information about.
+ *
+ * @returns {array<AddonSearchResult>}
+ */
+ async _getFullData(aIDs) {
+ let addons = [];
+ try {
+ addons = await this.getAddonsByIDs(aIDs, false);
+ } catch (err) {
+ logger.error(`Error in addon metadata check: ${err.message}`);
+ }
+
+ return addons;
+ },
+
+ /**
+ * Asynchronously add add-ons to the cache corresponding to the specified
+ * ids. If caching is disabled, the cache is unchanged.
+ *
+ * @param aIds
+ * The array of add-on ids to add to the cache
+ */
+ async cacheAddons(aIds) {
+ logger.debug(
+ "cacheAddons: enabled " + this.cacheEnabled + " IDs " + aIds.toSource()
+ );
+ if (!this.cacheEnabled) {
+ return [];
+ }
+
+ let ids = await getAddonsToCache(aIds);
+
+ // If there are no add-ons to cache, act as if caching is disabled
+ if (!ids.length) {
+ return [];
+ }
+
+ let addons = await this._getFullData(ids);
+ await AddonDatabase.update(addons);
+
+ return Array.from(addons.values());
+ },
+
+ /**
+ * Performs the daily background update check.
+ *
+ * @return Promise{null} Resolves when the metadata update is complete.
+ */
+ async backgroundUpdateCheck() {
+ let shutter = (async () => {
+ let allAddons = await lazy.AddonManager.getAllAddons();
+
+ // Completely remove cache if caching is not enabled
+ if (!this.cacheEnabled) {
+ logger.debug("Clearing cache because it is disabled");
+ await this._clearCache();
+ return;
+ }
+
+ let ids = allAddons.map(a => a.id);
+ logger.debug("Repopulate add-on cache with " + ids.toSource());
+
+ let addonsToCache = await getAddonsToCache(ids);
+
+ // Completely remove cache if there are no add-ons to cache
+ if (!addonsToCache.length) {
+ logger.debug("Clearing cache because 0 add-ons were requested");
+ await this._clearCache();
+ return;
+ }
+
+ let addons = await this._getFullData(addonsToCache);
+
+ AddonDatabase.repopulate(addons);
+
+ // Always call AddonManager updateAddonRepositoryData after we refill the cache
+ await lazy.AddonManagerPrivate.updateAddonRepositoryData();
+ })();
+ lazy.AddonManager.beforeShutdown.addBlocker(
+ "AddonRepository Background Updater",
+ shutter
+ );
+ await shutter;
+ lazy.AddonManager.beforeShutdown.removeBlocker(shutter);
+ },
+
+ /*
+ * Creates an AddonSearchResult by parsing an entry from the AMO API.
+ *
+ * @param aEntry
+ * An entry from the AMO search API to parse.
+ * @return Result object containing the parsed AddonSearchResult
+ */
+ _parseAddon(aEntry) {
+ let addon = new AddonSearchResult(aEntry.guid);
+
+ addon.name = aEntry.name;
+ if (typeof aEntry.current_version == "object") {
+ addon.version = String(aEntry.current_version.version);
+ if (Array.isArray(aEntry.current_version.files)) {
+ for (let file of aEntry.current_version.files) {
+ if (file.platform == "all" || file.platform == lazy.PLATFORM) {
+ if (file.url) {
+ addon.sourceURI = lazy.NetUtil.newURI(file.url);
+ }
+ break;
+ }
+ }
+ }
+ }
+ addon.homepageURL = aEntry.homepage;
+ addon.supportURL = aEntry.support_url;
+
+ addon.description = convertHTMLToPlainText(aEntry.summary);
+ addon.fullDescription = convertHTMLToPlainText(aEntry.description);
+
+ addon.weeklyDownloads = aEntry.weekly_downloads;
+
+ switch (aEntry.type) {
+ case "persona":
+ case "statictheme":
+ addon.type = "theme";
+ break;
+
+ case "language":
+ addon.type = "locale";
+ break;
+
+ default:
+ addon.type = aEntry.type;
+ break;
+ }
+
+ if (Array.isArray(aEntry.authors)) {
+ let authors = aEntry.authors.map(
+ author =>
+ new lazy.AddonManagerPrivate.AddonAuthor(author.name, author.url)
+ );
+ if (authors.length) {
+ addon.creator = authors[0];
+ addon.developers = authors.slice(1);
+ }
+ }
+
+ if (typeof aEntry.previews == "object") {
+ addon.screenshots = aEntry.previews.map(shot => {
+ let safeSize = orig =>
+ Array.isArray(orig) && orig.length >= 2 ? orig : [null, null];
+ let imageSize = safeSize(shot.image_size);
+ let thumbSize = safeSize(shot.thumbnail_size);
+ return new lazy.AddonManagerPrivate.AddonScreenshot(
+ shot.image_url,
+ imageSize[0],
+ imageSize[1],
+ shot.thumbnail_url,
+ thumbSize[0],
+ thumbSize[1],
+ shot.caption
+ );
+ });
+ }
+
+ addon.contributionURL = aEntry.contributions_url;
+
+ if (typeof aEntry.ratings == "object") {
+ addon.averageRating = Math.min(5, aEntry.ratings.average);
+ addon.reviewCount = aEntry.ratings.text_count;
+ }
+
+ addon.reviewURL = aEntry.ratings_url;
+ if (aEntry.last_updated) {
+ addon.updateDate = new Date(aEntry.last_updated);
+ }
+
+ addon.icons = aEntry.icons || {};
+
+ return addon;
+ },
+
+ // Create url from preference, returning null if preference does not exist
+ _formatURLPref(aPreference, aSubstitutions = {}) {
+ let url = Services.prefs.getCharPref(aPreference, "");
+ if (!url) {
+ logger.warn("_formatURLPref: Couldn't get pref: " + aPreference);
+ return null;
+ }
+
+ url = url.replace(/%([A-Z_]+)%/g, function (aMatch, aKey) {
+ return aKey in aSubstitutions
+ ? encodeURIComponent(aSubstitutions[aKey])
+ : aMatch;
+ });
+
+ return Services.urlFormatter.formatURL(url);
+ },
+
+ flush() {
+ return AddonDatabase.flush();
+ },
+
+ async getAvailableLangpacks() {
+ // This should be the API endpoint documented at:
+ // http://addons-server.readthedocs.io/en/latest/topics/api/addons.html#language-tools
+ let url = this._formatURLPref(PREF_GET_LANGPACKS);
+
+ let response = await fetch(url, { credentials: "omit" });
+ if (!response.ok) {
+ throw new Error("fetching available language packs failed");
+ }
+
+ let data = await response.json();
+
+ let result = [];
+ for (let entry of data.results) {
+ if (
+ !entry.current_compatible_version ||
+ !entry.current_compatible_version.files
+ ) {
+ continue;
+ }
+
+ for (let file of entry.current_compatible_version.files) {
+ if (
+ file.platform == "all" ||
+ file.platform == Services.appinfo.OS.toLowerCase()
+ ) {
+ result.push({
+ target_locale: entry.target_locale,
+ url: file.url,
+ hash: file.hash,
+ });
+ }
+ }
+ }
+
+ return result;
+ },
+};
+
+var AddonDatabase = {
+ connectionPromise: null,
+ _loaded: false,
+ _saveTask: null,
+ _blockerAdded: false,
+
+ // the in-memory database
+ DB: BLANK_DB(),
+
+ /**
+ * A getter to retrieve the path to the DB
+ */
+ get jsonFile() {
+ return PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ FILE_DATABASE
+ );
+ },
+
+ /**
+ * Asynchronously opens a new connection to the database file.
+ *
+ * @return {Promise} a promise that resolves to the database.
+ */
+ openConnection() {
+ if (!this.connectionPromise) {
+ this.connectionPromise = (async () => {
+ let inputDB, schema;
+
+ try {
+ let data = await IOUtils.readUTF8(this.jsonFile);
+ inputDB = JSON.parse(data);
+
+ if (
+ !inputDB.hasOwnProperty("addons") ||
+ !Array.isArray(inputDB.addons)
+ ) {
+ throw new Error("No addons array.");
+ }
+
+ if (!inputDB.hasOwnProperty("schema")) {
+ throw new Error("No schema specified.");
+ }
+
+ schema = parseInt(inputDB.schema, 10);
+
+ if (!Number.isInteger(schema) || schema < DB_MIN_JSON_SCHEMA) {
+ throw new Error("Invalid schema value.");
+ }
+ } catch (e) {
+ if (e.name == "NotFoundError") {
+ logger.debug("No " + FILE_DATABASE + " found.");
+ } else {
+ logger.error(
+ `Malformed ${FILE_DATABASE}: ${e} - resetting to empty`
+ );
+ }
+
+ // Create a blank addons.json file
+ this.save();
+
+ Services.prefs.setIntPref(PREF_GETADDONS_DB_SCHEMA, DB_SCHEMA);
+ this._loaded = true;
+ return this.DB;
+ }
+
+ Services.prefs.setIntPref(PREF_GETADDONS_DB_SCHEMA, DB_SCHEMA);
+
+ // Convert the addon objects as necessary
+ // and store them in our in-memory copy of the database.
+ for (let addon of inputDB.addons) {
+ let id = addon.id;
+
+ let entry = this._parseAddon(addon);
+ this.DB.addons.set(id, entry);
+ }
+
+ this._loaded = true;
+ return this.DB;
+ })();
+ }
+
+ return this.connectionPromise;
+ },
+
+ /**
+ * Asynchronously shuts down the database connection and releases all
+ * cached objects
+ *
+ * @param aCallback
+ * An optional callback to call once complete
+ * @param aSkipFlush
+ * An optional boolean to skip flushing data to disk. Useful
+ * when the database is going to be deleted afterwards.
+ */
+ shutdown(aSkipFlush) {
+ if (!this.connectionPromise) {
+ return Promise.resolve();
+ }
+
+ this.connectionPromise = null;
+ this._loaded = false;
+
+ if (aSkipFlush) {
+ return Promise.resolve();
+ }
+
+ return this.flush();
+ },
+
+ /**
+ * Asynchronously deletes the database, shutting down the connection
+ * first if initialized
+ *
+ * @param aCallback
+ * An optional callback to call once complete
+ * @return Promise{null} resolves when the database has been deleted
+ */
+ delete(aCallback) {
+ this.DB = BLANK_DB();
+
+ if (this._saveTask) {
+ this._saveTask.disarm();
+ this._saveTask = null;
+ }
+
+ // shutdown(true) never rejects
+ this._deleting = this.shutdown(true)
+ .then(() => IOUtils.remove(this.jsonFile))
+ .catch(error =>
+ logger.error(
+ "Unable to delete Addon Repository file " + this.jsonFile,
+ error
+ )
+ )
+ .then(() => (this._deleting = null))
+ .then(aCallback);
+
+ return this._deleting;
+ },
+
+ async _saveNow() {
+ let json = {
+ schema: this.DB.schema,
+ addons: Array.from(this.DB.addons.values()),
+ };
+
+ await IOUtils.writeUTF8(this.jsonFile, JSON.stringify(json), {
+ tmpPath: `${this.jsonFile}.tmp`,
+ });
+ },
+
+ save() {
+ if (!this._saveTask) {
+ this._saveTask = new lazy.DeferredTask(
+ () => this._saveNow(),
+ DB_BATCH_TIMEOUT_MS
+ );
+
+ if (!this._blockerAdded) {
+ lazy.AsyncShutdown.profileBeforeChange.addBlocker(
+ "Flush AddonRepository",
+ () => this.flush()
+ );
+ this._blockerAdded = true;
+ }
+ }
+ this._saveTask.arm();
+ },
+
+ /**
+ * Flush any pending I/O on the addons.json file
+ * @return: Promise{null}
+ * Resolves when the pending I/O (writing out or deleting
+ * addons.json) completes
+ */
+ flush() {
+ if (this._deleting) {
+ return this._deleting;
+ }
+
+ if (this._saveTask) {
+ let promise = this._saveTask.finalize();
+ this._saveTask = null;
+ return promise;
+ }
+
+ return Promise.resolve();
+ },
+
+ /**
+ * Get an individual addon entry from the in-memory cache.
+ * Note: calling this function before the database is read will
+ * return undefined.
+ *
+ * @param {string} aId The id of the addon to retrieve.
+ */
+ getAddon(aId) {
+ return this.DB.addons.get(aId);
+ },
+
+ /**
+ * Asynchronously repopulates the database so it only contains the
+ * specified add-ons
+ *
+ * @param {Map} aAddons
+ * Add-ons to repopulate the database with.
+ */
+ repopulate(aAddons) {
+ this.DB = BLANK_DB();
+ this._update(aAddons);
+
+ let now = Math.round(Date.now() / 1000);
+ logger.debug(
+ "Cache repopulated, setting " + PREF_METADATA_LASTUPDATE + " to " + now
+ );
+ Services.prefs.setIntPref(PREF_METADATA_LASTUPDATE, now);
+ },
+
+ /**
+ * Asynchronously insert new addons into the database.
+ *
+ * @param {Map} aAddons
+ * Add-ons to insert/update in the database
+ */
+ async update(aAddons) {
+ await this.openConnection();
+
+ this._update(aAddons);
+
+ this.save();
+ },
+
+ /**
+ * Merge the given addons into the database.
+ *
+ * @param {Map} aAddons
+ * Add-ons to insert/update in the database
+ */
+ _update(aAddons) {
+ for (let addon of aAddons) {
+ this.DB.addons.set(addon.id, this._parseAddon(addon));
+ }
+
+ this.save();
+ },
+
+ /*
+ * Creates an AddonSearchResult by parsing an object structure
+ * retrieved from the DB JSON representation.
+ *
+ * @param aObj
+ * The object to parse
+ * @return Returns an AddonSearchResult object.
+ */
+ _parseAddon(aObj) {
+ if (aObj instanceof AddonSearchResult) {
+ return aObj;
+ }
+
+ let id = aObj.id;
+ if (!aObj.id) {
+ return null;
+ }
+
+ let addon = new AddonSearchResult(id);
+
+ for (let expectedProperty of Object.keys(AddonSearchResult.prototype)) {
+ if (
+ !(expectedProperty in aObj) ||
+ typeof aObj[expectedProperty] === "function"
+ ) {
+ continue;
+ }
+
+ let value = aObj[expectedProperty];
+
+ try {
+ switch (expectedProperty) {
+ case "sourceURI":
+ addon.sourceURI = value ? lazy.NetUtil.newURI(value) : null;
+ break;
+
+ case "creator":
+ addon.creator = value ? this._makeDeveloper(value) : null;
+ break;
+
+ case "updateDate":
+ addon.updateDate = value ? new Date(value) : null;
+ break;
+
+ case "developers":
+ if (!addon.developers) {
+ addon.developers = [];
+ }
+ for (let developer of value) {
+ addon.developers.push(this._makeDeveloper(developer));
+ }
+ break;
+
+ case "screenshots":
+ if (!addon.screenshots) {
+ addon.screenshots = [];
+ }
+ for (let screenshot of value) {
+ addon.screenshots.push(this._makeScreenshot(screenshot));
+ }
+ break;
+
+ case "icons":
+ if (!addon.icons) {
+ addon.icons = {};
+ }
+ for (let size of Object.keys(aObj.icons)) {
+ addon.icons[size] = aObj.icons[size];
+ }
+ break;
+
+ case "iconURL":
+ break;
+
+ default:
+ addon[expectedProperty] = value;
+ }
+ } catch (ex) {
+ logger.warn(
+ "Error in parsing property value for " + expectedProperty + " | " + ex
+ );
+ }
+
+ // delete property from obj to indicate we've already
+ // handled it. The remaining public properties will
+ // be stored separately and just passed through to
+ // be written back to the DB.
+ delete aObj[expectedProperty];
+ }
+
+ // Copy remaining properties to a separate object
+ // to prevent accidental access on downgraded versions.
+ // The properties will be merged in the same object
+ // prior to being written back through toJSON.
+ for (let remainingProperty of Object.keys(aObj)) {
+ switch (typeof aObj[remainingProperty]) {
+ case "boolean":
+ case "number":
+ case "string":
+ case "object":
+ // these types are accepted
+ break;
+ default:
+ continue;
+ }
+
+ if (!remainingProperty.startsWith("_")) {
+ addon._unsupportedProperties[remainingProperty] =
+ aObj[remainingProperty];
+ }
+ }
+
+ return addon;
+ },
+
+ /**
+ * Make a developer object from a vanilla
+ * JS object from the JSON database
+ *
+ * @param aObj
+ * The JS object to use
+ * @return The created developer
+ */
+ _makeDeveloper(aObj) {
+ let name = aObj.name;
+ let url = aObj.url;
+ return new lazy.AddonManagerPrivate.AddonAuthor(name, url);
+ },
+
+ /**
+ * Make a screenshot object from a vanilla
+ * JS object from the JSON database
+ *
+ * @param aObj
+ * The JS object to use
+ * @return The created screenshot
+ */
+ _makeScreenshot(aObj) {
+ let url = aObj.url;
+ let width = aObj.width;
+ let height = aObj.height;
+ let thumbnailURL = aObj.thumbnailURL;
+ let thumbnailWidth = aObj.thumbnailWidth;
+ let thumbnailHeight = aObj.thumbnailHeight;
+ let caption = aObj.caption;
+ return new lazy.AddonManagerPrivate.AddonScreenshot(
+ url,
+ width,
+ height,
+ thumbnailURL,
+ thumbnailWidth,
+ thumbnailHeight,
+ caption
+ );
+ },
+};
diff --git a/toolkit/mozapps/extensions/internal/AddonSettings.sys.mjs b/toolkit/mozapps/extensions/internal/AddonSettings.sys.mjs
new file mode 100644
index 0000000000..09bb0adc97
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/AddonSettings.sys.mjs
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+import { AddonManager } from "resource://gre/modules/AddonManager.sys.mjs";
+
+const PREF_SIGNATURES_REQUIRED = "xpinstall.signatures.required";
+const PREF_LANGPACK_SIGNATURES = "extensions.langpacks.signatures.required";
+const PREF_ALLOW_EXPERIMENTS = "extensions.experiments.enabled";
+const PREF_EM_SIDELOAD_SCOPES = "extensions.sideloadScopes";
+const PREF_IS_EMBEDDED = "extensions.isembedded";
+const PREF_UPDATE_REQUIREBUILTINCERTS = "extensions.update.requireBuiltInCerts";
+const PREF_INSTALL_REQUIREBUILTINCERTS =
+ "extensions.install.requireBuiltInCerts";
+
+export var AddonSettings = {};
+
+// Make a non-changable property that can't be manipulated from other
+// code in the app.
+function makeConstant(name, value) {
+ Object.defineProperty(AddonSettings, name, {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value,
+ });
+}
+
+if (AppConstants.MOZ_REQUIRE_SIGNING && !Cu.isInAutomation) {
+ makeConstant("REQUIRE_SIGNING", true);
+ makeConstant("LANGPACKS_REQUIRE_SIGNING", true);
+} else {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "REQUIRE_SIGNING",
+ PREF_SIGNATURES_REQUIRED,
+ false
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "LANGPACKS_REQUIRE_SIGNING",
+ PREF_LANGPACK_SIGNATURES,
+ false
+ );
+}
+
+/**
+ * Require the use of certs shipped with Firefox for
+ * addon install and update, if the distribution does
+ * not require addon signing and is not ESR.
+ */
+XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "INSTALL_REQUIREBUILTINCERTS",
+ PREF_INSTALL_REQUIREBUILTINCERTS,
+ !AppConstants.MOZ_REQUIRE_SIGNING &&
+ !AppConstants.MOZ_APP_VERSION_DISPLAY.endsWith("esr")
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "UPDATE_REQUIREBUILTINCERTS",
+ PREF_UPDATE_REQUIREBUILTINCERTS,
+ !AppConstants.MOZ_REQUIRE_SIGNING &&
+ !AppConstants.MOZ_APP_VERSION_DISPLAY.endsWith("esr")
+);
+
+// Whether or not we're running in GeckoView embedded in an Android app
+if (Cu.isInAutomation) {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "IS_EMBEDDED",
+ PREF_IS_EMBEDDED,
+ false
+ );
+} else {
+ makeConstant("IS_EMBEDDED", AppConstants.platform === "android");
+}
+
+/**
+ * AddonSettings.EXPERIMENTS_ENABLED
+ *
+ * Experimental APIs are always available to privileged signed addons.
+ * This constant makes an optional preference available to enable experimental
+ * APIs for developement purposes.
+ *
+ * Two features are toggled with this preference:
+ *
+ * 1. The ability to load an extension that contains an experimental
+ * API but is not privileged.
+ * 2. The ability to load an unsigned extension that gains privilege
+ * if it is temporarily loaded (e.g. via about:debugging).
+ *
+ * MOZ_REQUIRE_SIGNING is set to zero in unbranded builds, we also
+ * ensure nightly, dev-ed and our test infrastructure have access to
+ * the preference.
+ *
+ * Official releases ignore this preference.
+ */
+if (
+ !AppConstants.MOZ_REQUIRE_SIGNING ||
+ AppConstants.NIGHTLY_BUILD ||
+ AppConstants.MOZ_DEV_EDITION ||
+ Cu.isInAutomation
+) {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "EXPERIMENTS_ENABLED",
+ PREF_ALLOW_EXPERIMENTS,
+ true
+ );
+} else {
+ makeConstant("EXPERIMENTS_ENABLED", false);
+}
+
+if (AppConstants.MOZ_DEV_EDITION) {
+ makeConstant("DEFAULT_THEME_ID", "firefox-compact-dark@mozilla.org");
+} else {
+ makeConstant("DEFAULT_THEME_ID", "default-theme@mozilla.org");
+}
+
+// SCOPES_SIDELOAD is a bitflag for what scopes we will load new extensions from when we scan the directories.
+// If a build allows sideloading, or we're in automation, we'll also allow use of the preference.
+if (AppConstants.MOZ_ALLOW_ADDON_SIDELOAD || Cu.isInAutomation) {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "SCOPES_SIDELOAD",
+ PREF_EM_SIDELOAD_SCOPES,
+ AppConstants.MOZ_ALLOW_ADDON_SIDELOAD
+ ? AddonManager.SCOPE_ALL
+ : AddonManager.SCOPE_PROFILE
+ );
+} else {
+ makeConstant("SCOPES_SIDELOAD", AddonManager.SCOPE_PROFILE);
+}
diff --git a/toolkit/mozapps/extensions/internal/AddonTestUtils.sys.mjs b/toolkit/mozapps/extensions/internal/AddonTestUtils.sys.mjs
new file mode 100644
index 0000000000..92aac8078f
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/AddonTestUtils.sys.mjs
@@ -0,0 +1,1815 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* eslint "mozilla/no-aArgs": 1 */
+/* eslint "no-unused-vars": [2, {"args": "none", "varsIgnorePattern": "^(Cc|Ci|Cr|Cu|EXPORTED_SYMBOLS)$"}] */
+/* eslint "semi": [2, "always"] */
+/* eslint "valid-jsdoc": [2, {requireReturn: false}] */
+
+const CERTDB_CONTRACTID = "@mozilla.org/security/x509certdb;1";
+
+import {
+ AddonManager,
+ AddonManagerPrivate,
+} from "resource://gre/modules/AddonManager.sys.mjs";
+import { AsyncShutdown } from "resource://gre/modules/AsyncShutdown.sys.mjs";
+import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
+
+const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExtensionAddonObserver: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs",
+ FileTestUtils: "resource://testing-common/FileTestUtils.sys.mjs",
+ Management: "resource://gre/modules/Extension.sys.mjs",
+ MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs",
+
+ XPCShellContentUtils:
+ "resource://testing-common/XPCShellContentUtils.sys.mjs",
+
+ getAppInfo: "resource://testing-common/AppInfo.sys.mjs",
+ updateAppInfo: "resource://testing-common/AppInfo.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ aomStartup: [
+ "@mozilla.org/addons/addon-manager-startup;1",
+ "amIAddonManagerStartup",
+ ],
+});
+
+const ArrayBufferInputStream = Components.Constructor(
+ "@mozilla.org/io/arraybuffer-input-stream;1",
+ "nsIArrayBufferInputStream",
+ "setData"
+);
+
+const nsFile = Components.Constructor(
+ "@mozilla.org/file/local;1",
+ "nsIFile",
+ "initWithPath"
+);
+
+const ZipReader = Components.Constructor(
+ "@mozilla.org/libjar/zip-reader;1",
+ "nsIZipReader",
+ "open"
+);
+
+const ZipWriter = Components.Constructor(
+ "@mozilla.org/zipwriter;1",
+ "nsIZipWriter",
+ "open"
+);
+
+function isRegExp(val) {
+ return val && typeof val === "object" && typeof val.test === "function";
+}
+
+class MockBarrier {
+ constructor(name) {
+ this.name = name;
+ this.blockers = [];
+ }
+
+ addBlocker(name, blocker, options) {
+ this.blockers.push({ name, blocker, options });
+ }
+
+ async trigger() {
+ await Promise.all(
+ this.blockers.map(async ({ blocker, name }) => {
+ try {
+ if (typeof blocker == "function") {
+ await blocker();
+ } else {
+ await blocker;
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ dump(
+ `Shutdown blocker '${name}' for ${this.name} threw error: ${e} :: ${e.stack}\n`
+ );
+ }
+ })
+ );
+
+ this.blockers = [];
+ }
+}
+
+// Mock out AddonManager's reference to the AsyncShutdown module so we can shut
+// down AddonManager from the test
+export var MockAsyncShutdown = {
+ profileBeforeChange: new MockBarrier("profileBeforeChange"),
+ profileChangeTeardown: new MockBarrier("profileChangeTeardown"),
+ quitApplicationGranted: new MockBarrier("quitApplicationGranted"),
+ // We can use the real Barrier
+ Barrier: AsyncShutdown.Barrier,
+};
+
+AddonManagerPrivate.overrideAsyncShutdown(MockAsyncShutdown);
+
+class AddonsList {
+ constructor(file) {
+ this.extensions = [];
+ this.themes = [];
+ this.xpis = [];
+
+ if (!file.exists()) {
+ return;
+ }
+
+ let data = lazy.aomStartup.readStartupData();
+
+ for (let loc of Object.values(data)) {
+ let dir = loc.path && new nsFile(loc.path);
+
+ for (let addon of Object.values(loc.addons)) {
+ let file;
+ if (dir) {
+ file = dir.clone();
+ try {
+ file.appendRelativePath(addon.path);
+ } catch (e) {
+ file = new nsFile(addon.path);
+ }
+ } else if (addon.path) {
+ file = new nsFile(addon.path);
+ }
+
+ if (!file) {
+ continue;
+ }
+
+ this.xpis.push(file);
+
+ if (addon.enabled) {
+ addon.type = addon.type || "extension";
+
+ if (addon.type == "theme") {
+ this.themes.push(file);
+ } else {
+ this.extensions.push(file);
+ }
+ }
+ }
+ }
+ }
+
+ hasItem(type, dir, id) {
+ var path = dir.clone();
+ path.append(id);
+
+ var xpiPath = dir.clone();
+ xpiPath.append(`${id}.xpi`);
+
+ return this[type].some(file => {
+ if (!file.exists()) {
+ throw new Error(
+ `Non-existent path found in addonStartup.json: ${file.path}`
+ );
+ }
+
+ if (file.isDirectory()) {
+ return file.equals(path);
+ }
+ if (file.isFile()) {
+ return file.equals(xpiPath);
+ }
+ return false;
+ });
+ }
+
+ hasTheme(dir, id) {
+ return this.hasItem("themes", dir, id);
+ }
+
+ hasExtension(dir, id) {
+ return this.hasItem("extensions", dir, id);
+ }
+}
+
+export var AddonTestUtils = {
+ addonIntegrationService: null,
+ addonsList: null,
+ appInfo: null,
+ addonStartup: null,
+ collectedTelemetryEvents: [],
+ testScope: null,
+ testUnpacked: false,
+ useRealCertChecks: false,
+ usePrivilegedSignatures: true,
+ certSignatureDate: null,
+ overrideEntry: null,
+
+ maybeInit(testScope) {
+ if (this.testScope != testScope) {
+ this.init(testScope);
+ }
+ },
+
+ init(testScope, enableLogging = true) {
+ if (this.testScope === testScope) {
+ return;
+ }
+ this.testScope = testScope;
+
+ // Get the profile directory for tests to use.
+ this.profileDir = testScope.do_get_profile();
+
+ this.profileExtensions = this.profileDir.clone();
+ this.profileExtensions.append("extensions");
+
+ this.addonStartup = this.profileDir.clone();
+ this.addonStartup.append("addonStartup.json.lz4");
+
+ // Register a temporary directory for the tests.
+ this.tempDir = this.profileDir.clone();
+ this.tempDir.append("temp");
+ this.tempDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ this.registerDirectory("TmpD", this.tempDir);
+
+ // Create a replacement app directory for the tests.
+ const appDirForAddons = this.profileDir.clone();
+ appDirForAddons.append("appdir-addons");
+ appDirForAddons.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+ this.registerDirectory("XREAddonAppDir", appDirForAddons);
+
+ // Enable more extensive EM logging.
+ if (enableLogging) {
+ Services.prefs.setBoolPref("extensions.logging.enabled", true);
+ }
+
+ // By default only load extensions from the profile install location
+ Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ AddonManager.SCOPE_PROFILE
+ );
+
+ // By default don't disable add-ons from any scope
+ Services.prefs.setIntPref("extensions.autoDisableScopes", 0);
+
+ // And scan for changes at startup
+ Services.prefs.setIntPref("extensions.startupScanScopes", 15);
+
+ // By default, don't cache add-ons in AddonRepository.jsm
+ Services.prefs.setBoolPref("extensions.getAddons.cache.enabled", false);
+
+ // Point update checks to the local machine for fast failures
+ Services.prefs.setCharPref(
+ "extensions.update.url",
+ "http://127.0.0.1/updateURL"
+ );
+ Services.prefs.setCharPref(
+ "extensions.update.background.url",
+ "http://127.0.0.1/updateBackgroundURL"
+ );
+ Services.prefs.setCharPref(
+ "services.settings.server",
+ "data:,#remote-settings-dummy/v1"
+ );
+
+ // By default ignore bundled add-ons
+ Services.prefs.setBoolPref("extensions.installDistroAddons", false);
+
+ // Ensure signature checks are enabled by default
+ Services.prefs.setBoolPref("xpinstall.signatures.required", true);
+
+ // Make sure that a given path does not exist
+ function pathShouldntExist(file) {
+ if (file.exists()) {
+ throw new Error(
+ `Test cleanup: path ${file.path} exists when it should not`
+ );
+ }
+ }
+
+ testScope.registerCleanupFunction(() => {
+ // Force a GC to ensure that anything holding a ref to temp file releases it.
+ // XXX This shouldn't be needed here, since cleanupTempXPIs() does a GC if
+ // something fails; see bug 1761255
+ this.info(`Force a GC`);
+ Cu.forceGC();
+
+ this.cleanupTempXPIs();
+
+ let ignoreEntries = new Set();
+ {
+ // FileTestUtils lazily creates a directory to hold the temporary files
+ // it creates. If that directory exists, ignore it.
+ let { value } = Object.getOwnPropertyDescriptor(
+ lazy.FileTestUtils,
+ "_globalTemporaryDirectory"
+ );
+ if (value) {
+ ignoreEntries.add(value.leafName);
+ }
+ }
+
+ // Check that the temporary directory is empty
+ var entries = [];
+ for (let { leafName } of this.iterDirectory(this.tempDir)) {
+ if (!ignoreEntries.has(leafName)) {
+ entries.push(leafName);
+ }
+ }
+ if (entries.length) {
+ throw new Error(
+ `Found unexpected files in temporary directory: ${entries.join(", ")}`
+ );
+ }
+
+ try {
+ appDirForAddons.remove(true);
+ } catch (ex) {
+ testScope.info(`Got exception removing addon app dir: ${ex}`);
+ }
+
+ // ensure no leftover files in the system addon upgrade location
+ let featuresDir = this.profileDir.clone();
+ featuresDir.append("features");
+ // upgrade directories will be in UUID folders under features/
+ for (let dir of this.iterDirectory(featuresDir)) {
+ dir.append("stage");
+ pathShouldntExist(dir);
+ }
+
+ // ensure no leftover files in the user addon location
+ let testDir = this.profileDir.clone();
+ testDir.append("extensions");
+ testDir.append("trash");
+ pathShouldntExist(testDir);
+
+ testDir.leafName = "staged";
+ pathShouldntExist(testDir);
+
+ return this.promiseShutdownManager();
+ });
+ },
+
+ initMochitest(testScope) {
+ if (this.testScope === testScope) {
+ return;
+ }
+ this.testScope = testScope;
+
+ this.profileDir = FileUtils.getDir("ProfD", []);
+
+ this.profileExtensions = FileUtils.getDir("ProfD", ["extensions"]);
+
+ this.tempDir = FileUtils.getDir("TmpD", []);
+ this.tempDir.append("addons-mochitest");
+ this.tempDir.createUnique(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+
+ testScope.registerCleanupFunction(() => {
+ // Defer testScope cleanup until the last cleanup function has run.
+ testScope.registerCleanupFunction(() => {
+ this.testScope = null;
+ });
+ this.cleanupTempXPIs();
+ try {
+ this.tempDir.remove(true);
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ });
+ },
+
+ /**
+ * Iterates over the entries in a given directory.
+ *
+ * Fails silently if the given directory does not exist.
+ *
+ * @param {nsIFile} dir
+ * Directory to iterate.
+ */
+ *iterDirectory(dir) {
+ let dirEnum;
+ try {
+ dirEnum = dir.directoryEntries;
+ let file;
+ while ((file = dirEnum.nextFile)) {
+ yield file;
+ }
+ } catch (e) {
+ if (dir.exists()) {
+ Cu.reportError(e);
+ }
+ } finally {
+ if (dirEnum) {
+ dirEnum.close();
+ }
+ }
+ },
+
+ /**
+ * Creates a new HttpServer for testing, and begins listening on the
+ * specified port. Automatically shuts down the server when the test
+ * unit ends.
+ *
+ * @param {object} [options = {}]
+ * The options object.
+ * @param {integer} [options.port = -1]
+ * The port to listen on. If omitted, listen on a random
+ * port. The latter is the preferred behavior.
+ * @param {sequence<string>?} [options.hosts = null]
+ * A set of hosts to accept connections to. Support for this is
+ * implemented using a proxy filter.
+ *
+ * @returns {HttpServer}
+ * The HTTP server instance.
+ */
+ createHttpServer(...args) {
+ lazy.XPCShellContentUtils.ensureInitialized(this.testScope);
+ return lazy.XPCShellContentUtils.createHttpServer(...args);
+ },
+
+ registerJSON(...args) {
+ return lazy.XPCShellContentUtils.registerJSON(...args);
+ },
+
+ info(msg) {
+ // info() for mochitests, do_print for xpcshell.
+ let print = this.testScope.info || this.testScope.do_print;
+ print(msg);
+ },
+
+ cleanupTempXPIs() {
+ let didGC = false;
+
+ for (let file of this.tempXPIs.splice(0)) {
+ if (file.exists()) {
+ try {
+ Services.obs.notifyObservers(file, "flush-cache-entry");
+ file.remove(false);
+ } catch (e) {
+ if (didGC) {
+ Cu.reportError(`Failed to remove ${file.path}: ${e}`);
+ } else {
+ // Bug 1606684 - Sometimes XPI files are still in use by a process
+ // after the test has been finished. Force a GC once and try again.
+ this.info(`Force a GC`);
+ Cu.forceGC();
+ didGC = true;
+
+ try {
+ file.remove(false);
+ } catch (e) {
+ Cu.reportError(`Failed to remove ${file.path} after GC: ${e}`);
+ }
+ }
+ }
+ }
+ }
+ },
+
+ createAppInfo(ID, name, version, platformVersion = "1.0") {
+ lazy.updateAppInfo({
+ ID,
+ name,
+ version,
+ platformVersion,
+ crashReporter: true,
+ });
+ this.appInfo = lazy.getAppInfo();
+ },
+
+ getManifestURI(file) {
+ if (file.isDirectory()) {
+ file.leafName = "manifest.json";
+ if (file.exists()) {
+ return NetUtil.newURI(file);
+ }
+
+ throw new Error("No manifest file present");
+ }
+
+ let zip = ZipReader(file);
+ try {
+ let uri = NetUtil.newURI(file);
+
+ if (zip.hasEntry("manifest.json")) {
+ return NetUtil.newURI(`jar:${uri.spec}!/manifest.json`);
+ }
+
+ throw new Error("No manifest file present");
+ } finally {
+ zip.close();
+ }
+ },
+
+ getIDFromExtension(file) {
+ return this.getIDFromManifest(this.getManifestURI(file));
+ },
+
+ async getIDFromManifest(manifestURI) {
+ let body = await fetch(manifestURI.spec);
+ let manifest = await body.json();
+ try {
+ if (manifest.browser_specific_settings?.gecko?.id) {
+ return manifest.browser_specific_settings.gecko.id;
+ }
+ return manifest.applications.gecko.id;
+ } catch (e) {
+ // IDs for WebExtensions are extracted from the certificate when
+ // not present in the manifest, so just generate a random one.
+ return Services.uuid.generateUUID().number;
+ }
+ },
+
+ overrideCertDB() {
+ let verifyCert = async (file, result, cert, callback) => {
+ if (
+ result == Cr.NS_ERROR_SIGNED_JAR_NOT_SIGNED &&
+ !this.useRealCertChecks &&
+ callback.wrappedJSObject
+ ) {
+ // Bypassing XPConnect allows us to create a fake x509 certificate from JS
+ callback = callback.wrappedJSObject;
+
+ try {
+ let id;
+ try {
+ let manifestURI = this.getManifestURI(file);
+ id = await this.getIDFromManifest(manifestURI);
+ } catch (err) {
+ if (file.leafName.endsWith(".xpi")) {
+ id = file.leafName.slice(0, -4);
+ }
+ }
+
+ let fakeCert = { commonName: id };
+ if (this.usePrivilegedSignatures) {
+ let privileged =
+ typeof this.usePrivilegedSignatures == "function"
+ ? this.usePrivilegedSignatures(id)
+ : this.usePrivilegedSignatures;
+ if (privileged === "system") {
+ fakeCert.organizationalUnit = "Mozilla Components";
+ } else if (privileged) {
+ fakeCert.organizationalUnit = "Mozilla Extensions";
+ }
+ }
+ if (this.certSignatureDate) {
+ // addon.signedDate is derived from this, used by the blocklist.
+ fakeCert.validity = {
+ notBefore: this.certSignatureDate * 1000,
+ };
+ }
+
+ return [callback, Cr.NS_OK, fakeCert];
+ } catch (e) {
+ // If there is any error then just pass along the original results
+ } finally {
+ // Make sure to close the open zip file or it will be locked.
+ if (file.isFile()) {
+ Services.obs.notifyObservers(
+ file,
+ "flush-cache-entry",
+ "cert-override"
+ );
+ }
+ }
+ }
+
+ return [callback, result, cert];
+ };
+
+ let FakeCertDB = {
+ init() {
+ for (let property of Object.keys(
+ this._genuine.QueryInterface(Ci.nsIX509CertDB)
+ )) {
+ if (property in this) {
+ continue;
+ }
+
+ if (typeof this._genuine[property] == "function") {
+ this[property] = this._genuine[property].bind(this._genuine);
+ }
+ }
+ },
+
+ openSignedAppFileAsync(root, file, callback) {
+ // First try calling the real cert DB
+ this._genuine.openSignedAppFileAsync(
+ root,
+ file,
+ (result, zipReader, cert) => {
+ verifyCert(file.clone(), result, cert, callback).then(
+ ([callback, result, cert]) => {
+ callback.openSignedAppFileFinished(result, zipReader, cert);
+ }
+ );
+ }
+ );
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIX509CertDB"]),
+ };
+
+ // Unregister the real database. This only works because the add-ons manager
+ // hasn't started up and grabbed the certificate database yet.
+ lazy.MockRegistrar.register(CERTDB_CONTRACTID, FakeCertDB);
+
+ // Initialize the mock service.
+ Cc[CERTDB_CONTRACTID].getService();
+ FakeCertDB.init();
+ },
+
+ /**
+ * Load the data from the specified files into the *real* blocklist providers.
+ * Loads using loadBlocklistRawData, which will treat this as an update.
+ *
+ * @param {nsIFile} dir
+ * The directory in which the files live.
+ * @param {string} prefix
+ * a prefix for the files which ought to be loaded.
+ * This method will suffix -extensions.json
+ * to the prefix it is given, and attempt to load it.
+ * If it exists, its data will be dumped into
+ * the respective store, and the update handler
+ * will be called.
+ */
+ async loadBlocklistData(dir, prefix) {
+ let loadedData = {};
+ let fileSuffix = "extensions";
+ const fileName = `${prefix}-${fileSuffix}.json`;
+
+ try {
+ loadedData[fileSuffix] = await IOUtils.readJSON(
+ PathUtils.join(dir.path, fileName)
+ );
+ this.info(`Loaded ${fileName}`);
+ } catch (e) {}
+
+ return this.loadBlocklistRawData(loadedData);
+ },
+
+ /**
+ * Load the following data into the *real* blocklist providers.
+ * Fires update methods as would happen if this data came from
+ * an actual blocklist update, etc.
+ *
+ * @param {object} data
+ * The data to load.
+ */
+ async loadBlocklistRawData(data) {
+ const { BlocklistPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/Blocklist.sys.mjs"
+ );
+ const blocklistMapping = {
+ extensions: BlocklistPrivate.ExtensionBlocklistRS,
+ extensionsMLBF: BlocklistPrivate.ExtensionBlocklistMLBF,
+ };
+
+ for (const [dataProp, blocklistObj] of Object.entries(blocklistMapping)) {
+ let newData = data[dataProp];
+ if (!newData) {
+ continue;
+ }
+ if (!Array.isArray(newData)) {
+ throw new Error(
+ "Expected an array of new items to put in the " +
+ dataProp +
+ " blocklist!"
+ );
+ }
+ for (let item of newData) {
+ if (!item.id) {
+ item.id = Services.uuid.generateUUID().number.slice(1, -1);
+ }
+ if (!item.last_modified) {
+ item.last_modified = Date.now();
+ }
+ }
+ blocklistObj.ensureInitialized();
+ let db = await blocklistObj._client.db;
+ const collectionTimestamp = Math.max(
+ ...newData.map(r => r.last_modified)
+ );
+ await db.importChanges({}, collectionTimestamp, newData, {
+ clear: true,
+ });
+ // We manually call _onUpdate... which is evil, but at the moment kinto doesn't have
+ // a better abstraction unless you want to mock your own http server to do the update.
+ await blocklistObj._onUpdate();
+ }
+ },
+
+ /**
+ * Starts up the add-on manager as if it was started by the application.
+ *
+ * @param {Object} params
+ * The new params are in an object and new code should use that.
+ * @param {boolean} params.earlyStartup
+ * Notifies early startup phase. default is true
+ * @param {boolean} params.lateStartup
+ * Notifies late startup phase which ensures addons are started or
+ * listeners are primed. default is true
+ * @param {boolean} params.newVersion
+ * If provided, the application version is changed to this string
+ * before the AddonManager is started.
+ */
+ async promiseStartupManager(params) {
+ if (this.addonIntegrationService) {
+ throw new Error(
+ "Attempting to startup manager that was already started."
+ );
+ }
+ // Support old arguments
+ if (typeof params != "object") {
+ params = {
+ newVersion: arguments[0],
+ };
+ }
+ let { earlyStartup = true, lateStartup = true, newVersion } = params;
+
+ lateStartup = earlyStartup && lateStartup;
+
+ if (newVersion) {
+ this.appInfo.version = newVersion;
+ this.appInfo.platformVersion = newVersion;
+ }
+
+ // AddonListeners are removed when the addonManager is shutdown,
+ // ensure the Extension observer is added. We call uninit in
+ // promiseShutdown to allow re-initialization.
+ lazy.ExtensionAddonObserver.init();
+
+ const { XPIInternal, XPIProvider } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIProvider.jsm"
+ );
+ XPIInternal.overrideAsyncShutdown(MockAsyncShutdown);
+
+ XPIInternal.BootstrapScope.prototype._beforeCallBootstrapMethod = (
+ method,
+ params,
+ reason
+ ) => {
+ try {
+ this.emit("bootstrap-method", { method, params, reason });
+ } catch (e) {
+ try {
+ this.testScope.do_throw(e);
+ } catch (e) {
+ // Le sigh.
+ }
+ }
+ };
+
+ this.addonIntegrationService = Cc[
+ "@mozilla.org/addons/integration;1"
+ ].getService(Ci.nsIObserver);
+
+ this.addonIntegrationService.observe(null, "addons-startup", null);
+
+ this.emit("addon-manager-started");
+
+ await Promise.all(XPIProvider.startupPromises);
+
+ // Load the add-ons list as it was after extension registration
+ await this.loadAddonsList(true);
+
+ // Wait for all add-ons to finish starting up before resolving.
+ await Promise.all(
+ Array.from(
+ XPIProvider.activeAddons.values(),
+ addon => addon.startupPromise
+ )
+ );
+ if (earlyStartup) {
+ lazy.ExtensionTestCommon.notifyEarlyStartup();
+ }
+ if (lateStartup) {
+ lazy.ExtensionTestCommon.notifyLateStartup();
+ }
+ },
+
+ async promiseShutdownManager({
+ clearOverrides = true,
+ clearL10nRegistry = true,
+ } = {}) {
+ if (!this.addonIntegrationService) {
+ return false;
+ }
+
+ if (this.overrideEntry && clearOverrides) {
+ this.overrideEntry.destruct();
+ this.overrideEntry = null;
+ }
+
+ const { XPIProvider } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIProvider.jsm"
+ );
+ const { XPIDatabase } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIDatabase.jsm"
+ );
+
+ // Ensure some startup observers in XPIProvider are released.
+ Services.obs.notifyObservers(null, "test-load-xpi-database");
+
+ Services.obs.notifyObservers(null, "quit-application-granted");
+ await MockAsyncShutdown.quitApplicationGranted.trigger();
+
+ // If XPIDatabase.asyncLoadDB() has been called before, then _dbPromise is
+ // a promise, potentially still pending. Wait for it to settle before
+ // triggering profileBeforeChange, because the latter can trigger errors in
+ // the pending asyncLoadDB() by an indirect call to XPIDatabase.shutdown().
+ await XPIDatabase._dbPromise;
+
+ await MockAsyncShutdown.profileBeforeChange.trigger();
+ await MockAsyncShutdown.profileChangeTeardown.trigger();
+
+ this.emit("addon-manager-shutdown");
+
+ this.addonIntegrationService = null;
+
+ // Load the add-ons list as it was after application shutdown
+ await this.loadAddonsList();
+
+ // Flush the jar cache entries for each bootstrapped XPI so that
+ // we don't run into file locking issues on Windows.
+ for (let file of this.addonsList.xpis) {
+ Services.obs.notifyObservers(file, "flush-cache-entry");
+ }
+
+ // Clear L10nRegistry entries so restaring the AOM will work correctly with locales.
+ if (clearL10nRegistry) {
+ L10nRegistry.getInstance().clearSources();
+ }
+
+ // Clear any crash report annotations
+ this.appInfo.annotations = {};
+
+ // Force the XPIProvider provider to reload to better
+ // simulate real-world usage.
+
+ // This would be cleaner if I could get it as the rejection reason from
+ // the AddonManagerInternal.shutdown() promise
+ let shutdownError = XPIDatabase._saveError;
+
+ AddonManagerPrivate.unregisterProvider(XPIProvider);
+ Cu.unload("resource://gre/modules/addons/XPIProvider.jsm");
+ Cu.unload("resource://gre/modules/addons/XPIDatabase.jsm");
+ Cu.unload("resource://gre/modules/addons/XPIInstall.jsm");
+
+ lazy.ExtensionAddonObserver.uninit();
+
+ lazy.ExtensionTestCommon.resetStartupPromises();
+
+ if (shutdownError) {
+ throw shutdownError;
+ }
+
+ return true;
+ },
+
+ /**
+ * Asynchronously restart the AddonManager. If newVersion is provided,
+ * simulate an application upgrade (or downgrade) where the version
+ * is changed to newVersion when re-started.
+ *
+ * @param {Object} params
+ * The new params are in an object and new code should use that.
+ * See promiseStartupManager for param details.
+ */
+ async promiseRestartManager(params) {
+ await this.promiseShutdownManager({ clearOverrides: false });
+ await this.promiseStartupManager(params);
+ },
+
+ /**
+ * If promiseStartupManager is called with earlyStartup: false, then
+ * use this to notify early startup.
+ *
+ * @returns {Promise} resolves when notification is complete
+ */
+ notifyEarlyStartup() {
+ return lazy.ExtensionTestCommon.notifyEarlyStartup();
+ },
+
+ /**
+ * If promiseStartupManager is called with lateStartup: false, then
+ * use this to notify late startup. You should also call early startup
+ * if necessary.
+ *
+ * @returns {Promise} resolves when notification is complete
+ */
+ notifyLateStartup() {
+ return lazy.ExtensionTestCommon.notifyLateStartup();
+ },
+
+ async loadAddonsList(flush = false) {
+ if (flush) {
+ const { XPIInternal } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIProvider.jsm"
+ );
+ XPIInternal.XPIStates.save();
+ await XPIInternal.XPIStates._jsonFile._save();
+ }
+
+ this.addonsList = new AddonsList(this.addonStartup);
+ },
+
+ /**
+ * Writes the given data to a file in the given zip file.
+ *
+ * @param {string|nsIFile} zipFile
+ * The zip file to write to.
+ * @param {Object} files
+ * An object containing filenames and the data to write to the
+ * corresponding paths in the zip file.
+ * @param {integer} [flags = 0]
+ * Additional flags to open the file with.
+ */
+ writeFilesToZip(zipFile, files, flags = 0) {
+ if (typeof zipFile == "string") {
+ zipFile = nsFile(zipFile);
+ }
+
+ var zipW = ZipWriter(
+ zipFile,
+ FileUtils.MODE_WRONLY | FileUtils.MODE_CREATE | flags
+ );
+
+ for (let [path, data] of Object.entries(files)) {
+ if (
+ typeof data === "object" &&
+ ChromeUtils.getClassName(data) === "Object"
+ ) {
+ data = JSON.stringify(data);
+ }
+ if (!(data instanceof ArrayBuffer)) {
+ data = new TextEncoder().encode(data).buffer;
+ }
+
+ let stream = ArrayBufferInputStream(data, 0, data.byteLength);
+
+ // Note these files are being created in the XPI archive with date
+ // 1 << 49, which is a valid time for ZipWriter.
+ zipW.addEntryStream(
+ path,
+ Math.pow(2, 49),
+ Ci.nsIZipWriter.COMPRESSION_NONE,
+ stream,
+ false
+ );
+ }
+
+ zipW.close();
+ },
+
+ async promiseWriteFilesToZip(zip, files, flags) {
+ await IOUtils.makeDirectory(PathUtils.parent(zip));
+
+ this.writeFilesToZip(zip, files, flags);
+
+ return Promise.resolve(nsFile(zip));
+ },
+
+ async promiseWriteFilesToDir(dir, files) {
+ await IOUtils.makeDirectory(dir);
+
+ for (let [path, data] of Object.entries(files)) {
+ path = path.split("/");
+ let leafName = path.pop();
+
+ // Create parent directories, if necessary.
+ let dirPath = dir;
+ for (let subDir of path) {
+ dirPath = PathUtils.join(dirPath, subDir);
+ await PathUtils.makeDirectory(dirPath);
+ }
+
+ const leafPath = PathUtils.join(dirPath, leafName);
+ if (
+ typeof data == "object" &&
+ ChromeUtils.getClassName(data) == "Object"
+ ) {
+ await IOUtils.writeJSON(leafPath, data);
+ } else if (typeof data == "string") {
+ await IOUtils.writeUTF8(leafPath, data);
+ }
+ }
+
+ return nsFile(dir);
+ },
+
+ promiseWriteFilesToExtension(dir, id, files, unpacked = this.testUnpacked) {
+ if (unpacked) {
+ let path = PathUtils.join(dir, id);
+
+ return this.promiseWriteFilesToDir(path, files);
+ }
+
+ let xpi = PathUtils.join(dir, `${id}.xpi`);
+
+ return this.promiseWriteFilesToZip(xpi, files);
+ },
+
+ tempXPIs: [],
+
+ allocTempXPIFile() {
+ let file = this.tempDir.clone();
+ let uuid = Services.uuid.generateUUID().number.slice(1, -1);
+ file.append(`${uuid}.xpi`);
+
+ this.tempXPIs.push(file);
+
+ return file;
+ },
+
+ /**
+ * Creates an XPI file for some manifest data in the temporary directory and
+ * returns the nsIFile for it. The file will be deleted when the test completes.
+ *
+ * @param {object} files
+ * The object holding data about the add-on
+ * @return {nsIFile} A file pointing to the created XPI file
+ */
+ createTempXPIFile(files) {
+ let file = this.allocTempXPIFile();
+ this.writeFilesToZip(file.path, files);
+ return file;
+ },
+
+ /**
+ * Creates an XPI file for some WebExtension data in the temporary directory and
+ * returns the nsIFile for it. The file will be deleted when the test completes.
+ *
+ * @param {Object} data
+ * The object holding data about the add-on, as expected by
+ * |ExtensionTestCommon.generateXPI|.
+ * @return {nsIFile} A file pointing to the created XPI file
+ */
+ createTempWebExtensionFile(data) {
+ let file = lazy.ExtensionTestCommon.generateXPI(data);
+ this.tempXPIs.push(file);
+ return file;
+ },
+
+ /**
+ * Creates an XPI with the given files and installs it.
+ *
+ * @param {object} files
+ * A files object as would be passed to {@see #createTempXPI}.
+ * @returns {Promise}
+ * A promise which resolves when the add-on is installed.
+ */
+ promiseInstallXPI(files) {
+ return this.promiseInstallFile(this.createTempXPIFile(files));
+ },
+
+ /**
+ * Creates an extension proxy file.
+ * See: https://developer.mozilla.org/en-US/Add-ons/Setting_up_extension_development_environment#Firefox_extension_proxy_file
+ *
+ * @param {nsIFile} dir
+ * The directory to add the proxy file to.
+ * @param {nsIFile} addon
+ * An nsIFile for the add-on file that this is a proxy file for.
+ * @param {string} id
+ * A string to use for the add-on ID.
+ * @returns {Promise} Resolves when the file has been created.
+ */
+ promiseWriteProxyFileToDir(dir, addon, id) {
+ let files = {
+ [id]: addon.path,
+ };
+
+ return this.promiseWriteFilesToDir(dir.path, files);
+ },
+
+ /**
+ * Manually installs an XPI file into an install location by either copying the
+ * XPI there or extracting it depending on whether unpacking is being tested
+ * or not.
+ *
+ * @param {nsIFile} xpiFile
+ * The XPI file to install.
+ * @param {nsIFile} [installLocation = this.profileExtensions]
+ * The install location (an nsIFile) to install into.
+ * @param {string} [id]
+ * The ID to install as.
+ * @param {boolean} [unpacked = this.testUnpacked]
+ * If true, install as an unpacked directory, rather than a
+ * packed XPI.
+ * @returns {nsIFile}
+ * A file pointing to the installed location of the XPI file or
+ * unpacked directory.
+ */
+ async manuallyInstall(
+ xpiFile,
+ installLocation = this.profileExtensions,
+ id = null,
+ unpacked = this.testUnpacked
+ ) {
+ if (id == null) {
+ id = await this.getIDFromExtension(xpiFile);
+ }
+
+ if (unpacked) {
+ let dir = installLocation.clone();
+ dir.append(id);
+ dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+ let zip = ZipReader(xpiFile);
+ for (let entry of zip.findEntries(null)) {
+ let target = dir.clone();
+ for (let part of entry.split("/")) {
+ target.append(part);
+ }
+ if (!target.parent.exists()) {
+ target.parent.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+ }
+ try {
+ zip.extract(entry, target);
+ } catch (e) {
+ if (
+ e.result != Cr.NS_ERROR_FILE_DIR_NOT_EMPTY &&
+ !(target.exists() && target.isDirectory())
+ ) {
+ throw e;
+ }
+ }
+ target.permissions |= FileUtils.PERMS_FILE;
+ }
+ zip.close();
+
+ return dir;
+ }
+
+ let target = installLocation.clone();
+ target.append(`${id}.xpi`);
+ xpiFile.copyTo(target.parent, target.leafName);
+ return target;
+ },
+
+ /**
+ * Manually uninstalls an add-on by removing its files from the install
+ * location.
+ *
+ * @param {nsIFile} installLocation
+ * The nsIFile of the install location to remove from.
+ * @param {string} id
+ * The ID of the add-on to remove.
+ * @param {boolean} [unpacked = this.testUnpacked]
+ * If true, uninstall an unpacked directory, rather than a
+ * packed XPI.
+ */
+ manuallyUninstall(installLocation, id, unpacked = this.testUnpacked) {
+ let file = this.getFileForAddon(installLocation, id, unpacked);
+
+ // In reality because the app is restarted a flush isn't necessary for XPIs
+ // removed outside the app, but for testing we must flush manually.
+ if (file.isFile()) {
+ Services.obs.notifyObservers(file, "flush-cache-entry");
+ }
+
+ file.remove(true);
+ },
+
+ /**
+ * Gets the nsIFile for where an add-on is installed. It may point to a file or
+ * a directory depending on whether add-ons are being installed unpacked or not.
+ *
+ * @param {nsIFile} dir
+ * The nsIFile for the install location
+ * @param {string} id
+ * The ID of the add-on
+ * @param {boolean} [unpacked = this.testUnpacked]
+ * If true, return the path to an unpacked directory, rather than a
+ * packed XPI.
+ * @returns {nsIFile}
+ * A file pointing to the XPI file or unpacked directory where
+ * the add-on should be installed.
+ */
+ getFileForAddon(dir, id, unpacked = this.testUnpacked) {
+ dir = dir.clone();
+ if (unpacked) {
+ dir.append(id);
+ } else {
+ dir.append(`${id}.xpi`);
+ }
+ return dir;
+ },
+
+ /**
+ * Sets the last modified time of the extension, usually to trigger an update
+ * of its metadata.
+ *
+ * @param {nsIFile} ext A file pointing to either the packed extension or its unpacked directory.
+ * @param {number} time The time to which we set the lastModifiedTime of the extension
+ *
+ * @deprecated Please use promiseSetExtensionModifiedTime instead
+ */
+ setExtensionModifiedTime(ext, time) {
+ ext.lastModifiedTime = time;
+ if (ext.isDirectory()) {
+ for (let file of this.iterDirectory(ext)) {
+ this.setExtensionModifiedTime(file, time);
+ }
+ }
+ },
+
+ async promiseSetExtensionModifiedTime(path, time) {
+ await IOUtils.setModificationTime(path, time);
+
+ const stat = await IOUtils.stat(path);
+ if (stat.type !== "directory") {
+ return;
+ }
+
+ const children = await IOUtils.getChildren(path);
+
+ try {
+ await Promise.all(
+ children.map(entry => this.promiseSetExtensionModifiedTime(entry, time))
+ );
+ } catch (ex) {
+ if (DOMException.isInstance(ex)) {
+ return;
+ }
+ throw ex;
+ }
+ },
+
+ registerDirectory(key, dir) {
+ var dirProvider = {
+ getFile(prop, persistent) {
+ persistent.value = false;
+ if (prop == key) {
+ return dir.clone();
+ }
+ return null;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]),
+ };
+ Services.dirsvc.registerProvider(dirProvider);
+
+ try {
+ Services.dirsvc.undefine(key);
+ } catch (e) {
+ // This throws if the key is not already registered, but that
+ // doesn't matter.
+ if (e.result != Cr.NS_ERROR_FAILURE) {
+ throw e;
+ }
+ }
+ },
+
+ /**
+ * Returns a promise that resolves when the given add-on event is fired. The
+ * resolved value is an array of arguments passed for the event.
+ *
+ * @param {string} event
+ * The name of the AddonListener event handler method for which
+ * an event is expected.
+ * @param {function} checkFn [optional]
+ * A function to check if this is the right event. Should return true
+ * for the event that it wants, false otherwise. Will be passed
+ * all the relevant arguments.
+ * If not passed, any event will do to resolve the promise.
+ * @returns {Promise<Array>}
+ * Resolves to an array containing the event handler's
+ * arguments the first time it is called.
+ */
+ promiseAddonEvent(event, checkFn) {
+ return new Promise(resolve => {
+ let listener = {
+ [event](...args) {
+ if (typeof checkFn == "function" && !checkFn(...args)) {
+ return;
+ }
+ AddonManager.removeAddonListener(listener);
+ resolve(args);
+ },
+ };
+
+ AddonManager.addAddonListener(listener);
+ });
+ },
+
+ promiseInstallEvent(event) {
+ return new Promise(resolve => {
+ let listener = {
+ [event](...args) {
+ AddonManager.removeInstallListener(listener);
+ resolve(args);
+ },
+ };
+
+ AddonManager.addInstallListener(listener);
+ });
+ },
+
+ /**
+ * A helper method to install AddonInstall and wait for completion.
+ *
+ * @param {AddonInstall} install
+ * The add-on to install.
+ * @returns {Promise<AddonInstall>}
+ * Resolves when the install completes, either successfully or
+ * in failure.
+ */
+ promiseCompleteInstall(install) {
+ let listener;
+ return new Promise(resolve => {
+ let installPromise;
+ listener = {
+ onDownloadFailed: resolve,
+ onDownloadCancelled: resolve,
+ onInstallFailed: resolve,
+ onInstallCancelled: resolve,
+ onInstallEnded() {
+ // onInstallEnded is called right when an add-on has been installed.
+ // install() may still be pending, e.g. for updates, and be awaiting
+ // the completion of the update, part of which is the removal of the
+ // temporary XPI file of the downloaded update. To avoid intermittent
+ // test failures due to lingering temporary files, await install().
+ resolve(installPromise);
+ },
+ onInstallPostponed: resolve,
+ };
+
+ install.addListener(listener);
+ installPromise = install.install();
+ }).then(() => {
+ install.removeListener(listener);
+ return install;
+ });
+ },
+
+ /**
+ * A helper method to install a file.
+ *
+ * @param {nsIFile} file
+ * The file to install
+ * @param {boolean} [ignoreIncompatible = false]
+ * Optional parameter to ignore add-ons that are incompatible
+ * with the application
+ * @param {Object} [installTelemetryInfo = undefined]
+ * Optional parameter to set the install telemetry info for the
+ * installed addon
+ * @returns {Promise}
+ * Resolves when the install has completed.
+ */
+ async promiseInstallFile(
+ file,
+ ignoreIncompatible = false,
+ installTelemetryInfo
+ ) {
+ let install = await AddonManager.getInstallForFile(
+ file,
+ null,
+ installTelemetryInfo
+ );
+ if (!install) {
+ throw new Error(`No AddonInstall created for ${file.path}`);
+ }
+
+ if (install.state != AddonManager.STATE_DOWNLOADED) {
+ throw new Error(
+ `Expected file to be downloaded for install of ${file.path}`
+ );
+ }
+
+ if (ignoreIncompatible && install.addon.appDisabled) {
+ return null;
+ }
+
+ await install.install();
+ return install;
+ },
+
+ /**
+ * A helper method to install an array of files.
+ *
+ * @param {Iterable<nsIFile>} files
+ * The files to install
+ * @param {boolean} [ignoreIncompatible = false]
+ * Optional parameter to ignore add-ons that are incompatible
+ * with the application
+ * @returns {Promise}
+ * Resolves when the installs have completed.
+ */
+ promiseInstallAllFiles(files, ignoreIncompatible = false) {
+ return Promise.all(
+ Array.from(files, file =>
+ this.promiseInstallFile(file, ignoreIncompatible)
+ )
+ );
+ },
+
+ promiseCompleteAllInstalls(installs) {
+ return Promise.all(Array.from(installs, this.promiseCompleteInstall));
+ },
+
+ /**
+ * @property {number} updateReason
+ * The default update reason for {@see promiseFindAddonUpdates}
+ * calls. May be overwritten by tests which primarily check for
+ * updates with a particular reason.
+ */
+ updateReason: AddonManager.UPDATE_WHEN_PERIODIC_UPDATE,
+
+ /**
+ * Returns a promise that will be resolved when an add-on update check is
+ * complete. The value resolved will be an AddonInstall if a new version was
+ * found.
+ *
+ * @param {object} addon The add-on to find updates for.
+ * @param {integer} reason The type of update to find.
+ * @param {Array} args Additional args to pass to `checkUpdates` after
+ * the update reason.
+ * @return {Promise<object>} an object containing information about the update.
+ */
+ promiseFindAddonUpdates(
+ addon,
+ reason = AddonTestUtils.updateReason,
+ ...args
+ ) {
+ // Retrieve the test assertion helper from the testScope
+ // (which is `equal` in xpcshell-test and `is` in mochitest)
+ let equal = this.testScope.equal || this.testScope.is;
+ return new Promise((resolve, reject) => {
+ let result = {};
+ addon.findUpdates(
+ {
+ onNoCompatibilityUpdateAvailable(addon2) {
+ if ("compatibilityUpdate" in result) {
+ throw new Error("Saw multiple compatibility update events");
+ }
+ equal(addon, addon2, "onNoCompatibilityUpdateAvailable");
+ result.compatibilityUpdate = false;
+ },
+
+ onCompatibilityUpdateAvailable(addon2) {
+ if ("compatibilityUpdate" in result) {
+ throw new Error("Saw multiple compatibility update events");
+ }
+ equal(addon, addon2, "onCompatibilityUpdateAvailable");
+ result.compatibilityUpdate = true;
+ },
+
+ onNoUpdateAvailable(addon2) {
+ if ("updateAvailable" in result) {
+ throw new Error("Saw multiple update available events");
+ }
+ equal(addon, addon2, "onNoUpdateAvailable");
+ result.updateAvailable = false;
+ },
+
+ onUpdateAvailable(addon2, install) {
+ if ("updateAvailable" in result) {
+ throw new Error("Saw multiple update available events");
+ }
+ equal(addon, addon2, "onUpdateAvailable");
+ result.updateAvailable = install;
+ },
+
+ onUpdateFinished(addon2, error) {
+ equal(addon, addon2, "onUpdateFinished");
+ if (error == AddonManager.UPDATE_STATUS_NO_ERROR) {
+ resolve(result);
+ } else {
+ result.error = error;
+ reject(result);
+ }
+ },
+ },
+ reason,
+ ...args
+ );
+ });
+ },
+
+ /**
+ * Monitors console output for the duration of a task, and returns a promise
+ * which resolves to a tuple containing a list of all console messages
+ * generated during the task's execution, and the result of the task itself.
+ *
+ * @param {function} task
+ * The task to run while monitoring console output. May be
+ * an async function, or an ordinary function which returns a promose.
+ * @return {Promise<[Array<nsIConsoleMessage>, *]>}
+ * Resolves to an object containing a `messages` property, with
+ * the array of console messages emitted during the execution
+ * of the task, and a `result` property, containing the task's
+ * return value.
+ */
+ async promiseConsoleOutput(task) {
+ const DONE = "=== xpcshell test console listener done ===";
+
+ let listener,
+ messages = [];
+ let awaitListener = new Promise(resolve => {
+ listener = msg => {
+ if (msg == DONE) {
+ resolve();
+ } else {
+ 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);
+ }
+ },
+
+ /**
+ * An object describing an expected or forbidden console message. Each
+ * property in the object corresponds to a property with the same name
+ * in a console message. If the value in the pattern object is a
+ * regular expression, it must match the value of the corresponding
+ * console message property. If it is any other value, it must be
+ * strictly equal to the correspondng console message property.
+ *
+ * @typedef {object} ConsoleMessagePattern
+ */
+
+ /**
+ * Checks the list of messages returned from `promiseConsoleOutput`
+ * against the given set of expected messages.
+ *
+ * This is roughly equivalent to the expected and forbidden message
+ * matching functionality of SimpleTest.monitorConsole.
+ *
+ * @param {Array<object>} messages
+ * The array of console messages to match.
+ * @param {object} options
+ * Options describing how to perform the match.
+ * @param {Array<ConsoleMessagePattern>} [options.expected = []]
+ * An array of messages which must appear in `messages`. The
+ * matching messages in the `messages` array must appear in the
+ * same order as the patterns in the `expected` array.
+ * @param {Array<ConsoleMessagePattern>} [options.forbidden = []]
+ * An array of messages which must not appear in the `messages`
+ * array.
+ * @param {bool} [options.forbidUnexpected = false]
+ * If true, the `messages` array must not contain any messages
+ * which are not matched by the given `expected` patterns.
+ */
+ checkMessages(
+ messages,
+ { expected = [], forbidden = [], forbidUnexpected = false }
+ ) {
+ function msgMatches(msg, expectedMsg) {
+ for (let [prop, pattern] of Object.entries(expectedMsg)) {
+ if (isRegExp(pattern) && typeof msg[prop] === "string") {
+ if (!pattern.test(msg[prop])) {
+ return false;
+ }
+ } else if (msg[prop] !== pattern) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ function validateOptionFormat(optionName, optionValue) {
+ for (let item of optionValue) {
+ if (!item || typeof item !== "object" || isRegExp(item)) {
+ throw new Error(
+ `Unexpected format in AddonTestUtils.checkMessages "${optionName}" parameter`
+ );
+ }
+ }
+ }
+
+ validateOptionFormat("expected", expected);
+ validateOptionFormat("forbidden", forbidden);
+
+ let i = 0;
+ for (let msg of messages) {
+ if (forbidden.some(pat => msgMatches(msg, pat))) {
+ this.testScope.ok(false, `Got forbidden console message: ${msg}`);
+ continue;
+ }
+
+ if (i < expected.length && msgMatches(msg, expected[i])) {
+ this.info(`Matched expected console message: ${msg}`);
+ i++;
+ } else if (forbidUnexpected) {
+ this.testScope.ok(false, `Got unexpected console message: ${msg}`);
+ }
+ }
+ for (let pat of expected.slice(i)) {
+ this.testScope.ok(
+ false,
+ `Did not get expected console message: ${uneval(pat)}`
+ );
+ }
+ },
+
+ /**
+ * Asserts that the expected installTelemetryInfo properties are available
+ * on the AddonWrapper or AddonInstall objects.
+ *
+ * @param {AddonWrapper|AddonInstall} addonOrInstall
+ * The addon or addonInstall object to check.
+ * @param {Object} expectedInstallInfo
+ * The expected installTelemetryInfo properties
+ * (every property can be a primitive value or a regular expression).
+ * @param {string} [msg]
+ * Optional assertion message suffix.
+ */
+ checkInstallInfo(addonOrInstall, expectedInstallInfo, msg = undefined) {
+ const installInfo = addonOrInstall.installTelemetryInfo;
+ const { Assert } = this.testScope;
+
+ msg = msg ? ` ${msg}` : "";
+
+ for (const key of Object.keys(expectedInstallInfo)) {
+ const actual = installInfo[key];
+ let expected = expectedInstallInfo[key];
+
+ // Assert the property value using a regular expression.
+ if (expected && typeof expected.test == "function") {
+ Assert.ok(
+ expected.test(actual),
+ `${key} value "${actual}" has the value expected "${expected}"${msg}`
+ );
+ } else {
+ Assert.deepEqual(
+ actual,
+ expected,
+ `Got the expected value for ${key}${msg}`
+ );
+ }
+ }
+ },
+
+ /**
+ * Helper to wait for a webextension to completely start
+ *
+ * @param {string} [id]
+ * An optional extension id to look for.
+ *
+ * @returns {Promise<Extension>}
+ * A promise that resolves with the extension, once it is started.
+ */
+ promiseWebExtensionStartup(id) {
+ return new Promise(resolve => {
+ lazy.Management.on("ready", function listener(event, extension) {
+ if (!id || extension.id == id) {
+ lazy.Management.off("ready", listener);
+ resolve(extension);
+ }
+ });
+ });
+ },
+
+ /**
+ * Wait until an extension with a search provider has been loaded.
+ * This should be called after the extension has started, but before shutdown.
+ *
+ * @param {object} extension
+ * The return value of ExtensionTestUtils.loadExtension.
+ * For browser tests, see mochitest/tests/SimpleTest/ExtensionTestUtils.js
+ * For xpcshell tests, see toolkit/components/extensions/ExtensionXPCShellUtils.jsm
+ * @param {object} [options]
+ * Optional options.
+ * @param {boolean} [options.expectPending = false]
+ * Whether to expect the search provider to still be starting up.
+ */
+ async waitForSearchProviderStartup(
+ extension,
+ { expectPending = false } = {}
+ ) {
+ // In xpcshell tests, equal/ok are defined in the global scope.
+ let { equal, ok } = this.testScope;
+ if (!equal || !ok) {
+ // In mochitests, these are available via Assert.sys.mjs.
+ let { Assert } = this.testScope;
+ equal = Assert.equal.bind(Assert);
+ ok = Assert.ok.bind(Assert);
+ }
+
+ equal(
+ extension.state,
+ "running",
+ "Search provider extension should be running"
+ );
+ ok(extension.id, "Extension ID of search provider should be set");
+
+ // The map of promises from browser/components/extensions/parent/ext-chrome-settings-overrides.js
+ let { pendingSearchSetupTasks } = lazy.Management.global;
+ let searchStartupPromise = pendingSearchSetupTasks.get(extension.id);
+ if (expectPending) {
+ ok(
+ searchStartupPromise,
+ "Search provider registration should be in progress"
+ );
+ }
+ return searchStartupPromise;
+ },
+
+ /**
+ * Initializes the URLPreloader, which is required in order to load
+ * built_in_addons.json.
+ */
+ initializeURLPreloader() {
+ lazy.aomStartup.initializeURLPreloader();
+ },
+
+ /**
+ * Override chrome URL for specifying allowed built-in add-ons.
+ *
+ * @param {object} data - An object specifying which add-on IDs are permitted
+ * to load, for instance: { "system": ["id1", "..."] }
+ */
+ async overrideBuiltIns(data) {
+ this.initializeURLPreloader();
+
+ let file = this.tempDir.clone();
+ file.append("override.txt");
+ this.tempXPIs.push(file);
+
+ let manifest = Services.io.newFileURI(file);
+ await IOUtils.writeJSON(file.path, data);
+ this.overrideEntry = lazy.aomStartup.registerChrome(manifest, [
+ [
+ "override",
+ "chrome://browser/content/built_in_addons.json",
+ Services.io.newFileURI(file).spec,
+ ],
+ ]);
+ },
+
+ // AMTelemetry events helpers.
+
+ /**
+ * Formerly this function re-routed telemetry events. Now it just ensures
+ * that there are no unexamined events after the test file is exiting.
+ */
+ hookAMTelemetryEvents() {
+ this.testScope.registerCleanupFunction(() => {
+ this.testScope.Assert.deepEqual(
+ [],
+ this.getAMTelemetryEvents(),
+ "No unexamined telemetry events after test is finished"
+ );
+ });
+ },
+
+ /**
+ * Retrive any AMTelemetry event collected and clears _all_ telemetry events.
+ *
+ * @returns {Array<Object>}
+ * The array of the collected telemetry data.
+ */
+ getAMTelemetryEvents() {
+ // This duplicates some logic from TelemetryTestUtils.
+ let snapshots = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ /* clear = */ true
+ );
+ let events = (snapshots.parent ?? [])
+ .filter(entry => entry[1] == "addonsManager")
+ .map(entry => ({
+ // The callers don't expect the timestamp or the category.
+ method: entry[2],
+ object: entry[3],
+ value: entry[4],
+ extra: entry[5],
+ }));
+
+ return events;
+ },
+};
+
+for (let [key, val] of Object.entries(AddonTestUtils)) {
+ if (typeof val == "function") {
+ AddonTestUtils[key] = val.bind(AddonTestUtils);
+ }
+}
+
+EventEmitter.decorate(AddonTestUtils);
diff --git a/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs b/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs
new file mode 100644
index 0000000000..5207ee3c95
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs
@@ -0,0 +1,621 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * The AddonUpdateChecker is responsible for retrieving the update information
+ * from an add-on's remote update manifest.
+ */
+
+const TIMEOUT = 60 * 1000;
+const TOOLKIT_ID = "toolkit@mozilla.org";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs",
+ Blocklist: "resource://gre/modules/Blocklist.sys.mjs",
+ CertUtils: "resource://gre/modules/CertUtils.sys.mjs",
+ ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs",
+});
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+
+const LOGGER_ID = "addons.update-checker";
+
+// Create a new logger for use by the Addons Update Checker
+// (Requires AddonManager.jsm)
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+/**
+ * Sanitizes the update URL in an update item, as returned by
+ * parseRDFManifest and parseJSONManifest. Ensures that:
+ *
+ * - The URL is secure, or secured by a strong enough hash.
+ * - The security principal of the update manifest has permission to
+ * load the URL.
+ *
+ * @param aUpdate
+ * The update item to sanitize.
+ * @param aRequest
+ * The XMLHttpRequest used to load the manifest.
+ * @param aHashPattern
+ * The regular expression used to validate the update hash.
+ * @param aHashString
+ * The human-readable string specifying which hash functions
+ * are accepted.
+ */
+function sanitizeUpdateURL(aUpdate, aRequest, aHashPattern, aHashString) {
+ if (aUpdate.updateURL) {
+ let scriptSecurity = Services.scriptSecurityManager;
+ let principal = scriptSecurity.getChannelURIPrincipal(aRequest.channel);
+ try {
+ // This logs an error on failure, so no need to log it a second time
+ scriptSecurity.checkLoadURIStrWithPrincipal(
+ principal,
+ aUpdate.updateURL,
+ scriptSecurity.DISALLOW_SCRIPT
+ );
+ } catch (e) {
+ delete aUpdate.updateURL;
+ return;
+ }
+
+ if (
+ lazy.AddonManager.checkUpdateSecurity &&
+ !aUpdate.updateURL.startsWith("https:") &&
+ !aHashPattern.test(aUpdate.updateHash)
+ ) {
+ logger.warn(
+ `Update link ${aUpdate.updateURL} is not secure and is not verified ` +
+ `by a strong enough hash (needs to be ${aHashString}).`
+ );
+ delete aUpdate.updateURL;
+ delete aUpdate.updateHash;
+ }
+ }
+}
+
+/**
+ * Parses an JSON update manifest into an array of update objects.
+ *
+ * @param aId
+ * The ID of the add-on being checked for updates
+ * @param aRequest
+ * The XMLHttpRequest that has retrieved the update manifest
+ * @param aManifestData
+ * The pre-parsed manifest, as a JSON object tree
+ * @return an array of update objects
+ * @throws if the update manifest is invalid in any way
+ */
+function parseJSONManifest(aId, aRequest, aManifestData) {
+ let TYPE_CHECK = {
+ array: val => Array.isArray(val),
+ object: val => val && typeof val == "object" && !Array.isArray(val),
+ };
+
+ function getProperty(aObj, aProperty, aType, aDefault = undefined) {
+ if (!(aProperty in aObj)) {
+ return aDefault;
+ }
+
+ let value = aObj[aProperty];
+
+ let matchesType =
+ aType in TYPE_CHECK ? TYPE_CHECK[aType](value) : typeof value == aType;
+ if (!matchesType) {
+ throw Components.Exception(
+ `Update manifest property '${aProperty}' has incorrect type (expected ${aType})`
+ );
+ }
+
+ return value;
+ }
+
+ function getRequiredProperty(aObj, aProperty, aType) {
+ let value = getProperty(aObj, aProperty, aType);
+ if (value === undefined) {
+ throw Components.Exception(
+ `Update manifest is missing a required ${aProperty} property.`
+ );
+ }
+ return value;
+ }
+
+ let manifest = aManifestData;
+
+ if (!TYPE_CHECK.object(manifest)) {
+ throw Components.Exception(
+ "Root element of update manifest must be a JSON object literal"
+ );
+ }
+
+ // The set of add-ons this manifest has updates for
+ let addons = getRequiredProperty(manifest, "addons", "object");
+
+ // The entry for this particular add-on
+ let addon = getProperty(addons, aId, "object");
+
+ // A missing entry doesn't count as a failure, just as no avialable update
+ // information
+ if (!addon) {
+ logger.warn("Update manifest did not contain an entry for " + aId);
+ return [];
+ }
+
+ // The list of available updates
+ let updates = getProperty(addon, "updates", "array", []);
+
+ let results = [];
+
+ for (let update of updates) {
+ let version = getRequiredProperty(update, "version", "string");
+
+ logger.debug(`Found an update entry for ${aId} version ${version}`);
+
+ let applications = getProperty(update, "applications", "object", {
+ gecko: {},
+ });
+
+ // "gecko" is currently the only supported application entry. If
+ // it's missing, skip this update.
+ if (!("gecko" in applications)) {
+ logger.debug(
+ "gecko not in application entry, skipping update of ${addon}"
+ );
+ continue;
+ }
+
+ let app = getProperty(applications, "gecko", "object");
+
+ let appEntry = {
+ id: TOOLKIT_ID,
+ minVersion: getProperty(
+ app,
+ "strict_min_version",
+ "string",
+ lazy.AddonManagerPrivate.webExtensionsMinPlatformVersion
+ ),
+ maxVersion: "*",
+ };
+
+ let result = {
+ id: aId,
+ version,
+ updateURL: getProperty(update, "update_link", "string"),
+ updateHash: getProperty(update, "update_hash", "string"),
+ updateInfoURL: getProperty(update, "update_info_url", "string"),
+ strictCompatibility: false,
+ targetApplications: [appEntry],
+ };
+
+ if ("strict_max_version" in app) {
+ if ("advisory_max_version" in app) {
+ logger.warn(
+ "Ignoring 'advisory_max_version' update manifest property for " +
+ aId +
+ " property since 'strict_max_version' also present"
+ );
+ }
+
+ appEntry.maxVersion = getProperty(app, "strict_max_version", "string");
+ result.strictCompatibility = appEntry.maxVersion != "*";
+ } else if ("advisory_max_version" in app) {
+ appEntry.maxVersion = getProperty(app, "advisory_max_version", "string");
+ }
+
+ // Add an app entry for the current API ID, too, so that it overrides any
+ // existing app-specific entries, which would take priority over the toolkit
+ // entry.
+ //
+ // Note: This currently only has any effect on legacy extensions (mainly
+ // those used in tests), since WebExtensions cannot yet specify app-specific
+ // compatibility ranges.
+ result.targetApplications.push(
+ Object.assign({}, appEntry, { id: Services.appinfo.ID })
+ );
+
+ // The JSON update protocol requires an SHA-2 hash. RDF still
+ // supports SHA-1, for compatibility reasons.
+ sanitizeUpdateURL(result, aRequest, /^sha(256|512):/, "sha256 or sha512");
+
+ results.push(result);
+ }
+ return results;
+}
+
+/**
+ * Starts downloading an update manifest and then passes it to an appropriate
+ * parser to convert to an array of update objects
+ *
+ * @param aId
+ * The ID of the add-on being checked for updates
+ * @param aUrl
+ * The URL of the update manifest
+ * @param aObserver
+ * An observer to pass results to
+ */
+function UpdateParser(aId, aUrl, aObserver) {
+ this.id = aId;
+ this.observer = aObserver;
+ this.url = aUrl;
+
+ logger.debug("Requesting " + aUrl);
+ try {
+ this.request = new lazy.ServiceRequest({ mozAnon: true });
+ this.request.open("GET", this.url, true);
+ this.request.channel.notificationCallbacks =
+ new lazy.CertUtils.BadCertHandler(
+ !lazy.AddonSettings.UPDATE_REQUIREBUILTINCERTS
+ );
+ this.request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ // Prevent the request from writing to cache.
+ this.request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
+ this.request.overrideMimeType("text/plain");
+ this.request.timeout = TIMEOUT;
+ this.request.addEventListener("load", () => this.onLoad());
+ this.request.addEventListener("error", () => this.onError());
+ this.request.addEventListener("timeout", () => this.onTimeout());
+ this.request.send(null);
+ } catch (e) {
+ logger.error("Failed to request update manifest", e);
+ }
+}
+
+UpdateParser.prototype = {
+ id: null,
+ observer: null,
+ request: null,
+ url: null,
+
+ /**
+ * Called when the manifest has been successfully loaded.
+ */
+ onLoad() {
+ let request = this.request;
+ this.request = null;
+ this._doneAt = new Error("place holder");
+
+ try {
+ lazy.CertUtils.checkCert(
+ request.channel,
+ !lazy.AddonSettings.UPDATE_REQUIREBUILTINCERTS
+ );
+ } catch (e) {
+ logger.warn("Request failed: " + this.url + " - " + e);
+ this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
+ return;
+ }
+
+ if (!Components.isSuccessCode(request.status)) {
+ logger.warn("Request failed: " + this.url + " - " + request.status);
+ this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
+ return;
+ }
+
+ let channel = request.channel;
+ if (channel instanceof Ci.nsIHttpChannel && !channel.requestSucceeded) {
+ logger.warn(
+ "Request failed: " +
+ this.url +
+ " - " +
+ channel.responseStatus +
+ ": " +
+ channel.responseStatusText
+ );
+ this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
+ return;
+ }
+
+ let results;
+ try {
+ let json = JSON.parse(request.responseText);
+ results = parseJSONManifest(this.id, request, json);
+ } catch (e) {
+ logger.warn("onUpdateCheckComplete failed to parse update manifest", e);
+ this.notifyError(lazy.AddonManager.ERROR_PARSE_ERROR);
+ return;
+ }
+
+ if ("onUpdateCheckComplete" in this.observer) {
+ try {
+ this.observer.onUpdateCheckComplete(results);
+ } catch (e) {
+ logger.warn("onUpdateCheckComplete notification failed", e);
+ }
+ } else {
+ logger.warn(
+ "onUpdateCheckComplete may not properly cancel",
+ new Error("stack marker")
+ );
+ }
+ },
+
+ /**
+ * Called when the request times out
+ */
+ onTimeout() {
+ this.request = null;
+ this._doneAt = new Error("Timed out");
+ logger.warn("Request for " + this.url + " timed out");
+ this.notifyError(lazy.AddonManager.ERROR_TIMEOUT);
+ },
+
+ /**
+ * Called when the manifest failed to load.
+ */
+ onError() {
+ if (!Components.isSuccessCode(this.request.status)) {
+ logger.warn("Request failed: " + this.url + " - " + this.request.status);
+ } else if (this.request.channel instanceof Ci.nsIHttpChannel) {
+ try {
+ if (this.request.channel.requestSucceeded) {
+ logger.warn(
+ "Request failed: " +
+ this.url +
+ " - " +
+ this.request.channel.responseStatus +
+ ": " +
+ this.request.channel.responseStatusText
+ );
+ }
+ } catch (e) {
+ logger.warn("HTTP Request failed for an unknown reason");
+ }
+ } else {
+ logger.warn("Request failed for an unknown reason");
+ }
+
+ this.request = null;
+ this._doneAt = new Error("UP_onError");
+
+ this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
+ },
+
+ /**
+ * Helper method to notify the observer that an error occurred.
+ */
+ notifyError(aStatus) {
+ if ("onUpdateCheckError" in this.observer) {
+ try {
+ this.observer.onUpdateCheckError(aStatus);
+ } catch (e) {
+ logger.warn("onUpdateCheckError notification failed", e);
+ }
+ }
+ },
+
+ /**
+ * Called to cancel an in-progress update check.
+ */
+ cancel() {
+ if (!this.request) {
+ logger.error("Trying to cancel already-complete request", this._doneAt);
+ return;
+ }
+ this.request.abort();
+ this.request = null;
+ this._doneAt = new Error("UP_cancel");
+ this.notifyError(lazy.AddonManager.ERROR_CANCELLED);
+ },
+};
+
+/**
+ * Tests if an update matches a version of the application or platform
+ *
+ * @param aUpdate
+ * The available update
+ * @param aAppVersion
+ * The application version to use
+ * @param aPlatformVersion
+ * The platform version to use
+ * @param aIgnoreMaxVersion
+ * Ignore maxVersion when testing if an update matches. Optional.
+ * @param aIgnoreStrictCompat
+ * Ignore strictCompatibility when testing if an update matches. Optional.
+ * @return true if the update is compatible with the application/platform
+ */
+function matchesVersions(
+ aUpdate,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+) {
+ if (aUpdate.strictCompatibility && !aIgnoreStrictCompat) {
+ aIgnoreMaxVersion = false;
+ }
+
+ let result = false;
+ for (let app of aUpdate.targetApplications) {
+ if (app.id == Services.appinfo.ID) {
+ return (
+ Services.vc.compare(aAppVersion, app.minVersion) >= 0 &&
+ (aIgnoreMaxVersion ||
+ Services.vc.compare(aAppVersion, app.maxVersion) <= 0)
+ );
+ }
+ if (app.id == TOOLKIT_ID) {
+ result =
+ Services.vc.compare(aPlatformVersion, app.minVersion) >= 0 &&
+ (aIgnoreMaxVersion ||
+ Services.vc.compare(aPlatformVersion, app.maxVersion) <= 0);
+ }
+ }
+ return result;
+}
+
+export var AddonUpdateChecker = {
+ /**
+ * Retrieves the best matching compatibility update for the application from
+ * a list of available update objects.
+ *
+ * @param aUpdates
+ * An array of update objects
+ * @param aVersion
+ * The version of the add-on to get new compatibility information for
+ * @param aIgnoreCompatibility
+ * An optional parameter to get the first compatibility update that
+ * is compatible with any version of the application or toolkit
+ * @param aAppVersion
+ * The version of the application or null to use the current version
+ * @param aPlatformVersion
+ * The version of the platform or null to use the current version
+ * @param aIgnoreMaxVersion
+ * Ignore maxVersion when testing if an update matches. Optional.
+ * @param aIgnoreStrictCompat
+ * Ignore strictCompatibility when testing if an update matches. Optional.
+ * @return an update object if one matches or null if not
+ */
+ getCompatibilityUpdate(
+ aUpdates,
+ aVersion,
+ aIgnoreCompatibility,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+ ) {
+ if (!aAppVersion) {
+ aAppVersion = Services.appinfo.version;
+ }
+ if (!aPlatformVersion) {
+ aPlatformVersion = Services.appinfo.platformVersion;
+ }
+
+ for (let update of aUpdates) {
+ if (Services.vc.compare(update.version, aVersion) == 0) {
+ if (aIgnoreCompatibility) {
+ for (let targetApp of update.targetApplications) {
+ let id = targetApp.id;
+ if (id == Services.appinfo.ID || id == TOOLKIT_ID) {
+ return update;
+ }
+ }
+ } else if (
+ matchesVersions(
+ update,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+ )
+ ) {
+ return update;
+ }
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Asynchronously returns the newest available update from a list of update objects.
+ *
+ * @param aUpdates
+ * An array of update objects
+ * @param aAddon
+ * The add-on that is being updated.
+ * @param aAppVersion
+ * The version of the application or null to use the current version
+ * @param aPlatformVersion
+ * The version of the platform or null to use the current version
+ * @param aIgnoreMaxVersion
+ * When determining compatible updates, ignore maxVersion. Optional.
+ * @param aIgnoreStrictCompat
+ * When determining compatible updates, ignore strictCompatibility. Optional.
+ * @return an update object if one matches or null if not
+ */
+ async getNewestCompatibleUpdate(
+ aUpdates,
+ aAddon,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+ ) {
+ if (!aAppVersion) {
+ aAppVersion = Services.appinfo.version;
+ }
+ if (!aPlatformVersion) {
+ aPlatformVersion = Services.appinfo.platformVersion;
+ }
+
+ let newestVersion = aAddon.version;
+ let newest = null;
+ let blocked = null;
+ let blockedState;
+ for (let update of aUpdates) {
+ if (!update.updateURL) {
+ continue;
+ }
+ if (Services.vc.compare(newestVersion, update.version) >= 0) {
+ // Update older than add-on version or older than previous result.
+ continue;
+ }
+ if (
+ !matchesVersions(
+ update,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+ )
+ ) {
+ continue;
+ }
+ let state = await lazy.Blocklist.getAddonBlocklistState(
+ update,
+ aAppVersion,
+ aPlatformVersion
+ );
+ if (state != Ci.nsIBlocklistService.STATE_NOT_BLOCKED) {
+ if (
+ !blocked ||
+ Services.vc.compare(blocked.version, update.version) < 0
+ ) {
+ blocked = update;
+ blockedState = state;
+ }
+ continue;
+ }
+ newest = update;
+ newestVersion = update.version;
+ }
+ if (
+ blocked &&
+ (!newest || Services.vc.compare(blocked.version, newestVersion) >= 0)
+ ) {
+ // If |newest| has a higher version than |blocked|, then the add-on would
+ // not be considered for installation. But if |blocked| would otherwise
+ // be eligible for installation, then report to telemetry that installation
+ // has been blocked because of the blocklist.
+ lazy.Blocklist.recordAddonBlockChangeTelemetry(
+ {
+ id: aAddon.id,
+ version: blocked.version,
+ blocklistState: blockedState,
+ },
+ "addon_update_check"
+ );
+ }
+ return newest;
+ },
+
+ /**
+ * Starts an update check.
+ *
+ * @param aId
+ * The ID of the add-on being checked for updates
+ * @param aUrl
+ * The URL of the add-on's update manifest
+ * @param aObserver
+ * An observer to notify of results
+ * @return UpdateParser so that the caller can use UpdateParser.cancel() to shut
+ * down in-progress update requests
+ */
+ checkForUpdates(aId, aUrl, aObserver) {
+ return new UpdateParser(aId, aUrl, aObserver);
+ },
+};
diff --git a/toolkit/mozapps/extensions/internal/GMPProvider.sys.mjs b/toolkit/mozapps/extensions/internal/GMPProvider.sys.mjs
new file mode 100644
index 0000000000..5a9e1308af
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/GMPProvider.sys.mjs
@@ -0,0 +1,910 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ GMPInstallManager: "resource://gre/modules/GMPInstallManager.sys.mjs",
+ Log: "resource://gre/modules/Log.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+import {
+ GMPPrefs,
+ GMPUtils,
+ OPEN_H264_ID,
+ WIDEVINE_ID,
+} from "resource://gre/modules/GMPUtils.sys.mjs";
+
+const SEC_IN_A_DAY = 24 * 60 * 60;
+// How long to wait after a user enabled EME before attempting to download CDMs.
+const GMP_CHECK_DELAY = 10 * 1000; // milliseconds
+
+const XHTML = "http://www.w3.org/1999/xhtml";
+
+const NS_GRE_DIR = "GreD";
+const CLEARKEY_PLUGIN_ID = "gmp-clearkey";
+const CLEARKEY_VERSION = "0.1";
+
+const FIRST_CONTENT_PROCESS_TOPIC = "ipc:first-content-process-created";
+
+const GMP_LICENSE_INFO = "plugins-gmp-license-info";
+const GMP_PRIVACY_INFO = "plugins-gmp-privacy-info";
+const GMP_LEARN_MORE = "learn_more_label";
+
+const GMP_PLUGINS = [
+ {
+ id: OPEN_H264_ID,
+ name: "plugins-openh264-name",
+ description: "plugins-openh264-description",
+ // The following licenseURL is part of an awful hack to include the OpenH264
+ // license without having bug 624602 fixed yet, and intentionally ignores
+ // localisation.
+ licenseURL: "chrome://mozapps/content/extensions/OpenH264-license.txt",
+ homepageURL: "https://www.openh264.org/",
+ },
+ {
+ id: WIDEVINE_ID,
+ name: "plugins-widevine-name",
+ description: "plugins-widevine-description",
+ licenseURL: "https://www.google.com/policies/privacy/",
+ homepageURL: "https://www.widevine.com/",
+ isEME: true,
+ },
+];
+
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "pluginsBundle",
+ () => new Localization(["toolkit/about/aboutPlugins.ftl"], true)
+);
+XPCOMUtils.defineLazyGetter(lazy, "gmpService", () =>
+ Cc["@mozilla.org/gecko-media-plugin-service;1"].getService(
+ Ci.mozIGeckoMediaPluginChromeService
+ )
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "gmpProviderEnabled",
+ GMPPrefs.KEY_PROVIDER_ENABLED
+);
+
+var gLogger;
+var gLogAppenderDump = null;
+
+function configureLogging() {
+ if (!gLogger) {
+ gLogger = lazy.Log.repository.getLogger("Toolkit.GMP");
+ gLogger.addAppender(
+ new lazy.Log.ConsoleAppender(new lazy.Log.BasicFormatter())
+ );
+ }
+ gLogger.level = GMPPrefs.getInt(
+ GMPPrefs.KEY_LOGGING_LEVEL,
+ lazy.Log.Level.Warn
+ );
+
+ let logDumping = GMPPrefs.getBool(GMPPrefs.KEY_LOGGING_DUMP, false);
+ if (logDumping != !!gLogAppenderDump) {
+ if (logDumping) {
+ gLogAppenderDump = new lazy.Log.DumpAppender(
+ new lazy.Log.BasicFormatter()
+ );
+ gLogger.addAppender(gLogAppenderDump);
+ } else {
+ gLogger.removeAppender(gLogAppenderDump);
+ gLogAppenderDump = null;
+ }
+ }
+}
+
+/**
+ * The GMPWrapper provides the info for the various GMP plugins to public
+ * callers through the API.
+ */
+function GMPWrapper(aPluginInfo, aRawPluginInfo) {
+ this._plugin = aPluginInfo;
+ this._rawPlugin = aRawPluginInfo;
+ this._log = lazy.Log.repository.getLoggerWithMessagePrefix(
+ "Toolkit.GMP",
+ "GMPWrapper(" + this._plugin.id + ") "
+ );
+ Services.prefs.addObserver(
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_ENABLED, this._plugin.id),
+ this,
+ true
+ );
+ Services.prefs.addObserver(
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_VERSION, this._plugin.id),
+ this,
+ true
+ );
+ if (this._plugin.isEME) {
+ Services.prefs.addObserver(GMPPrefs.KEY_EME_ENABLED, this, true);
+ Services.obs.addObserver(this, "EMEVideo:CDMMissing");
+ }
+}
+
+GMPWrapper.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ // An active task that checks for plugin updates and installs them.
+ _updateTask: null,
+ _gmpPath: null,
+ _isUpdateCheckPending: false,
+
+ set gmpPath(aPath) {
+ this._gmpPath = aPath;
+ },
+ get gmpPath() {
+ if (!this._gmpPath && this.isInstalled) {
+ this._gmpPath = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ this._plugin.id,
+ GMPPrefs.getString(GMPPrefs.KEY_PLUGIN_VERSION, null, this._plugin.id)
+ );
+ }
+ return this._gmpPath;
+ },
+
+ get id() {
+ return this._plugin.id;
+ },
+ get type() {
+ return "plugin";
+ },
+ get isGMPlugin() {
+ return true;
+ },
+ get name() {
+ return this._plugin.name;
+ },
+ get creator() {
+ return null;
+ },
+ get homepageURL() {
+ return this._plugin.homepageURL;
+ },
+
+ get description() {
+ return this._plugin.description;
+ },
+ get fullDescription() {
+ return null;
+ },
+
+ getFullDescription(doc) {
+ let plugin = this._rawPlugin;
+
+ let frag = doc.createDocumentFragment();
+ for (let [urlProp, labelId] of [
+ ["learnMoreURL", GMP_LEARN_MORE],
+ [
+ "licenseURL",
+ this.id == WIDEVINE_ID ? GMP_PRIVACY_INFO : GMP_LICENSE_INFO,
+ ],
+ ]) {
+ if (plugin[urlProp]) {
+ let a = doc.createElementNS(XHTML, "a");
+ a.href = plugin[urlProp];
+ a.target = "_blank";
+ a.textContent = lazy.pluginsBundle.formatValueSync(labelId);
+
+ if (frag.childElementCount) {
+ frag.append(
+ doc.createElementNS(XHTML, "br"),
+ doc.createElementNS(XHTML, "br")
+ );
+ }
+ frag.append(a);
+ }
+ }
+
+ return frag;
+ },
+
+ get version() {
+ return GMPPrefs.getString(
+ GMPPrefs.KEY_PLUGIN_VERSION,
+ null,
+ this._plugin.id
+ );
+ },
+
+ get isActive() {
+ return (
+ !this.appDisabled &&
+ !this.userDisabled &&
+ !GMPUtils.isPluginHidden(this._plugin)
+ );
+ },
+ get appDisabled() {
+ if (
+ this._plugin.isEME &&
+ !GMPPrefs.getBool(GMPPrefs.KEY_EME_ENABLED, true)
+ ) {
+ // If "media.eme.enabled" is false, all EME plugins are disabled.
+ return true;
+ }
+ return false;
+ },
+
+ get userDisabled() {
+ return !GMPPrefs.getBool(
+ GMPPrefs.KEY_PLUGIN_ENABLED,
+ true,
+ this._plugin.id
+ );
+ },
+ set userDisabled(aVal) {
+ GMPPrefs.setBool(
+ GMPPrefs.KEY_PLUGIN_ENABLED,
+ aVal === false,
+ this._plugin.id
+ );
+ },
+
+ async enable() {
+ this.userDisabled = false;
+ },
+ async disable() {
+ this.userDisabled = true;
+ },
+
+ get blocklistState() {
+ return Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ },
+ get size() {
+ return 0;
+ },
+ get scope() {
+ return lazy.AddonManager.SCOPE_APPLICATION;
+ },
+ get pendingOperations() {
+ return lazy.AddonManager.PENDING_NONE;
+ },
+
+ get operationsRequiringRestart() {
+ return lazy.AddonManager.OP_NEEDS_RESTART_NONE;
+ },
+
+ get permissions() {
+ let permissions = 0;
+ if (!this.appDisabled) {
+ permissions |= lazy.AddonManager.PERM_CAN_UPGRADE;
+ permissions |= this.userDisabled
+ ? lazy.AddonManager.PERM_CAN_ENABLE
+ : lazy.AddonManager.PERM_CAN_DISABLE;
+ }
+ return permissions;
+ },
+
+ get updateDate() {
+ let time = Number(
+ GMPPrefs.getInt(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, 0, this._plugin.id)
+ );
+ if (this.isInstalled) {
+ return new Date(time * 1000);
+ }
+ return null;
+ },
+
+ get isCompatible() {
+ return true;
+ },
+
+ get isPlatformCompatible() {
+ return true;
+ },
+
+ get providesUpdatesSecurely() {
+ return true;
+ },
+
+ get foreignInstall() {
+ return false;
+ },
+
+ get installTelemetryInfo() {
+ return { source: "gmp-plugin" };
+ },
+
+ isCompatibleWith(aAppVersion, aPlatformVersion) {
+ return true;
+ },
+
+ get applyBackgroundUpdates() {
+ if (!GMPPrefs.isSet(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, this._plugin.id)) {
+ return lazy.AddonManager.AUTOUPDATE_DEFAULT;
+ }
+
+ return GMPPrefs.getBool(
+ GMPPrefs.KEY_PLUGIN_AUTOUPDATE,
+ true,
+ this._plugin.id
+ )
+ ? lazy.AddonManager.AUTOUPDATE_ENABLE
+ : lazy.AddonManager.AUTOUPDATE_DISABLE;
+ },
+
+ set applyBackgroundUpdates(aVal) {
+ if (aVal == lazy.AddonManager.AUTOUPDATE_DEFAULT) {
+ GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, this._plugin.id);
+ } else if (aVal == lazy.AddonManager.AUTOUPDATE_ENABLE) {
+ GMPPrefs.setBool(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, true, this._plugin.id);
+ } else if (aVal == lazy.AddonManager.AUTOUPDATE_DISABLE) {
+ GMPPrefs.setBool(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, false, this._plugin.id);
+ }
+ },
+
+ /**
+ * Called by the addon manager to update GMP addons. For example this will be
+ * used if a user manually checks for GMP plugin updates by using the
+ * menu in about:addons.
+ *
+ * This function is not used if MediaKeySystemAccess is requested and
+ * Widevine is not yet installed, or if the user toggles prefs to enable EME.
+ * For the function used in those cases see `checkForUpdates`.
+ */
+ findUpdates(aListener, aReason, aAppVersion, aPlatformVersion) {
+ this._log.trace(
+ "findUpdates() - " + this._plugin.id + " - reason=" + aReason
+ );
+
+ // In the case of GMP addons we do not wish to implement AddonInstall, as
+ // we don't want to display information as in a normal addon install such
+ // as a download progress bar. As such, we short circuit our
+ // listeners by indicating that no updates exist (though some may).
+ lazy.AddonManagerPrivate.callNoUpdateListeners(this, aListener);
+
+ if (aReason === lazy.AddonManager.UPDATE_WHEN_PERIODIC_UPDATE) {
+ if (!lazy.AddonManager.shouldAutoUpdate(this)) {
+ this._log.trace(
+ "findUpdates() - " + this._plugin.id + " - no autoupdate"
+ );
+ return Promise.resolve(false);
+ }
+
+ let secSinceLastCheck =
+ Date.now() / 1000 -
+ Services.prefs.getIntPref(GMPPrefs.KEY_UPDATE_LAST_CHECK, 0);
+ if (secSinceLastCheck <= SEC_IN_A_DAY) {
+ this._log.trace(
+ "findUpdates() - " +
+ this._plugin.id +
+ " - last check was less then a day ago"
+ );
+ return Promise.resolve(false);
+ }
+ } else if (aReason !== lazy.AddonManager.UPDATE_WHEN_USER_REQUESTED) {
+ this._log.trace(
+ "findUpdates() - " +
+ this._plugin.id +
+ " - the given reason to update is not supported"
+ );
+ return Promise.resolve(false);
+ }
+
+ if (this._updateTask !== null) {
+ this._log.trace(
+ "findUpdates() - " + this._plugin.id + " - update task already running"
+ );
+ return this._updateTask;
+ }
+
+ this._updateTask = (async () => {
+ this._log.trace("findUpdates() - updateTask");
+ try {
+ let installManager = new lazy.GMPInstallManager();
+ let res = await installManager.checkForAddons();
+ let update = res.addons.find(addon => addon.id === this._plugin.id);
+ if (update && update.isValid && !update.isInstalled) {
+ this._log.trace(
+ "findUpdates() - found update for " +
+ this._plugin.id +
+ ", installing"
+ );
+ await installManager.installAddon(update);
+ } else {
+ this._log.trace("findUpdates() - no updates for " + this._plugin.id);
+ }
+ this._log.info(
+ "findUpdates() - updateTask succeeded for " + this._plugin.id
+ );
+ } catch (e) {
+ this._log.error(
+ "findUpdates() - updateTask for " + this._plugin.id + " threw",
+ e
+ );
+ throw e;
+ } finally {
+ this._updateTask = null;
+ }
+ return true;
+ })();
+
+ return this._updateTask;
+ },
+
+ get pluginLibraries() {
+ if (this.isInstalled) {
+ let path = this.version;
+ return [path];
+ }
+ return [];
+ },
+ get pluginFullpath() {
+ if (this.isInstalled) {
+ let path = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ this._plugin.id,
+ this.version
+ );
+ return [path];
+ }
+ return [];
+ },
+
+ get isInstalled() {
+ return this.version && !!this.version.length;
+ },
+
+ _handleEnabledChanged() {
+ this._log.info(
+ "_handleEnabledChanged() id=" +
+ this._plugin.id +
+ " isActive=" +
+ this.isActive
+ );
+
+ lazy.AddonManagerPrivate.callAddonListeners(
+ this.isActive ? "onEnabling" : "onDisabling",
+ this,
+ false
+ );
+ if (this._gmpPath) {
+ if (this.isActive) {
+ this._log.info(
+ "onPrefEnabledChanged() - adding gmp directory " + this._gmpPath
+ );
+ lazy.gmpService.addPluginDirectory(this._gmpPath);
+ } else {
+ this._log.info(
+ "onPrefEnabledChanged() - removing gmp directory " + this._gmpPath
+ );
+ lazy.gmpService.removePluginDirectory(this._gmpPath);
+ }
+ }
+ lazy.AddonManagerPrivate.callAddonListeners(
+ this.isActive ? "onEnabled" : "onDisabled",
+ this
+ );
+ },
+
+ onPrefEMEGlobalEnabledChanged() {
+ this._log.info(
+ "onPrefEMEGlobalEnabledChanged() id=" +
+ this._plugin.id +
+ " appDisabled=" +
+ this.appDisabled +
+ " isActive=" +
+ this.isActive +
+ " hidden=" +
+ GMPUtils.isPluginHidden(this._plugin)
+ );
+
+ lazy.AddonManagerPrivate.callAddonListeners("onPropertyChanged", this, [
+ "appDisabled",
+ ]);
+ // If EME or the GMP itself are disabled, uninstall the GMP.
+ // Otherwise, check for updates, so we download and install the GMP.
+ if (this.appDisabled) {
+ this.uninstallPlugin();
+ } else if (!GMPUtils.isPluginHidden(this._plugin)) {
+ lazy.AddonManagerPrivate.callInstallListeners(
+ "onExternalInstall",
+ null,
+ this,
+ null,
+ false
+ );
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalling", this, false);
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalled", this);
+ this.checkForUpdates(GMP_CHECK_DELAY);
+ }
+ if (!this.userDisabled) {
+ this._handleEnabledChanged();
+ }
+ },
+
+ /**
+ * This is called if prefs are changed to enable EME, or if Widevine
+ * MediaKeySystemAccess is requested but the Widevine CDM is not installed.
+ *
+ * For the function used by the addon manager see `findUpdates`.
+ */
+ checkForUpdates(delay) {
+ if (this._isUpdateCheckPending) {
+ return;
+ }
+ this._isUpdateCheckPending = true;
+ GMPPrefs.reset(GMPPrefs.KEY_UPDATE_LAST_CHECK, null);
+ // Delay this in case the user changes his mind and doesn't want to
+ // enable EME after all.
+ lazy.setTimeout(() => {
+ if (!this.appDisabled) {
+ let gmpInstallManager = new lazy.GMPInstallManager();
+ // We don't really care about the results, if someone is interested
+ // they can check the log.
+ gmpInstallManager.simpleCheckAndInstall().catch(() => {});
+ }
+ this._isUpdateCheckPending = false;
+ }, delay);
+ },
+
+ onPrefEnabledChanged() {
+ if (!this._plugin.isEME || !this.appDisabled) {
+ this._handleEnabledChanged();
+ }
+ },
+
+ onPrefVersionChanged() {
+ lazy.AddonManagerPrivate.callAddonListeners("onUninstalling", this, false);
+ if (this._gmpPath) {
+ this._log.info(
+ "onPrefVersionChanged() - unregistering gmp directory " + this._gmpPath
+ );
+ lazy.gmpService.removeAndDeletePluginDirectory(
+ this._gmpPath,
+ true /* can defer */
+ );
+ }
+ lazy.AddonManagerPrivate.callAddonListeners("onUninstalled", this);
+
+ lazy.AddonManagerPrivate.callInstallListeners(
+ "onExternalInstall",
+ null,
+ this,
+ null,
+ false
+ );
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalling", this, false);
+ this._gmpPath = null;
+ if (this.isInstalled) {
+ this._gmpPath = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ this._plugin.id,
+ GMPPrefs.getString(GMPPrefs.KEY_PLUGIN_VERSION, null, this._plugin.id)
+ );
+ }
+ if (this._gmpPath && this.isActive) {
+ this._log.info(
+ "onPrefVersionChanged() - registering gmp directory " + this._gmpPath
+ );
+ lazy.gmpService.addPluginDirectory(this._gmpPath);
+ }
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalled", this);
+ },
+
+ observe(subject, topic, data) {
+ if (topic == "nsPref:changed") {
+ let pref = data;
+ if (
+ pref ==
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_ENABLED, this._plugin.id)
+ ) {
+ this.onPrefEnabledChanged();
+ } else if (
+ pref ==
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_VERSION, this._plugin.id)
+ ) {
+ this.onPrefVersionChanged();
+ } else if (pref == GMPPrefs.KEY_EME_ENABLED) {
+ this.onPrefEMEGlobalEnabledChanged();
+ }
+ } else if (topic == "EMEVideo:CDMMissing") {
+ this.checkForUpdates(0);
+ }
+ },
+
+ uninstallPlugin() {
+ lazy.AddonManagerPrivate.callAddonListeners("onUninstalling", this, false);
+ if (this.gmpPath) {
+ this._log.info(
+ "uninstallPlugin() - unregistering gmp directory " + this.gmpPath
+ );
+ lazy.gmpService.removeAndDeletePluginDirectory(this.gmpPath);
+ }
+ GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_VERSION, this.id);
+ GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_HASHVALUE, this.id);
+ GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_ABI, this.id);
+ GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, this.id);
+ lazy.AddonManagerPrivate.callAddonListeners("onUninstalled", this);
+ },
+
+ shutdown() {
+ Services.prefs.removeObserver(
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_ENABLED, this._plugin.id),
+ this
+ );
+ Services.prefs.removeObserver(
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_VERSION, this._plugin.id),
+ this
+ );
+ if (this._plugin.isEME) {
+ Services.prefs.removeObserver(GMPPrefs.KEY_EME_ENABLED, this);
+ Services.obs.removeObserver(this, "EMEVideo:CDMMissing");
+ }
+ return this._updateTask;
+ },
+
+ _arePluginFilesOnDisk() {
+ let fileExists = function (aGmpPath, aFileName) {
+ let f = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ let path = PathUtils.join(aGmpPath, aFileName);
+ f.initWithPath(path);
+ return f.exists();
+ };
+
+ let id = this._plugin.id.substring(4);
+ let libName = AppConstants.DLL_PREFIX + id + AppConstants.DLL_SUFFIX;
+ let infoName;
+ if (this._plugin.id == WIDEVINE_ID) {
+ infoName = "manifest.json";
+ } else {
+ infoName = id + ".info";
+ }
+
+ return (
+ fileExists(this.gmpPath, libName) && fileExists(this.gmpPath, infoName)
+ );
+ },
+
+ validate() {
+ if (!this.isInstalled) {
+ // Not installed -> Valid.
+ return {
+ installed: false,
+ valid: true,
+ };
+ }
+
+ let expectedABI = GMPUtils._expectedABI(this._plugin);
+ let abi = GMPPrefs.getString(
+ GMPPrefs.KEY_PLUGIN_ABI,
+ expectedABI,
+ this._plugin.id
+ );
+ if (abi != expectedABI) {
+ // ABI doesn't match. Possibly this is a profile migrated across platforms
+ // or from 32 -> 64 bit.
+ return {
+ installed: true,
+ mismatchedABI: true,
+ valid: false,
+ };
+ }
+
+ // Installed -> Check if files are missing.
+ let filesOnDisk = this._arePluginFilesOnDisk();
+ return {
+ installed: true,
+ valid: filesOnDisk,
+ };
+ },
+};
+
+var GMPProvider = {
+ get name() {
+ return "GMPProvider";
+ },
+
+ _plugins: null,
+
+ startup() {
+ configureLogging();
+ this._log = lazy.Log.repository.getLoggerWithMessagePrefix(
+ "Toolkit.GMP",
+ "GMPProvider."
+ );
+ this.buildPluginList();
+ this.ensureProperCDMInstallState();
+
+ Services.prefs.addObserver(GMPPrefs.KEY_LOG_BASE, configureLogging);
+
+ for (let plugin of this._plugins.values()) {
+ let wrapper = plugin.wrapper;
+ let gmpPath = wrapper.gmpPath;
+ let isEnabled = wrapper.isActive;
+ this._log.trace(
+ "startup - enabled=" + isEnabled + ", gmpPath=" + gmpPath
+ );
+
+ if (gmpPath && isEnabled) {
+ let validation = wrapper.validate();
+ if (validation.mismatchedABI) {
+ this._log.info(
+ "startup - gmp " + plugin.id + " mismatched ABI, uninstalling"
+ );
+ wrapper.uninstallPlugin();
+ continue;
+ }
+ if (!validation.valid) {
+ this._log.info(
+ "startup - gmp " + plugin.id + " invalid, uninstalling"
+ );
+ wrapper.uninstallPlugin();
+ continue;
+ }
+ this._log.info("startup - adding gmp directory " + gmpPath);
+ try {
+ lazy.gmpService.addPluginDirectory(gmpPath);
+ } catch (e) {
+ if (e.name != "NS_ERROR_NOT_AVAILABLE") {
+ throw e;
+ }
+ this._log.warn(
+ "startup - adding gmp directory failed with " +
+ e.name +
+ " - sandboxing not available?",
+ e
+ );
+ }
+ }
+ }
+
+ try {
+ let greDir = Services.dirsvc.get(NS_GRE_DIR, Ci.nsIFile);
+ let path = greDir.path;
+ if (
+ GMPUtils._isWindowsOnARM64() &&
+ GMPPrefs.getBool(
+ GMPPrefs.KEY_PLUGIN_ALLOW_X64_ON_ARM64,
+ true,
+ CLEARKEY_PLUGIN_ID
+ )
+ ) {
+ path = PathUtils.join(path, "i686");
+ }
+ let clearkeyPath = PathUtils.join(
+ path,
+ CLEARKEY_PLUGIN_ID,
+ CLEARKEY_VERSION
+ );
+ this._log.info("startup - adding clearkey CDM directory " + clearkeyPath);
+ lazy.gmpService.addPluginDirectory(clearkeyPath);
+ } catch (e) {
+ this._log.warn("startup - adding clearkey CDM failed", e);
+ }
+ },
+
+ shutdown() {
+ this._log.trace("shutdown");
+ Services.prefs.removeObserver(GMPPrefs.KEY_LOG_BASE, configureLogging);
+
+ let shutdownTask = (async () => {
+ this._log.trace("shutdown - shutdownTask");
+ let shutdownSucceeded = true;
+
+ for (let plugin of this._plugins.values()) {
+ try {
+ await plugin.wrapper.shutdown();
+ } catch (e) {
+ shutdownSucceeded = false;
+ }
+ }
+
+ this._plugins = null;
+
+ if (!shutdownSucceeded) {
+ throw new Error("Shutdown failed");
+ }
+ })();
+
+ return shutdownTask;
+ },
+
+ async getAddonByID(aId) {
+ if (!this.isEnabled) {
+ return null;
+ }
+
+ let plugin = this._plugins.get(aId);
+ if (plugin && !GMPUtils.isPluginHidden(plugin)) {
+ return plugin.wrapper;
+ }
+ return null;
+ },
+
+ async getAddonsByTypes(aTypes) {
+ if (!this.isEnabled || (aTypes && !aTypes.includes("plugin"))) {
+ return [];
+ }
+
+ let results = Array.from(this._plugins.values())
+ .filter(p => !GMPUtils.isPluginHidden(p))
+ .map(p => p.wrapper);
+
+ return results;
+ },
+
+ get isEnabled() {
+ return lazy.gmpProviderEnabled;
+ },
+
+ buildPluginList() {
+ this._plugins = new Map();
+ for (let aPlugin of GMP_PLUGINS) {
+ let plugin = {
+ id: aPlugin.id,
+ name: lazy.pluginsBundle.formatValueSync(aPlugin.name),
+ description: lazy.pluginsBundle.formatValueSync(aPlugin.description),
+ homepageURL: aPlugin.homepageURL,
+ optionsURL: aPlugin.optionsURL,
+ wrapper: null,
+ isEME: aPlugin.isEME,
+ };
+ plugin.wrapper = new GMPWrapper(plugin, aPlugin);
+ this._plugins.set(plugin.id, plugin);
+ }
+ },
+
+ ensureProperCDMInstallState() {
+ if (!GMPPrefs.getBool(GMPPrefs.KEY_EME_ENABLED, true)) {
+ for (let plugin of this._plugins.values()) {
+ if (plugin.isEME && plugin.wrapper.isInstalled) {
+ lazy.gmpService.addPluginDirectory(plugin.wrapper.gmpPath);
+ plugin.wrapper.uninstallPlugin();
+ }
+ }
+ }
+ },
+
+ observe(subject, topic, data) {
+ if (topic == FIRST_CONTENT_PROCESS_TOPIC) {
+ lazy.AddonManagerPrivate.registerProvider(GMPProvider, ["plugin"]);
+ Services.obs.notifyObservers(null, "gmp-provider-registered");
+
+ Services.obs.removeObserver(this, FIRST_CONTENT_PROCESS_TOPIC);
+ }
+ },
+
+ addObserver() {
+ Services.obs.addObserver(this, FIRST_CONTENT_PROCESS_TOPIC);
+ },
+};
+
+GMPProvider.addObserver();
+
+// For test use only.
+export const GMPTestUtils = {
+ /**
+ * Used to override the GMP service with a mock.
+ *
+ * @param {object} mockService
+ * The mocked gmpService object.
+ * @param {function} callback
+ * Method called with the overridden gmpService. The override
+ * is undone after the callback returns.
+ */
+ async overrideGmpService(mockService, callback) {
+ let originalGmpService = lazy.gmpService;
+ lazy.gmpService = mockService;
+ try {
+ return await callback();
+ } finally {
+ lazy.gmpService = originalGmpService;
+ }
+ },
+};
diff --git a/toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs b/toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs
new file mode 100644
index 0000000000..1615a551c8
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs
@@ -0,0 +1,601 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 { Log } from "resource://gre/modules/Log.sys.mjs";
+import { CertUtils } from "resource://gre/modules/CertUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs",
+});
+
+// This will inherit settings from the "addons" logger.
+var logger = Log.repository.getLogger("addons.productaddons");
+// We want to set the level of this logger independent from its parent to help
+// debug things like GMP updates. Link this to its own level pref.
+logger.manageLevelFromPref("extensions.logging.productaddons.level");
+
+/**
+ * Number of milliseconds after which we need to cancel `downloadXMLWithRequest`
+ * and `conservativeFetch`.
+ *
+ * Bug 1087674 suggests that the XHR/ServiceRequest we use in
+ * `downloadXMLWithRequest` may never terminate in presence of network nuisances
+ * (e.g. strange antivirus behavior). This timeout is a defensive measure to
+ * ensure that we fail cleanly in such case.
+ */
+const TIMEOUT_DELAY_MS = 20000;
+
+/**
+ * Gets the status of an XMLHttpRequest either directly or from its underlying
+ * channel.
+ *
+ * @param request
+ * The XMLHttpRequest.
+ * @returns {Object} result - An object containing the results.
+ * @returns {integer} result.status - Request status code, if available, else the channel nsresult.
+ * @returns {integer} result.channelStatus - Channel nsresult.
+ * @returns {integer} result.errorCode - Request error code.
+ */
+function getRequestStatus(request) {
+ let status = null;
+ let errorCode = null;
+ let channelStatus = null;
+
+ try {
+ status = request.status;
+ } catch (e) {}
+ try {
+ errorCode = request.errorCode;
+ } catch (e) {}
+ try {
+ channelStatus = request.channel.QueryInterface(Ci.nsIRequest).status;
+ } catch (e) {}
+
+ if (status == null) {
+ status = channelStatus;
+ }
+
+ return { status, channelStatus, errorCode };
+}
+
+/**
+ * A wrapper around `ServiceRequest` that behaves like a limited `fetch()`.
+ * This doesn't handle headers like fetch, but can be expanded as callers need.
+ *
+ * Use this in order to leverage the `beConservative` flag, for
+ * example to avoid using HTTP3 to fetch critical data.
+ *
+ * @param input a resource
+ * @returns a Response object
+ */
+async function conservativeFetch(input) {
+ return new Promise(function (resolve, reject) {
+ const request = new lazy.ServiceRequest({ mozAnon: true });
+ request.timeout = TIMEOUT_DELAY_MS;
+
+ request.onerror = () => {
+ let err = new TypeError("NetworkError: Network request failed");
+ err.addonCheckerErr = ProductAddonChecker.NETWORK_REQUEST_ERR;
+ reject(err);
+ };
+ request.ontimeout = () => {
+ let err = new TypeError("Timeout: Network request failed");
+ err.addonCheckerErr = ProductAddonChecker.NETWORK_TIMEOUT_ERR;
+ reject(err);
+ };
+ request.onabort = () => {
+ let err = new DOMException("Aborted", "AbortError");
+ err.addonCheckerErr = ProductAddonChecker.ABORT_ERR;
+ reject(err);
+ };
+ request.onload = () => {
+ const responseAttributes = {
+ status: request.status,
+ statusText: request.statusText,
+ url: request.responseURL,
+ };
+ resolve(new Response(request.response, responseAttributes));
+ };
+
+ const method = "GET";
+
+ request.open(method, input, true);
+
+ request.send();
+ });
+}
+
+/**
+ * Verifies the content signature on GMP's update.xml. When we fetch update.xml
+ * balrog should send back content signature headers, which this function
+ * is used to verify.
+ *
+ * @param data
+ * The data received from balrog. I.e. the xml contents of update.xml.
+ * @param contentSignatureHeader
+ * The contents of the 'content-signature' header received along with
+ * `data`.
+ * @return A promise that will resolve to nothing if the signature verification
+ * succeeds, or rejects on failure, with an Error that sets its
+ * addonCheckerErr property disambiguate failure cases and a message
+ * explaining the error.
+ */
+async function verifyGmpContentSignature(data, contentSignatureHeader) {
+ if (!contentSignatureHeader) {
+ logger.warn(
+ "Unexpected missing content signature header during content signature validation"
+ );
+ let err = new Error(
+ "Content signature validation failed: missing content signature header"
+ );
+ err.addonCheckerErr = ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR;
+ throw err;
+ }
+ // Split out the header. It should contain a the following fields, separated by a semicolon
+ // - x5u - a URI to the cert chain. See also https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.5
+ // - p384ecdsa - the signature to verify. See also https://github.com/mozilla-services/autograph/blob/main/signer/contentsignaturepki/README.md
+ const headerFields = contentSignatureHeader
+ .split(";") // Split fields...
+ .map(s => s.trim()) // Remove whitespace...
+ .map(s => [
+ // Break each field into it's name and value. This more verbose version is
+ // used instead of `split()` to handle values that contain = characters. This
+ // shouldn't happen for the signature because it's base64_url (no = padding),
+ // but it's not clear if it's possible for the x5u URL (as part of a query).
+ // Guard anyway, better safe than sorry.
+ s.substring(0, s.indexOf("=")), // Get field name...
+ s.substring(s.indexOf("=") + 1), // and field value.
+ ]);
+
+ let x5u;
+ let signature;
+ for (const [fieldName, fieldValue] of headerFields) {
+ if (fieldName == "x5u") {
+ x5u = fieldValue;
+ } else if (fieldName == "p384ecdsa") {
+ // The signature needs to contain 'p384ecdsa', so stich it back together.
+ signature = `p384ecdsa=${fieldValue}`;
+ }
+ }
+
+ if (!x5u) {
+ logger.warn("Unexpected missing x5u during content signature validation");
+ let err = Error("Content signature validation failed: missing x5u");
+ err.addonCheckerErr = ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR;
+ throw err;
+ }
+
+ if (!signature) {
+ logger.warn(
+ "Unexpected missing signature during content signature validation"
+ );
+ let err = Error("Content signature validation failed: missing signature");
+ err.addonCheckerErr = ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR;
+ throw err;
+ }
+
+ // The x5u field should contain the location of the cert chain, fetch it.
+ // Use `conservativeFetch` so we get conservative behaviour and ensure (more)
+ // reliable fetching.
+ const certChain = await (await conservativeFetch(x5u)).text();
+
+ const verifier = Cc[
+ "@mozilla.org/security/contentsignatureverifier;1"
+ ].createInstance(Ci.nsIContentSignatureVerifier);
+
+ // See bug 1771992. In the future, this may need to handle staging and dev
+ // environments in addition to just production and testing.
+ let root = Ci.nsIContentSignatureVerifier.ContentSignatureProdRoot;
+ if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
+ root = Ci.nsIX509CertDB.AppXPCShellRoot;
+ }
+
+ let valid;
+ try {
+ valid = await verifier.asyncVerifyContentSignature(
+ data,
+ signature,
+ certChain,
+ "aus.content-signature.mozilla.org",
+ root
+ );
+ } catch (err) {
+ logger.warn(`Unexpected error while validating content signature: ${err}`);
+ let newErr = new Error(`Content signature validation failed: ${err}`);
+ newErr.addonCheckerErr = ProductAddonChecker.VERIFICATION_FAILED_ERR;
+ throw newErr;
+ }
+
+ if (!valid) {
+ logger.warn("Unexpected invalid content signature found during validation");
+ let err = new Error("Content signature is not valid");
+ err.addonCheckerErr = ProductAddonChecker.VERIFICATION_INVALID_ERR;
+ throw err;
+ }
+}
+
+/**
+ * Downloads an XML document from a URL optionally testing the SSL certificate
+ * for certain attributes.
+ *
+ * @param url
+ * The url to download from.
+ * @param allowNonBuiltIn
+ * Whether to trust SSL certificates without a built-in CA issuer.
+ * @param allowedCerts
+ * The list of certificate attributes to match the SSL certificate
+ * against or null to skip checks.
+ * @return a promise that resolves to the ServiceRequest request on success or
+ * rejects with a JS exception in case of error.
+ */
+function downloadXMLWithRequest(
+ url,
+ allowNonBuiltIn = false,
+ allowedCerts = null
+) {
+ return new Promise((resolve, reject) => {
+ let request = new lazy.ServiceRequest();
+ // This is here to let unit test code override the ServiceRequest.
+ if (request.wrappedJSObject) {
+ request = request.wrappedJSObject;
+ }
+ request.open("GET", url, true);
+ request.channel.notificationCallbacks = new CertUtils.BadCertHandler(
+ allowNonBuiltIn
+ );
+ // Prevent the request from reading from the cache.
+ request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ // Prevent the request from writing to the cache.
+ request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
+ // Don't send any cookies
+ request.channel.loadFlags |= Ci.nsIRequest.LOAD_ANONYMOUS;
+ request.timeout = TIMEOUT_DELAY_MS;
+
+ request.overrideMimeType("text/xml");
+ // The Cache-Control header is only interpreted by proxies and the
+ // final destination. It does not help if a resource is already
+ // cached locally.
+ request.setRequestHeader("Cache-Control", "no-cache");
+ // HTTP/1.0 servers might not implement Cache-Control and
+ // might only implement Pragma: no-cache
+ request.setRequestHeader("Pragma", "no-cache");
+
+ let fail = event => {
+ let request = event.target;
+ let status = getRequestStatus(request);
+ let message =
+ "Failed downloading XML, status: " +
+ status.status +
+ ", channelStatus: " +
+ status.channelStatus +
+ ", errorCode: " +
+ status.errorCode +
+ ", reason: " +
+ event.type;
+ logger.warn(message);
+ let ex = new Error(message);
+ ex.status = status.status;
+ if (event.type == "error") {
+ ex.addonCheckerErr = ProductAddonChecker.NETWORK_REQUEST_ERR;
+ } else if (event.type == "abort") {
+ ex.addonCheckerErr = ProductAddonChecker.ABORT_ERR;
+ } else if (event.type == "timeout") {
+ ex.addonCheckerErr = ProductAddonChecker.NETWORK_TIMEOUT_ERR;
+ }
+ reject(ex);
+ };
+
+ let success = event => {
+ logger.info("Completed downloading document");
+ let request = event.target;
+
+ try {
+ CertUtils.checkCert(request.channel, allowNonBuiltIn, allowedCerts);
+ } catch (ex) {
+ logger.error("Request failed certificate checks: " + ex);
+ ex.status = getRequestStatus(request).requestStatus;
+ ex.addonCheckerErr = ProductAddonChecker.VERIFICATION_FAILED_ERR;
+ reject(ex);
+ return;
+ }
+
+ resolve(request);
+ };
+
+ request.addEventListener("error", fail);
+ request.addEventListener("abort", fail);
+ request.addEventListener("timeout", fail);
+ request.addEventListener("load", success);
+
+ logger.info("sending request to: " + url);
+ request.send(null);
+ });
+}
+
+/**
+ * Downloads an XML document from a URL optionally testing the SSL certificate
+ * for certain attributes, and/or testing the content signature.
+ *
+ * @param url
+ * The url to download from.
+ * @param allowNonBuiltIn
+ * Whether to trust SSL certificates without a built-in CA issuer.
+ * @param allowedCerts
+ * The list of certificate attributes to match the SSL certificate
+ * against or null to skip checks.
+ * @param verifyContentSignature
+ * When true, will verify the content signature information from the
+ * response header. Failure to verify will result in an error.
+ * @return a promise that resolves to the DOM document downloaded or rejects
+ * with a JS exception in case of error.
+ */
+async function downloadXML(
+ url,
+ allowNonBuiltIn = false,
+ allowedCerts = null,
+ verifyContentSignature = false
+) {
+ let request = await downloadXMLWithRequest(
+ url,
+ allowNonBuiltIn,
+ allowedCerts
+ );
+ if (verifyContentSignature) {
+ await verifyGmpContentSignature(
+ request.response,
+ request.getResponseHeader("content-signature")
+ );
+ }
+ return request.responseXML;
+}
+
+/**
+ * Parses a list of add-ons from a DOM document.
+ *
+ * @param document
+ * The DOM document to parse.
+ * @return null if there is no <addons> element otherwise an object containing
+ * an array of the addons listed and a field notifying whether the
+ * fallback was used.
+ */
+function parseXML(document) {
+ // Check that the root element is correct
+ if (document.documentElement.localName != "updates") {
+ let err = new Error(
+ "got node name: " +
+ document.documentElement.localName +
+ ", expected: updates"
+ );
+ err.addonCheckerErr = ProductAddonChecker.XML_PARSE_ERR;
+ throw err;
+ }
+
+ // Check if there are any addons elements in the updates element
+ let addons = document.querySelector("updates:root > addons");
+ if (!addons) {
+ return null;
+ }
+
+ let results = [];
+ let addonList = document.querySelectorAll("updates:root > addons > addon");
+ for (let addonElement of addonList) {
+ let addon = {};
+
+ for (let name of [
+ "id",
+ "URL",
+ "hashFunction",
+ "hashValue",
+ "version",
+ "size",
+ ]) {
+ if (addonElement.hasAttribute(name)) {
+ addon[name] = addonElement.getAttribute(name);
+ }
+ }
+ addon.size = Number(addon.size) || undefined;
+
+ results.push(addon);
+ }
+
+ return {
+ usedFallback: false,
+ addons: results,
+ };
+}
+
+/**
+ * Downloads file from a URL using ServiceRequest.
+ *
+ * @param url
+ * The url to download from.
+ * @param options (optional)
+ * @param options.httpsOnlyNoUpgrade
+ * Prevents upgrade to https:// when HTTPS-Only Mode is enabled.
+ * @return a promise that resolves to the path of a temporary file or rejects
+ * with a JS exception in case of error.
+ */
+function downloadFile(url, options = { httpsOnlyNoUpgrade: false }) {
+ return new Promise((resolve, reject) => {
+ let sr = new lazy.ServiceRequest();
+
+ sr.onload = function (response) {
+ logger.info("downloadFile File download. status=" + sr.status);
+ if (sr.status != 200 && sr.status != 206) {
+ reject(Components.Exception("File download failed", sr.status));
+ return;
+ }
+ (async function () {
+ const path = await IOUtils.createUniqueFile(
+ PathUtils.tempDir,
+ "tmpaddon"
+ );
+ logger.info(`Downloaded file will be saved to ${path}`);
+ await IOUtils.write(path, new Uint8Array(sr.response));
+
+ return path;
+ })().then(resolve, reject);
+ };
+
+ let fail = event => {
+ let request = event.target;
+ let status = getRequestStatus(request);
+ let message =
+ "Failed downloading via ServiceRequest, status: " +
+ status.status +
+ ", channelStatus: " +
+ status.channelStatus +
+ ", errorCode: " +
+ status.errorCode +
+ ", reason: " +
+ event.type;
+ logger.warn(message);
+ let ex = new Error(message);
+ ex.status = status.status;
+ reject(ex);
+ };
+ sr.addEventListener("error", fail);
+ sr.addEventListener("abort", fail);
+
+ sr.responseType = "arraybuffer";
+ try {
+ sr.open("GET", url);
+ if (options.httpsOnlyNoUpgrade) {
+ sr.channel.loadInfo.httpsOnlyStatus |= Ci.nsILoadInfo.HTTPS_ONLY_EXEMPT;
+ }
+ // Allow deprecated HTTP request from SystemPrincipal
+ sr.channel.loadInfo.allowDeprecatedSystemRequests = true;
+ sr.send(null);
+ } catch (ex) {
+ reject(ex);
+ }
+ });
+}
+
+/**
+ * Verifies that a downloaded file matches what was expected.
+ *
+ * @param properties
+ * The properties to check, `hashFunction` with `hashValue`
+ * are supported. Any properties missing won't be checked.
+ * @param path
+ * The path of the file to check.
+ * @return a promise that resolves if the file matched or rejects with a JS
+ * exception in case of error.
+ */
+var verifyFile = async function (properties, path) {
+ if (properties.size !== undefined) {
+ let stat = await IOUtils.stat(path);
+ if (stat.size != properties.size) {
+ throw new Error(
+ "Downloaded file was " +
+ stat.size +
+ " bytes but expected " +
+ properties.size +
+ " bytes."
+ );
+ }
+ }
+
+ if (properties.hashFunction !== undefined) {
+ let expectedDigest = properties.hashValue.toLowerCase();
+ let digest = await IOUtils.computeHexDigest(path, properties.hashFunction);
+ if (digest != expectedDigest) {
+ throw new Error(
+ "Hash was `" + digest + "` but expected `" + expectedDigest + "`."
+ );
+ }
+ }
+};
+
+export const ProductAddonChecker = {
+ // More specific error names to help debug and report failures.
+ NETWORK_REQUEST_ERR: "NetworkRequestError",
+ NETWORK_TIMEOUT_ERR: "NetworkTimeoutError",
+ ABORT_ERR: "AbortError", // Doesn't have network prefix to work with existing convention.
+ VERIFICATION_MISSING_DATA_ERR: "VerificationMissingDataError",
+ VERIFICATION_FAILED_ERR: "VerificationFailedError",
+ VERIFICATION_INVALID_ERR: "VerificationInvalidError",
+ XML_PARSE_ERR: "XMLParseError",
+
+ /**
+ * Downloads a list of add-ons from a URL optionally testing the SSL
+ * certificate for certain attributes, and/or testing the content signature.
+ *
+ * @param url
+ * The url to download from.
+ * @param allowNonBuiltIn
+ * Whether to trust SSL certificates without a built-in CA issuer.
+ * @param allowedCerts
+ * The list of certificate attributes to match the SSL certificate
+ * against or null to skip checks.
+ * @param verifyContentSignature
+ * When true, will verify the content signature information from the
+ * response header. Failure to verify will result in an error.
+ * @return a promise that resolves to an object containing the list of add-ons
+ * and whether the local fallback was used, or rejects with a JS
+ * exception in case of error. In the case of an error, a best effort
+ * is made to set the error addonCheckerErr property to one of the
+ * more specific names used by the product addon checker.
+ */
+ getProductAddonList(
+ url,
+ allowNonBuiltIn = false,
+ allowedCerts = null,
+ verifyContentSignature = false
+ ) {
+ return downloadXML(
+ url,
+ allowNonBuiltIn,
+ allowedCerts,
+ verifyContentSignature
+ ).then(parseXML);
+ },
+
+ /**
+ * Downloads an add-on to a local file and checks that it matches the expected
+ * file. The caller is responsible for deleting the temporary file returned.
+ *
+ * @param addon
+ * The addon to download.
+ * @param options (optional)
+ * @param options.httpsOnlyNoUpgrade
+ * Prevents upgrade to https:// when HTTPS-Only Mode is enabled.
+ * @return a promise that resolves to the temporary file downloaded or rejects
+ * with a JS exception in case of error.
+ */
+ async downloadAddon(addon, options = { httpsOnlyNoUpgrade: false }) {
+ let path = await downloadFile(addon.URL, options);
+ try {
+ await verifyFile(addon, path);
+ return path;
+ } catch (e) {
+ await IOUtils.remove(path);
+ throw e;
+ }
+ },
+};
+
+// For test use only.
+export const ProductAddonCheckerTestUtils = {
+ /**
+ * Used to override ServiceRequest calls with a mock request.
+ * @param mockRequest The mocked ServiceRequest object.
+ * @param callback Method called with the overridden ServiceRequest. The override
+ * is undone after the callback returns.
+ */
+ async overrideServiceRequest(mockRequest, callback) {
+ let originalServiceRequest = lazy.ServiceRequest;
+ lazy.ServiceRequest = function () {
+ return mockRequest;
+ };
+ try {
+ return await callback();
+ } finally {
+ lazy.ServiceRequest = originalServiceRequest;
+ }
+ },
+};
diff --git a/toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs b/toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs
new file mode 100644
index 0000000000..b23a96a5eb
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs
@@ -0,0 +1,663 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 { computeSha256HashAsString } from "resource://gre/modules/addons/crypto-utils.sys.mjs";
+import {
+ GATED_PERMISSIONS,
+ SITEPERMS_ADDON_PROVIDER_PREF,
+ SITEPERMS_ADDON_TYPE,
+ isGatedPermissionType,
+ isKnownPublicSuffix,
+ isPrincipalInSitePermissionsBlocklist,
+} from "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+});
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "addonsBundle",
+ () => new Localization(["toolkit/about/aboutAddons.ftl"], true)
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "SITEPERMS_ADDON_PROVIDER_ENABLED",
+ SITEPERMS_ADDON_PROVIDER_PREF,
+ false
+);
+
+const FIRST_CONTENT_PROCESS_TOPIC = "ipc:first-content-process-created";
+const SITEPERMS_ADDON_ID_SUFFIX = "@siteperms.mozilla.org";
+
+// Generate a per-session random salt, which is then used to generate
+// per-siteOrigin hashed strings used as the addon id in SitePermsAddonWrapper constructor
+// (expected to be matching new addon id generated for the same siteOrigin during
+// the same browsing session and different ones in new browsing sessions).
+//
+// NOTE: `generateSalt` is exported for testing purpose, should not be
+// used outside of tests.
+let SALT;
+export function generateSalt() {
+ // Throw if we're not in test and SALT is already defined
+ if (
+ typeof SALT !== "undefined" &&
+ !Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")
+ ) {
+ throw new Error("This should only be called from XPCShell tests");
+ }
+ SALT = crypto.getRandomValues(new Uint8Array(12)).join("");
+}
+
+function getSalt() {
+ if (!SALT) {
+ generateSalt();
+ }
+ return SALT;
+}
+
+class SitePermsAddonWrapper {
+ // An array of nsIPermission granted for the siteOrigin.
+ // We can't use a Set as handlePermissionChange might be called with different
+ // nsIPermission instance for the same permission (in the generic sense)
+ #permissions = [];
+
+ // This will be set to true in the `uninstall` method to recognize when a perm-changed notification
+ // is actually triggered by the SitePermsAddonWrapper uninstall method itself.
+ isUninstalling = false;
+
+ /**
+ * @param {string} siteOriginNoSuffix: The origin this addon is installed for
+ * WITHOUT the suffix generated from the
+ * origin attributes (see:
+ * nsIPrincipal.siteOriginNoSuffix).
+ * @param {Array<nsIPermission>} permissions: An array of the initial
+ * permissions the user granted
+ * for the addon origin.
+ */
+ constructor(siteOriginNoSuffix, permissions = []) {
+ this.siteOrigin = siteOriginNoSuffix;
+ this.principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ this.siteOrigin
+ );
+ // Use a template string for the concat in case `siteOrigin` isn't a string.
+ const saltedValue = `${this.siteOrigin}${getSalt()}`;
+ this.id = `${computeSha256HashAsString(
+ saltedValue
+ )}${SITEPERMS_ADDON_ID_SUFFIX}`;
+
+ for (const perm of permissions) {
+ this.#permissions.push(perm);
+ }
+ }
+
+ get isUninstalled() {
+ return this.#permissions.length === 0;
+ }
+
+ /**
+ * Returns the list of gated permissions types granted for the instance's origin
+ *
+ * @return {Array<String>}
+ */
+ get sitePermissions() {
+ return Array.from(new Set(this.#permissions.map(perm => perm.type)));
+ }
+
+ /**
+ * Update #permissions, and calls `uninstall` if there are no remaining gated permissions
+ * granted. This is called by SitePermsAddonProvider when it gets a "perm-changed" notification for a gated
+ * permission.
+ *
+ * @param {nsIPermission} permission: The permission being added/removed
+ * @param {String} action: The action perm-changed notifies us about
+ */
+ handlePermissionChange(permission, action) {
+ if (action == "added") {
+ this.#permissions.push(permission);
+ } else if (action == "deleted") {
+ // We want to remove the registered permission for the right principal (looking into originSuffix so we
+ // can unregister revoked permission on a specific context, private window, ...).
+ this.#permissions = this.#permissions.filter(
+ perm =>
+ !(
+ perm.type == permission.type &&
+ perm.principal.originSuffix === permission.principal.originSuffix
+ )
+ );
+
+ if (this.#permissions.length === 0) {
+ this.uninstall();
+ }
+ }
+ }
+
+ get type() {
+ return SITEPERMS_ADDON_TYPE;
+ }
+
+ get name() {
+ return lazy.addonsBundle.formatValueSync("addon-sitepermission-host", {
+ host: this.principal.host,
+ });
+ }
+
+ get creator() {}
+
+ get homepageURL() {}
+
+ get description() {}
+
+ get fullDescription() {}
+
+ get version() {
+ // We consider the previous implementation attempt (signed addons) to be the initial version,
+ // hence the 2.0 for this approach.
+ return "2.0";
+ }
+ get updateDate() {}
+
+ get isActive() {
+ return true;
+ }
+
+ get appDisabled() {
+ return false;
+ }
+
+ get userDisabled() {
+ return false;
+ }
+ set userDisabled(aVal) {}
+
+ get size() {
+ return 0;
+ }
+
+ async updateBlocklistState(options = {}) {}
+
+ get blocklistState() {
+ return Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ }
+
+ get scope() {
+ return lazy.AddonManager.SCOPE_APPLICATION;
+ }
+
+ get pendingOperations() {
+ return lazy.AddonManager.PENDING_NONE;
+ }
+
+ get operationsRequiringRestart() {
+ return lazy.AddonManager.OP_NEEDS_RESTART_NONE;
+ }
+
+ get permissions() {
+ // The addon only supports PERM_CAN_UNINSTALL and no other AOM permission.
+ return lazy.AddonManager.PERM_CAN_UNINSTALL;
+ }
+
+ get signedState() {
+ // Will make the permission prompt use the webextSitePerms.headerUnsignedWithPerms string
+ return lazy.AddonManager.SIGNEDSTATE_MISSING;
+ }
+
+ async enable() {}
+
+ async disable() {}
+
+ /**
+ * Uninstall the addon, calling AddonManager hooks and removing all granted permissions.
+ *
+ * @throws Services.perms.removeFromPrincipal could throw, see PermissionManager::AddInternal.
+ */
+ async uninstall() {
+ if (this.isUninstalling) {
+ return;
+ }
+ try {
+ this.isUninstalling = true;
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onUninstalling",
+ this,
+ false
+ );
+ const permissions = [...this.#permissions];
+ for (const permission of permissions) {
+ try {
+ Services.perms.removeFromPrincipal(
+ permission.principal,
+ permission.type
+ );
+ // Only remove the permission from the array if it was successfully removed from the principal
+ this.#permissions.splice(this.#permissions.indexOf(permission), 1);
+ } catch (err) {
+ Cu.reportError(err);
+ }
+ }
+ lazy.AddonManagerPrivate.callAddonListeners("onUninstalled", this);
+ } finally {
+ this.isUninstalling = false;
+ }
+ }
+
+ get isCompatible() {
+ return true;
+ }
+
+ get isPlatformCompatible() {
+ return true;
+ }
+
+ get providesUpdatesSecurely() {
+ return true;
+ }
+
+ get foreignInstall() {
+ return false;
+ }
+
+ get installTelemetryInfo() {
+ return { source: "siteperm-addon-provider", method: "synthetic-install" };
+ }
+
+ isCompatibleWith(aAppVersion, aPlatformVersion) {
+ return true;
+ }
+}
+
+class SitePermsAddonInstalling extends SitePermsAddonWrapper {
+ #install = null;
+
+ /**
+ * @param {string} siteOriginNoSuffix: The origin this addon is installed
+ * for, WITHOUT the suffix generated from
+ * the origin attributes (see:
+ * nsIPrincipal.siteOriginNoSuffix).
+ * @param {SitePermsAddonInstall} install: The SitePermsAddonInstall instance
+ * calling this constructor.
+ */
+ constructor(siteOriginNoSuffix, install) {
+ // SitePermsAddonWrapper expect an array of nsIPermission as its second parameter.
+ // Since we don't have a proper permission here, we pass an object with the properties
+ // being used in the class.
+ const permission = {
+ principal: install.principal,
+ type: install.newSitePerm,
+ };
+
+ super(siteOriginNoSuffix, [permission]);
+ this.#install = install;
+ }
+
+ get existingAddon() {
+ return SitePermsAddonProvider.wrappersMapByOrigin.get(this.siteOrigin);
+ }
+
+ uninstall() {
+ // While about:addons tab is already open, new addon cards for newly installed
+ // addons are created from the `onInstalled` AOM events, for the `SitePermsAddonWrapper`
+ // the `onInstalling` and `onInstalled` events are emitted by `SitePermsAddonInstall`
+ // and the addon instance is going to be a `SitePermsAddonInstalling` instance if
+ // there wasn't an AddonCard for the same addon id yet.
+ //
+ // To make sure that all permissions will be uninstalled if a user uninstall the
+ // addon from an AddonCard created from a `SitePermsAddonInstalling` instance,
+ // we forward calls to the uninstall method of the existing `SitePermsAddonWrapper`
+ // instance being tracked by the `SitePermsAddonProvider`.
+ // If there isn't any then removing only the single permission added along with the
+ // `SitePremsAddonInstalling` is going to be enough.
+ if (this.existingAddon) {
+ return this.existingAddon.uninstall();
+ }
+
+ return super.uninstall();
+ }
+
+ validInstallOrigins() {
+ // Always return true from here,
+ // actual checks are done from AddonManagerInternal.getSitePermsAddonInstallForWebpage
+ return true;
+ }
+}
+
+// Numeric id included in the install telemetry events to correlate multiple events related
+// to the same install or update flow.
+let nextInstallId = 0;
+
+class SitePermsAddonInstall {
+ #listeners = new Set();
+ #installEvents = {
+ INSTALL_CANCELLED: "onInstallCancelled",
+ INSTALL_ENDED: "onInstallEnded",
+ INSTALL_FAILED: "onInstallFailed",
+ };
+
+ /**
+ * @param {nsIPrincipal} installingPrincipal
+ * @param {String} sitePerm
+ */
+ constructor(installingPrincipal, sitePerm) {
+ this.principal = installingPrincipal;
+ this.newSitePerm = sitePerm;
+ this.state = lazy.AddonManager.STATE_DOWNLOADED;
+ this.addon = new SitePermsAddonInstalling(
+ this.principal.siteOriginNoSuffix,
+ this
+ );
+ this.installId = ++nextInstallId;
+ }
+
+ get installTelemetryInfo() {
+ return this.addon.installTelemetryInfo;
+ }
+
+ async checkPrompt() {
+ // `promptHandler` can be set from `AddonManagerInternal.setupPromptHandler`
+ if (this.promptHandler) {
+ let info = {
+ // TODO: Investigate if we need to handle addon "update", i.e. granting new
+ // gated permission on an origin other permissions were already granted for (Bug 1790778).
+ existingAddon: null,
+ addon: this.addon,
+ icon: "chrome://mozapps/skin/extensions/category-sitepermission.svg",
+ // Used in AMTelemetry to detect the install flow related to this prompt.
+ install: this,
+ };
+
+ try {
+ await this.promptHandler(info);
+ } catch (err) {
+ if (this.error < 0) {
+ this.state = lazy.AddonManager.STATE_INSTALL_FAILED;
+ // In some cases onOperationCancelled is called during failures
+ // to install/uninstall/enable/disable addons. We may need to
+ // do that here in the future.
+ this.#callInstallListeners(this.#installEvents.INSTALL_FAILED);
+ } else if (this.state !== lazy.AddonManager.STATE_CANCELLED) {
+ this.cancel();
+ }
+ return;
+ }
+ }
+
+ this.state = lazy.AddonManager.STATE_PROMPTS_DONE;
+ this.install();
+ }
+
+ install() {
+ if (this.state === lazy.AddonManager.STATE_PROMPTS_DONE) {
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalling", this.addon);
+ Services.perms.addFromPrincipal(
+ this.principal,
+ this.newSitePerm,
+ Services.perms.ALLOW_ACTION
+ );
+ this.state = lazy.AddonManager.STATE_INSTALLED;
+ this.#callInstallListeners(this.#installEvents.INSTALL_ENDED);
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalled", this.addon);
+ this.addon.install = null;
+ return;
+ }
+
+ if (this.state !== lazy.AddonManager.STATE_DOWNLOADED) {
+ this.state = lazy.AddonManager.STATE_INSTALL_FAILED;
+ this.#callInstallListeners(this.#installEvents.INSTALL_FAILED);
+ return;
+ }
+
+ this.checkPrompt();
+ }
+
+ cancel() {
+ // This method can be called if the install is already cancelled.
+ // We don't want to go further in such case as it would lead to duplicated Telemetry events.
+ if (this.state == lazy.AddonManager.STATE_CANCELLED) {
+ console.error("SitePermsAddonInstall#cancel called twice on ", this);
+ return;
+ }
+
+ this.state = lazy.AddonManager.STATE_CANCELLED;
+ this.#callInstallListeners(this.#installEvents.INSTALL_CANCELLED);
+ }
+
+ /**
+ * Add a listener for the install events
+ *
+ * @param {Object} listener
+ * @param {Function} [listener.onDownloadEnded]
+ * @param {Function} [listener.onInstallCancelled]
+ * @param {Function} [listener.onInstallEnded]
+ * @param {Function} [listener.onInstallFailed]
+ */
+ addListener(listener) {
+ this.#listeners.add(listener);
+ }
+
+ /**
+ * Remove a listener
+ *
+ * @param {Object} listener: The same object reference that was used for `addListener`
+ */
+ removeListener(listener) {
+ this.#listeners.delete(listener);
+ }
+
+ /**
+ * Call the listeners callbacks for a given event.
+ *
+ * @param {String} eventName: The event to fire. Should be one of `this.#installEvents`
+ */
+ #callInstallListeners(eventName) {
+ if (!Object.values(this.#installEvents).includes(eventName)) {
+ console.warn(`Unknown "${eventName}" "event`);
+ return;
+ }
+
+ lazy.AddonManagerPrivate.callInstallListeners(
+ eventName,
+ Array.from(this.#listeners),
+ this
+ );
+ }
+}
+
+const SitePermsAddonProvider = {
+ get name() {
+ return "SitePermsAddonProvider";
+ },
+
+ wrappersMapByOrigin: new Map(),
+
+ /**
+ * Update wrappersMapByOrigin on perm-changed
+ *
+ * @param {nsIPermission} permission: The permission being added/removed
+ * @param {String} action: The action perm-changed notifies us about
+ */
+ handlePermissionChange(permission, action = "added") {
+ // Bail out if it it's not a gated perm
+ if (!isGatedPermissionType(permission.type)) {
+ return;
+ }
+
+ // Gated APIs should probably not be available on non-secure origins,
+ // but let's double check here.
+ if (permission.principal.scheme !== "https") {
+ return;
+ }
+
+ if (isPrincipalInSitePermissionsBlocklist(permission.principal)) {
+ return;
+ }
+
+ const { siteOriginNoSuffix } = permission.principal;
+
+ // Install origin cannot be on a known etld (e.g. github.io).
+ // We shouldn't get a permission change for those here, but let's
+ // be extra safe
+ if (isKnownPublicSuffix(siteOriginNoSuffix)) {
+ return;
+ }
+
+ // Pipe the change to the existing addon if there is one.
+ if (this.wrappersMapByOrigin.has(siteOriginNoSuffix)) {
+ this.wrappersMapByOrigin
+ .get(siteOriginNoSuffix)
+ .handlePermissionChange(permission, action);
+ }
+
+ if (action == "added") {
+ // We only have one SitePermsAddon per origin, handling multiple permissions.
+ if (this.wrappersMapByOrigin.has(siteOriginNoSuffix)) {
+ return;
+ }
+
+ const addonWrapper = new SitePermsAddonWrapper(siteOriginNoSuffix, [
+ permission,
+ ]);
+ this.wrappersMapByOrigin.set(siteOriginNoSuffix, addonWrapper);
+ return;
+ }
+
+ if (action == "deleted") {
+ if (!this.wrappersMapByOrigin.has(siteOriginNoSuffix)) {
+ return;
+ }
+ // Only remove the addon if it doesn't have any permissions left.
+ if (!this.wrappersMapByOrigin.get(siteOriginNoSuffix).isUninstalled) {
+ return;
+ }
+ this.wrappersMapByOrigin.delete(siteOriginNoSuffix);
+ }
+ },
+
+ /**
+ * Returns a Promise that resolves when handled the list of gated permissions
+ * and setup ther observer for the "perm-changed" event.
+ *
+ * @returns Promise
+ */
+ lazyInit() {
+ if (!this._initPromise) {
+ this._initPromise = new Promise(resolve => {
+ // Build the initial list of addons per origin
+ const perms = Services.perms.getAllByTypes(GATED_PERMISSIONS);
+ for (const perm of perms) {
+ this.handlePermissionChange(perm);
+ }
+ Services.obs.addObserver(this, "perm-changed");
+ resolve();
+ });
+ }
+ return this._initPromise;
+ },
+
+ shutdown() {
+ if (this._initPromise) {
+ Services.obs.removeObserver(this, "perm-changed");
+ }
+ this.wrappersMapByOrigin.clear();
+ this._initPromise = null;
+ },
+
+ /**
+ * Get a SitePermsAddonWrapper from an extension id
+ *
+ * @param {String|null|undefined} id: The extension id,
+ * @returns {SitePermsAddonWrapper|undefined}
+ */
+ async getAddonByID(id) {
+ await this.lazyInit();
+ if (!id?.endsWith?.(SITEPERMS_ADDON_ID_SUFFIX)) {
+ return undefined;
+ }
+
+ for (const addon of this.wrappersMapByOrigin.values()) {
+ if (addon.id === id) {
+ return addon;
+ }
+ }
+ return undefined;
+ },
+
+ /**
+ * Get a list of SitePermsAddonWrapper for a given list of extension types.
+ *
+ * @param {Array<String>|null|undefined} types: If null or undefined is passed,
+ * the callsites expect to get all the addons from the provider, without
+ * any filtering.
+ * @returns {Array<SitePermsAddonWrapper>}
+ */
+ async getAddonsByTypes(types) {
+ if (
+ !this.isEnabled ||
+ // `types` can be null/undefined, and in such case we _do_ want to return the addons.
+ (Array.isArray(types) && !types.includes(SITEPERMS_ADDON_TYPE))
+ ) {
+ return [];
+ }
+
+ await this.lazyInit();
+ return Array.from(this.wrappersMapByOrigin.values());
+ },
+
+ /**
+ * Create and return a SitePermsAddonInstall instance for a permission on a given principal
+ *
+ * @param {nsIPrincipal} installingPrincipal
+ * @param {String} sitePerm
+ * @returns {SitePermsAddonInstall}
+ */
+ getSitePermsAddonInstallForWebpage(installingPrincipal, sitePerm) {
+ return new SitePermsAddonInstall(installingPrincipal, sitePerm);
+ },
+
+ get isEnabled() {
+ return lazy.SITEPERMS_ADDON_PROVIDER_ENABLED;
+ },
+
+ observe(subject, topic, data) {
+ if (!this.isEnabled) {
+ return;
+ }
+
+ if (topic == FIRST_CONTENT_PROCESS_TOPIC) {
+ Services.obs.removeObserver(this, FIRST_CONTENT_PROCESS_TOPIC);
+
+ lazy.AddonManagerPrivate.registerProvider(SitePermsAddonProvider, [
+ SITEPERMS_ADDON_TYPE,
+ ]);
+ Services.obs.notifyObservers(null, "sitepermsaddon-provider-registered");
+ } else if (topic === "perm-changed") {
+ if (data === "cleared") {
+ // In such case, `subject` is null, but we can simply uninstall all existing addons.
+ for (const addon of this.wrappersMapByOrigin.values()) {
+ addon.uninstall();
+ }
+ this.wrappersMapByOrigin.clear();
+ return;
+ }
+
+ const perm = subject.QueryInterface(Ci.nsIPermission);
+ this.handlePermissionChange(perm, data);
+ }
+ },
+
+ addFirstContentProcessObserver() {
+ Services.obs.addObserver(this, FIRST_CONTENT_PROCESS_TOPIC);
+ },
+};
+
+// We want to register the SitePermsAddonProvider once the first content process gets created
+// (and only if the feature is also enabled through the "dom.sitepermsaddon-provider.enabled"
+// about:config pref).
+SitePermsAddonProvider.addFirstContentProcessObserver();
diff --git a/toolkit/mozapps/extensions/internal/XPIDatabase.jsm b/toolkit/mozapps/extensions/internal/XPIDatabase.jsm
new file mode 100644
index 0000000000..03186dad43
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/XPIDatabase.jsm
@@ -0,0 +1,3790 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * This file contains most of the logic required to maintain the
+ * extensions database, including querying and modifying extension
+ * metadata. In general, we try to avoid loading it during startup when
+ * at all possible. Please keep that in mind when deciding whether to
+ * add code here or elsewhere.
+ */
+
+/* eslint "valid-jsdoc": [2, {requireReturn: false, requireReturnDescription: false, prefer: {return: "returns"}}] */
+
+var EXPORTED_SYMBOLS = [
+ "AddonInternal",
+ "BuiltInThemesHelpers",
+ "XPIDatabase",
+ "XPIDatabaseReconcile",
+];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ ThirdPartyUtil: ["@mozilla.org/thirdpartyutil;1", "mozIThirdPartyUtil"],
+});
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
+ AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs",
+ Blocklist: "resource://gre/modules/Blocklist.sys.mjs",
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ ExtensionData: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs",
+ PermissionsUtils: "resource://gre/modules/PermissionsUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ UpdateChecker: "resource://gre/modules/addons/XPIInstall.jsm",
+ XPIInstall: "resource://gre/modules/addons/XPIInstall.jsm",
+ XPIInternal: "resource://gre/modules/addons/XPIProvider.jsm",
+ XPIProvider: "resource://gre/modules/addons/XPIProvider.jsm",
+ verifyBundleSignedState: "resource://gre/modules/addons/XPIInstall.jsm",
+});
+
+// WARNING: BuiltInThemes.sys.mjs may be provided by the host application (e.g.
+// Firefox), or it might not exist at all. Use with caution, as we don't
+// want things to completely fail if that module can't be loaded.
+XPCOMUtils.defineLazyGetter(lazy, "BuiltInThemes", () => {
+ try {
+ let { BuiltInThemes } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemes.sys.mjs"
+ );
+ return BuiltInThemes;
+ } catch (e) {
+ Cu.reportError(`Unable to load BuiltInThemes.sys.mjs: ${e}`);
+ }
+ return undefined;
+});
+
+// A set of helpers to account from a single place that in some builds
+// (e.g. GeckoView and Thunderbird) the BuiltInThemes module may either
+// not be bundled at all or not be exposing the same methods provided
+// by the module as defined in Firefox Desktop.
+const BuiltInThemesHelpers = {
+ getLocalizedColorwayGroupName(addonId) {
+ return lazy.BuiltInThemes?.getLocalizedColorwayGroupName?.(addonId);
+ },
+
+ getLocalizedColorwayDescription(addonId) {
+ return lazy.BuiltInThemes?.getLocalizedColorwayGroupDescription?.(addonId);
+ },
+
+ isActiveTheme(addonId) {
+ return lazy.BuiltInThemes?.isActiveTheme?.(addonId);
+ },
+
+ isRetainedExpiredTheme(addonId) {
+ return lazy.BuiltInThemes?.isRetainedExpiredTheme?.(addonId);
+ },
+
+ themeIsExpired(addonId) {
+ return lazy.BuiltInThemes?.themeIsExpired?.(addonId);
+ },
+
+ // Helper function called form XPInstall.jsm to remove from the retained themes
+ // list the built-in colorways theme that have been migrated to a non built-in.
+ unretainMigratedColorwayTheme(addonId) {
+ lazy.BuiltInThemes?.unretainMigratedColorwayTheme?.(addonId);
+ },
+};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ BuiltInThemesHelpers,
+ "isColorwayMigrationEnabled",
+ "browser.theme.colorway-migration",
+ false
+);
+
+const { nsIBlocklistService } = Ci;
+
+const { Log } = ChromeUtils.importESModule(
+ "resource://gre/modules/Log.sys.mjs"
+);
+const LOGGER_ID = "addons.xpi-utils";
+
+const nsIFile = Components.Constructor(
+ "@mozilla.org/file/local;1",
+ "nsIFile",
+ "initWithPath"
+);
+
+// Create a new logger for use by the Addons XPI Provider Utils
+// (Requires AddonManager.jsm)
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+const KEY_PROFILEDIR = "ProfD";
+const FILE_JSON_DB = "extensions.json";
+
+const PREF_DB_SCHEMA = "extensions.databaseSchema";
+const PREF_EM_AUTO_DISABLED_SCOPES = "extensions.autoDisableScopes";
+const PREF_PENDING_OPERATIONS = "extensions.pendingOperations";
+const PREF_XPI_PERMISSIONS_BRANCH = "xpinstall.";
+const PREF_XPI_SIGNATURES_DEV_ROOT = "xpinstall.signatures.dev-root";
+
+const TOOLKIT_ID = "toolkit@mozilla.org";
+
+const KEY_APP_SYSTEM_ADDONS = "app-system-addons";
+const KEY_APP_SYSTEM_DEFAULTS = "app-system-defaults";
+const KEY_APP_SYSTEM_PROFILE = "app-system-profile";
+const KEY_APP_BUILTINS = "app-builtin";
+const KEY_APP_SYSTEM_LOCAL = "app-system-local";
+const KEY_APP_SYSTEM_SHARE = "app-system-share";
+const KEY_APP_GLOBAL = "app-global";
+const KEY_APP_PROFILE = "app-profile";
+const KEY_APP_TEMPORARY = "app-temporary";
+
+const DEFAULT_THEME_ID = "default-theme@mozilla.org";
+
+// Properties to cache and reload when an addon installation is pending
+const PENDING_INSTALL_METADATA = [
+ "syncGUID",
+ "targetApplications",
+ "userDisabled",
+ "softDisabled",
+ "embedderDisabled",
+ "sourceURI",
+ "releaseNotesURI",
+ "installDate",
+ "updateDate",
+ "applyBackgroundUpdates",
+ "installTelemetryInfo",
+];
+
+// Properties to save in JSON file
+const PROP_JSON_FIELDS = [
+ "id",
+ "syncGUID",
+ "version",
+ "type",
+ "loader",
+ "updateURL",
+ "installOrigins",
+ "manifestVersion",
+ "optionsURL",
+ "optionsType",
+ "optionsBrowserStyle",
+ "aboutURL",
+ "defaultLocale",
+ "visible",
+ "active",
+ "userDisabled",
+ "appDisabled",
+ "embedderDisabled",
+ "pendingUninstall",
+ "installDate",
+ "updateDate",
+ "applyBackgroundUpdates",
+ "path",
+ "skinnable",
+ "sourceURI",
+ "releaseNotesURI",
+ "softDisabled",
+ "foreignInstall",
+ "strictCompatibility",
+ "locales",
+ "targetApplications",
+ "targetPlatforms",
+ "signedState",
+ "signedDate",
+ "seen",
+ "dependencies",
+ "incognito",
+ "userPermissions",
+ "optionalPermissions",
+ "sitePermissions",
+ "siteOrigin",
+ "icons",
+ "iconURL",
+ "blocklistState",
+ "blocklistURL",
+ "startupData",
+ "previewImage",
+ "hidden",
+ "installTelemetryInfo",
+ "recommendationState",
+ "rootURI",
+];
+
+const SIGNED_TYPES = new Set([
+ "extension",
+ "locale",
+ "theme",
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ "sitepermission-deprecated",
+]);
+
+// Time to wait before async save of XPI JSON database, in milliseconds
+const ASYNC_SAVE_DELAY_MS = 20;
+
+const l10n = new Localization(["browser/appExtensionFields.ftl"], true);
+
+/**
+ * Schedules an idle task, and returns a promise which resolves to an
+ * IdleDeadline when an idle slice is available. The caller should
+ * perform all of its idle work in the same micro-task, before the
+ * deadline is reached.
+ *
+ * @returns {Promise<IdleDeadline>}
+ */
+function promiseIdleSlice() {
+ return new Promise(resolve => {
+ ChromeUtils.idleDispatch(resolve);
+ });
+}
+
+let arrayForEach = Function.call.bind(Array.prototype.forEach);
+
+/**
+ * Loops over the given array, in the same way as Array forEach, but
+ * splitting the work among idle tasks.
+ *
+ * @param {Array} array
+ * The array to loop over.
+ * @param {function} func
+ * The function to call on each array element.
+ * @param {integer} [taskTimeMS = 5]
+ * The minimum time to allocate to each task. If less time than
+ * this is available in a given idle slice, and there are more
+ * elements to loop over, they will be deferred until the next
+ * idle slice.
+ */
+async function idleForEach(array, func, taskTimeMS = 5) {
+ let deadline;
+ for (let i = 0; i < array.length; i++) {
+ if (!deadline || deadline.timeRemaining() < taskTimeMS) {
+ deadline = await promiseIdleSlice();
+ }
+ func(array[i], i);
+ }
+}
+
+/**
+ * Asynchronously fill in the _repositoryAddon field for one addon
+ *
+ * @param {AddonInternal} aAddon
+ * The add-on to annotate.
+ * @returns {AddonInternal}
+ * The annotated add-on.
+ */
+async function getRepositoryAddon(aAddon) {
+ if (aAddon) {
+ aAddon._repositoryAddon = await lazy.AddonRepository.getCachedAddonByID(
+ aAddon.id
+ );
+ }
+ return aAddon;
+}
+
+/**
+ * Copies properties from one object to another. If no target object is passed
+ * a new object will be created and returned.
+ *
+ * @param {object} aObject
+ * An object to copy from
+ * @param {string[]} aProperties
+ * An array of properties to be copied
+ * @param {object?} [aTarget]
+ * An optional target object to copy the properties to
+ * @returns {Object}
+ * The object that the properties were copied onto
+ */
+function copyProperties(aObject, aProperties, aTarget) {
+ if (!aTarget) {
+ aTarget = {};
+ }
+ aProperties.forEach(function (aProp) {
+ if (aProp in aObject) {
+ aTarget[aProp] = aObject[aProp];
+ }
+ });
+ return aTarget;
+}
+
+// Maps instances of AddonInternal to AddonWrapper
+const wrapperMap = new WeakMap();
+let addonFor = wrapper => wrapperMap.get(wrapper);
+
+const EMPTY_ARRAY = Object.freeze([]);
+
+let AddonWrapper;
+
+/**
+ * The AddonInternal is an internal only representation of add-ons. It
+ * may have come from the database or an extension manifest.
+ */
+class AddonInternal {
+ constructor(addonData) {
+ this._wrapper = null;
+ this._selectedLocale = null;
+ this.active = false;
+ this.visible = false;
+ this.userDisabled = false;
+ this.appDisabled = false;
+ this.softDisabled = false;
+ this.embedderDisabled = false;
+ this.blocklistState = Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ this.blocklistURL = null;
+ this.sourceURI = null;
+ this.releaseNotesURI = null;
+ this.foreignInstall = false;
+ this.seen = true;
+ this.skinnable = false;
+ this.startupData = null;
+ this._hidden = false;
+ this.installTelemetryInfo = null;
+ this.rootURI = null;
+ this._updateInstall = null;
+ this.recommendationState = null;
+
+ this.inDatabase = false;
+
+ /**
+ * @property {Array<string>} dependencies
+ * An array of bootstrapped add-on IDs on which this add-on depends.
+ * The add-on will remain appDisabled if any of the dependent
+ * add-ons is not installed and enabled.
+ */
+ this.dependencies = EMPTY_ARRAY;
+
+ if (addonData) {
+ copyProperties(addonData, PROP_JSON_FIELDS, this);
+ this.location = addonData.location;
+
+ if (!this.dependencies) {
+ this.dependencies = [];
+ }
+ Object.freeze(this.dependencies);
+
+ if (this.location) {
+ this.addedToDatabase();
+ }
+
+ this.sourceBundle = addonData._sourceBundle;
+ }
+ }
+
+ get sourceBundle() {
+ return this._sourceBundle;
+ }
+
+ set sourceBundle(file) {
+ this._sourceBundle = file;
+ if (file) {
+ this.rootURI = lazy.XPIInternal.getURIForResourceInFile(file, "").spec;
+ }
+ }
+
+ get wrapper() {
+ if (!this._wrapper) {
+ this._wrapper = new AddonWrapper(this);
+ }
+ return this._wrapper;
+ }
+
+ get resolvedRootURI() {
+ return lazy.XPIInternal.maybeResolveURI(Services.io.newURI(this.rootURI));
+ }
+
+ get isBuiltinColorwayTheme() {
+ return (
+ this.type === "theme" &&
+ this.location.isBuiltin &&
+ this.id.endsWith("-colorway@mozilla.org")
+ );
+ }
+
+ /**
+ * Validate a list of origins are contained in the installOrigins array (defined in manifest.json).
+ *
+ * SitePermission addons are a special case, where the triggering install site may be a subdomain
+ * of a valid xpi origin.
+ *
+ * @param {Object} origins Object containing URIs related to install.
+ * @params {nsIURI} origins.installFrom The nsIURI of the website that has triggered the install flow.
+ * @params {nsIURI} origins.source The nsIURI where the xpi is hosted.
+ * @returns {boolean}
+ */
+ validInstallOrigins({ installFrom, source }) {
+ if (
+ !Services.prefs.getBoolPref("extensions.install_origins.enabled", true)
+ ) {
+ return true;
+ }
+
+ let { installOrigins, manifestVersion } = this;
+ if (!installOrigins) {
+ // Install origins are mandatory in MV3 and optional
+ // in MV2. Old addons need to keep installing per the
+ // old install flow.
+ return manifestVersion < 3;
+ }
+ // An empty install_origins prevents any install from 3rd party websites.
+ if (!installOrigins.length) {
+ return false;
+ }
+
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ if (this.type == "sitepermission-deprecated") {
+ // NOTE: This may move into a check for all addons later.
+ for (let origin of installOrigins) {
+ let host = new URL(origin).host;
+ // install_origin cannot be on a known etld (e.g. github.io).
+ if (Services.eTLD.getKnownPublicSuffixFromHost(host) == host) {
+ logger.warn(
+ `Addon ${this.id} Installation not allowed from the install_origin ${host} that is an eTLD`
+ );
+ return false;
+ }
+ }
+
+ if (!installOrigins.includes(new URL(source.spec).origin)) {
+ logger.warn(
+ `Addon ${this.id} Installation not allowed, "${source.spec}" is not included in the Addon install_origins`
+ );
+ return false;
+ }
+
+ if (lazy.ThirdPartyUtil.isThirdPartyURI(source, installFrom)) {
+ logger.warn(
+ `Addon ${this.id} Installation not allowed, installFrom "${installFrom.spec}" is third party to the Addon install_origins`
+ );
+ return false;
+ }
+
+ return true;
+ }
+
+ for (const [name, uri] of Object.entries({ installFrom, source })) {
+ if (!installOrigins.includes(new URL(uri.spec).origin)) {
+ logger.warn(
+ `Addon ${this.id} Installation not allowed, ${name} "${uri.spec}" is not included in the Addon install_origins`
+ );
+ return false;
+ }
+ }
+ return true;
+ }
+
+ addedToDatabase() {
+ this._key = `${this.location.name}:${this.id}`;
+ this.inDatabase = true;
+ }
+
+ get isWebExtension() {
+ return this.loader == null;
+ }
+
+ get selectedLocale() {
+ if (this._selectedLocale) {
+ return this._selectedLocale;
+ }
+
+ /**
+ * this.locales is a list of objects that have property `locales`.
+ * It's value is an array of locale codes.
+ *
+ * First, we reduce this nested structure to a flat list of locale codes.
+ */
+ const locales = [].concat(...this.locales.map(loc => loc.locales));
+
+ let requestedLocales = Services.locale.requestedLocales;
+
+ /**
+ * If en-US is not in the list, add it as the last fallback.
+ */
+ if (!requestedLocales.includes("en-US")) {
+ requestedLocales.push("en-US");
+ }
+
+ /**
+ * Then we negotiate best locale code matching the app locales.
+ */
+ let bestLocale = Services.locale.negotiateLanguages(
+ requestedLocales,
+ locales,
+ "und",
+ Services.locale.langNegStrategyLookup
+ )[0];
+
+ /**
+ * If no match has been found, we'll assign the default locale as
+ * the selected one.
+ */
+ if (bestLocale === "und") {
+ this._selectedLocale = this.defaultLocale;
+ } else {
+ /**
+ * Otherwise, we'll go through all locale entries looking for the one
+ * that has the best match in it's locales list.
+ */
+ this._selectedLocale = this.locales.find(loc =>
+ loc.locales.includes(bestLocale)
+ );
+ }
+
+ return this._selectedLocale;
+ }
+
+ get providesUpdatesSecurely() {
+ return !this.updateURL || this.updateURL.startsWith("https:");
+ }
+
+ get isCorrectlySigned() {
+ switch (this.location.name) {
+ case KEY_APP_SYSTEM_PROFILE:
+ // Add-ons installed via Normandy must be signed by the system
+ // key or the "Mozilla Extensions" key.
+ return [
+ lazy.AddonManager.SIGNEDSTATE_SYSTEM,
+ lazy.AddonManager.SIGNEDSTATE_PRIVILEGED,
+ ].includes(this.signedState);
+ case KEY_APP_SYSTEM_ADDONS:
+ // System add-ons must be signed by the system key.
+ return this.signedState == lazy.AddonManager.SIGNEDSTATE_SYSTEM;
+
+ case KEY_APP_SYSTEM_DEFAULTS:
+ case KEY_APP_BUILTINS:
+ case KEY_APP_TEMPORARY:
+ // Temporary and built-in add-ons do not require signing.
+ return true;
+
+ case KEY_APP_SYSTEM_SHARE:
+ case KEY_APP_SYSTEM_LOCAL:
+ // On UNIX platforms except OSX, an additional location for system
+ // add-ons exists in /usr/{lib,share}/mozilla/extensions. Add-ons
+ // installed there do not require signing.
+ if (Services.appinfo.OS != "Darwin") {
+ return true;
+ }
+ break;
+ }
+
+ if (this.signedState === lazy.AddonManager.SIGNEDSTATE_NOT_REQUIRED) {
+ return true;
+ }
+ return this.signedState > lazy.AddonManager.SIGNEDSTATE_MISSING;
+ }
+
+ get isCompatible() {
+ return this.isCompatibleWith();
+ }
+
+ get isPrivileged() {
+ return lazy.ExtensionData.getIsPrivileged({
+ signedState: this.signedState,
+ builtIn: this.location.isBuiltin,
+ temporarilyInstalled: this.location.isTemporary,
+ });
+ }
+
+ get hidden() {
+ return (
+ this.location.hidden ||
+ // The hidden flag is intended to only be used for features that are part
+ // of the application. Temporary add-ons should not be hidden.
+ (this._hidden && this.isPrivileged && !this.location.isTemporary) ||
+ false
+ );
+ }
+
+ set hidden(val) {
+ this._hidden = val;
+ }
+
+ get disabled() {
+ return (
+ this.userDisabled ||
+ this.appDisabled ||
+ this.softDisabled ||
+ this.embedderDisabled
+ );
+ }
+
+ get isPlatformCompatible() {
+ if (!this.targetPlatforms.length) {
+ return true;
+ }
+
+ let matchedOS = false;
+
+ // If any targetPlatform matches the OS and contains an ABI then we will
+ // only match a targetPlatform that contains both the current OS and ABI
+ let needsABI = false;
+
+ // Some platforms do not specify an ABI, test against null in that case.
+ let abi = null;
+ try {
+ abi = Services.appinfo.XPCOMABI;
+ } catch (e) {}
+
+ // Something is causing errors in here
+ try {
+ for (let platform of this.targetPlatforms) {
+ if (platform.os == Services.appinfo.OS) {
+ if (platform.abi) {
+ needsABI = true;
+ if (platform.abi === abi) {
+ return true;
+ }
+ } else {
+ matchedOS = true;
+ }
+ }
+ }
+ } catch (e) {
+ let message =
+ "Problem with addon " +
+ this.id +
+ " targetPlatforms " +
+ JSON.stringify(this.targetPlatforms);
+ logger.error(message, e);
+ lazy.AddonManagerPrivate.recordException("XPI", message, e);
+ // don't trust this add-on
+ return false;
+ }
+
+ return matchedOS && !needsABI;
+ }
+
+ isCompatibleWith(aAppVersion, aPlatformVersion) {
+ let app = this.matchingTargetApplication;
+ if (!app) {
+ return false;
+ }
+
+ // set reasonable defaults for minVersion and maxVersion
+ let minVersion = app.minVersion || "0";
+ let maxVersion = app.maxVersion || "*";
+
+ if (!aAppVersion) {
+ aAppVersion = Services.appinfo.version;
+ }
+ if (!aPlatformVersion) {
+ aPlatformVersion = Services.appinfo.platformVersion;
+ }
+
+ let version;
+ if (app.id == Services.appinfo.ID) {
+ version = aAppVersion;
+ } else if (app.id == TOOLKIT_ID) {
+ version = aPlatformVersion;
+ }
+
+ // Only extensions and dictionaries can be compatible by default; themes
+ // and language packs always use strict compatibility checking.
+ // Dictionaries are compatible by default unless requested by the dictinary.
+ if (
+ !this.strictCompatibility &&
+ (!lazy.AddonManager.strictCompatibility || this.type == "dictionary")
+ ) {
+ return Services.vc.compare(version, minVersion) >= 0;
+ }
+
+ return (
+ Services.vc.compare(version, minVersion) >= 0 &&
+ Services.vc.compare(version, maxVersion) <= 0
+ );
+ }
+
+ get matchingTargetApplication() {
+ let app = null;
+ for (let targetApp of this.targetApplications) {
+ if (targetApp.id == Services.appinfo.ID) {
+ return targetApp;
+ }
+ if (targetApp.id == TOOLKIT_ID) {
+ app = targetApp;
+ }
+ }
+ return app;
+ }
+
+ async findBlocklistEntry() {
+ return lazy.Blocklist.getAddonBlocklistEntry(this.wrapper);
+ }
+
+ async updateBlocklistState(options = {}) {
+ if (this.location.isSystem || this.location.isBuiltin) {
+ return;
+ }
+
+ let { applySoftBlock = true, updateDatabase = true } = options;
+
+ let oldState = this.blocklistState;
+
+ let entry = await this.findBlocklistEntry();
+ let newState = entry ? entry.state : Services.blocklist.STATE_NOT_BLOCKED;
+
+ this.blocklistState = newState;
+ this.blocklistURL = entry && entry.url;
+
+ let userDisabled, softDisabled;
+ // After a blocklist update, the blocklist service manually applies
+ // new soft blocks after displaying a UI, in which cases we need to
+ // skip updating it here.
+ if (applySoftBlock && oldState != newState) {
+ if (newState == Services.blocklist.STATE_SOFTBLOCKED) {
+ if (this.type == "theme") {
+ userDisabled = true;
+ } else {
+ softDisabled = !this.userDisabled;
+ }
+ } else {
+ softDisabled = false;
+ }
+ }
+
+ if (this.inDatabase && updateDatabase) {
+ await XPIDatabase.updateAddonDisabledState(this, {
+ userDisabled,
+ softDisabled,
+ });
+ XPIDatabase.saveChanges();
+ } else {
+ this.appDisabled = !XPIDatabase.isUsableAddon(this);
+ if (userDisabled !== undefined) {
+ this.userDisabled = userDisabled;
+ }
+ if (softDisabled !== undefined) {
+ this.softDisabled = softDisabled;
+ }
+ }
+ }
+
+ recordAddonBlockChangeTelemetry(reason) {
+ lazy.Blocklist.recordAddonBlockChangeTelemetry(this.wrapper, reason);
+ }
+
+ async setUserDisabled(val, allowSystemAddons = false) {
+ if (val == (this.userDisabled || this.softDisabled)) {
+ return;
+ }
+
+ if (this.inDatabase) {
+ // System add-ons should not be user disabled, as there is no UI to
+ // re-enable them.
+ if (this.location.isSystem && !allowSystemAddons) {
+ throw new Error(`Cannot disable system add-on ${this.id}`);
+ }
+ await XPIDatabase.updateAddonDisabledState(this, { userDisabled: val });
+ } else {
+ this.userDisabled = val;
+ // When enabling remove the softDisabled flag
+ if (!val) {
+ this.softDisabled = false;
+ }
+ }
+ }
+
+ applyCompatibilityUpdate(aUpdate, aSyncCompatibility) {
+ let wasCompatible = this.isCompatible;
+
+ for (let targetApp of this.targetApplications) {
+ for (let updateTarget of aUpdate.targetApplications) {
+ if (
+ targetApp.id == updateTarget.id &&
+ (aSyncCompatibility ||
+ Services.vc.compare(targetApp.maxVersion, updateTarget.maxVersion) <
+ 0)
+ ) {
+ targetApp.minVersion = updateTarget.minVersion;
+ targetApp.maxVersion = updateTarget.maxVersion;
+
+ if (this.inDatabase) {
+ XPIDatabase.saveChanges();
+ }
+ }
+ }
+ }
+
+ if (wasCompatible != this.isCompatible) {
+ if (this.inDatabase) {
+ XPIDatabase.updateAddonDisabledState(this);
+ } else {
+ this.appDisabled = !XPIDatabase.isUsableAddon(this);
+ }
+ }
+ }
+
+ toJSON() {
+ let obj = copyProperties(this, PROP_JSON_FIELDS);
+ obj.location = this.location.name;
+ return obj;
+ }
+
+ /**
+ * When an add-on install is pending its metadata will be cached in a file.
+ * This method reads particular properties of that metadata that may be newer
+ * than that in the extension manifest, like compatibility information.
+ *
+ * @param {Object} aObj
+ * A JS object containing the cached metadata
+ */
+ importMetadata(aObj) {
+ for (let prop of PENDING_INSTALL_METADATA) {
+ if (!(prop in aObj)) {
+ continue;
+ }
+
+ this[prop] = aObj[prop];
+ }
+
+ // Compatibility info may have changed so update appDisabled
+ this.appDisabled = !XPIDatabase.isUsableAddon(this);
+ }
+
+ permissions() {
+ let permissions = 0;
+
+ // Add-ons that aren't installed cannot be modified in any way
+ if (!this.inDatabase) {
+ return permissions;
+ }
+
+ if (!this.appDisabled) {
+ if (this.userDisabled || this.softDisabled) {
+ permissions |= lazy.AddonManager.PERM_CAN_ENABLE;
+ } else if (this.type != "theme" || this.id != DEFAULT_THEME_ID) {
+ // We do not expose disabling the default theme.
+ permissions |= lazy.AddonManager.PERM_CAN_DISABLE;
+ }
+ }
+
+ // Add-ons that are in locked install locations, or are pending uninstall
+ // cannot be uninstalled or upgraded. One caveat is extensions sideloaded
+ // from non-profile locations. Since Firefox 73(?), new sideloaded extensions
+ // from outside the profile have not been installed so any such extensions
+ // must be from an older profile. Users may uninstall such an extension which
+ // removes the related state from this profile but leaves the actual file alone
+ // (since it is outside this profile and may be in use in other profiles)
+ let changesAllowed = !this.location.locked && !this.pendingUninstall;
+ if (changesAllowed) {
+ // System add-on upgrades are triggered through a different mechanism (see updateSystemAddons())
+ // Builtin addons are only upgraded with Firefox (or app) updates.
+ let isSystem = this.location.isSystem || this.location.isBuiltin;
+ // Add-ons that are installed by a file link cannot be upgraded.
+ if (!isSystem && !this.location.isLinkedAddon(this.id)) {
+ permissions |= lazy.AddonManager.PERM_CAN_UPGRADE;
+ }
+ // Allow active and retained colorways builtin themes to be updated to the same theme hosted on AMO
+ // (the PERM_CAN_UPGRADE permission will ensure we will be asking AMO for an update,
+ // then the AMO addon xpi will be installed in the profile location, overridden in
+ // the `createUpdate` defined in `XPIInstall.jsm` and called from `UpdateChecker`
+ // `onUpdateCheckComplete` method).
+ if (
+ this.isBuiltinColorwayTheme &&
+ BuiltInThemesHelpers.isColorwayMigrationEnabled &&
+ BuiltInThemesHelpers.themeIsExpired(this.id) &&
+ (BuiltInThemesHelpers.isActiveTheme(this.id) ||
+ BuiltInThemesHelpers.isRetainedExpiredTheme(this.id))
+ ) {
+ permissions |= lazy.AddonManager.PERM_CAN_UPGRADE;
+ }
+ }
+
+ // We allow uninstall of legacy sideloaded extensions, even when in locked locations,
+ // but we do not remove the addon file in that case.
+ let isLegacySideload =
+ this.foreignInstall &&
+ !(this.location.scope & lazy.AddonSettings.SCOPES_SIDELOAD);
+ if (changesAllowed || isLegacySideload) {
+ permissions |= lazy.AddonManager.PERM_API_CAN_UNINSTALL;
+ if (!this.location.isBuiltin) {
+ permissions |= lazy.AddonManager.PERM_CAN_UNINSTALL;
+ }
+ }
+
+ // The permission to "toggle the private browsing access" is locked down
+ // when the extension has opted out or it gets the permission automatically
+ // on every extension startup (as system, privileged and builtin addons).
+ if (
+ (this.type === "extension" ||
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ this.type == "sitepermission-deprecated") &&
+ this.incognito !== "not_allowed" &&
+ this.signedState !== lazy.AddonManager.SIGNEDSTATE_PRIVILEGED &&
+ this.signedState !== lazy.AddonManager.SIGNEDSTATE_SYSTEM &&
+ !this.location.isBuiltin
+ ) {
+ permissions |= lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS;
+ }
+
+ if (Services.policies) {
+ if (!Services.policies.isAllowed(`uninstall-extension:${this.id}`)) {
+ permissions &= ~lazy.AddonManager.PERM_CAN_UNINSTALL;
+ }
+ if (!Services.policies.isAllowed(`disable-extension:${this.id}`)) {
+ permissions &= ~lazy.AddonManager.PERM_CAN_DISABLE;
+ }
+ if (Services.policies.getExtensionSettings(this.id)?.updates_disabled) {
+ permissions &= ~lazy.AddonManager.PERM_CAN_UPGRADE;
+ }
+ }
+
+ return permissions;
+ }
+
+ propagateDisabledState(oldAddon) {
+ if (oldAddon) {
+ this.userDisabled = oldAddon.userDisabled;
+ this.embedderDisabled = oldAddon.embedderDisabled;
+ this.softDisabled = oldAddon.softDisabled;
+ this.blocklistState = oldAddon.blocklistState;
+ }
+ }
+}
+
+/**
+ * The AddonWrapper wraps an Addon to provide the data visible to consumers of
+ * the public API.
+ *
+ * NOTE: Do not add any new logic here. Add it to AddonInternal and expose
+ * through defineAddonWrapperProperty after this class definition.
+ *
+ * @param {AddonInternal} aAddon
+ * The add-on object to wrap.
+ */
+AddonWrapper = class {
+ constructor(aAddon) {
+ wrapperMap.set(this, aAddon);
+ }
+
+ get __AddonInternal__() {
+ return addonFor(this);
+ }
+
+ get seen() {
+ return addonFor(this).seen;
+ }
+
+ markAsSeen() {
+ addonFor(this).seen = true;
+ XPIDatabase.saveChanges();
+ }
+
+ get installTelemetryInfo() {
+ const addon = addonFor(this);
+ if (!addon.installTelemetryInfo && addon.location) {
+ if (addon.location.isSystem) {
+ return { source: "system-addon" };
+ }
+
+ if (addon.location.isTemporary) {
+ return { source: "temporary-addon" };
+ }
+ }
+
+ return addon.installTelemetryInfo;
+ }
+
+ get temporarilyInstalled() {
+ return addonFor(this).location.isTemporary;
+ }
+
+ get aboutURL() {
+ return this.isActive ? addonFor(this).aboutURL : null;
+ }
+
+ get optionsURL() {
+ if (!this.isActive) {
+ return null;
+ }
+
+ let addon = addonFor(this);
+ if (addon.optionsURL) {
+ if (this.isWebExtension) {
+ // The internal object's optionsURL property comes from the addons
+ // DB and should be a relative URL. However, extensions with
+ // options pages installed before bug 1293721 was fixed got absolute
+ // URLs in the addons db. This code handles both cases.
+ let policy = WebExtensionPolicy.getByID(addon.id);
+ if (!policy) {
+ return null;
+ }
+ let base = policy.getURL();
+ return new URL(addon.optionsURL, base).href;
+ }
+ return addon.optionsURL;
+ }
+
+ return null;
+ }
+
+ get optionsType() {
+ if (!this.isActive) {
+ return null;
+ }
+
+ let addon = addonFor(this);
+ let hasOptionsURL = !!this.optionsURL;
+
+ if (addon.optionsType) {
+ switch (parseInt(addon.optionsType, 10)) {
+ case lazy.AddonManager.OPTIONS_TYPE_TAB:
+ case lazy.AddonManager.OPTIONS_TYPE_INLINE_BROWSER:
+ return hasOptionsURL ? addon.optionsType : null;
+ }
+ return null;
+ }
+
+ return null;
+ }
+
+ get optionsBrowserStyle() {
+ let addon = addonFor(this);
+ return addon.optionsBrowserStyle;
+ }
+
+ get incognito() {
+ return addonFor(this).incognito;
+ }
+
+ async getBlocklistURL() {
+ return addonFor(this).blocklistURL;
+ }
+
+ get iconURL() {
+ return lazy.AddonManager.getPreferredIconURL(this, 48);
+ }
+
+ get icons() {
+ let addon = addonFor(this);
+ let icons = {};
+
+ if (addon._repositoryAddon) {
+ for (let size in addon._repositoryAddon.icons) {
+ icons[size] = addon._repositoryAddon.icons[size];
+ }
+ }
+
+ if (addon.icons) {
+ for (let size in addon.icons) {
+ let path = addon.icons[size].replace(/^\//, "");
+ icons[size] = this.getResourceURI(path).spec;
+ }
+ }
+
+ let canUseIconURLs = this.isActive;
+ if (canUseIconURLs && addon.iconURL) {
+ icons[32] = addon.iconURL;
+ icons[48] = addon.iconURL;
+ }
+
+ Object.freeze(icons);
+ return icons;
+ }
+
+ get screenshots() {
+ let addon = addonFor(this);
+ let repositoryAddon = addon._repositoryAddon;
+ if (repositoryAddon && "screenshots" in repositoryAddon) {
+ let repositoryScreenshots = repositoryAddon.screenshots;
+ if (repositoryScreenshots && repositoryScreenshots.length) {
+ return repositoryScreenshots;
+ }
+ }
+
+ if (addon.previewImage) {
+ let url = this.getResourceURI(addon.previewImage).spec;
+ return [new lazy.AddonManagerPrivate.AddonScreenshot(url)];
+ }
+
+ return null;
+ }
+
+ get recommendationStates() {
+ let addon = addonFor(this);
+ let state = addon.recommendationState;
+ if (
+ state &&
+ state.validNotBefore < addon.updateDate &&
+ state.validNotAfter > addon.updateDate &&
+ addon.isCorrectlySigned &&
+ !this.temporarilyInstalled
+ ) {
+ return state.states;
+ }
+ return [];
+ }
+
+ // NOTE: this boolean getter doesn't return true for all recommendation
+ // states at the moment. For the states actually supported on the autograph
+ // side see:
+ // https://github.com/mozilla-services/autograph/blob/8a34847a/autograph.yaml#L1456-L1460
+ get isRecommended() {
+ return this.recommendationStates.includes("recommended");
+ }
+
+ get canBypassThirdParyInstallPrompt() {
+ // We only bypass if the extension is signed (to support distributions
+ // that turn off the signing requirement) and has recommendation states,
+ // or the extension is signed as privileged.
+ return (
+ this.signedState == lazy.AddonManager.SIGNEDSTATE_PRIVILEGED ||
+ (this.signedState >= lazy.AddonManager.SIGNEDSTATE_SIGNED &&
+ this.recommendationStates.length)
+ );
+ }
+
+ get applyBackgroundUpdates() {
+ return addonFor(this).applyBackgroundUpdates;
+ }
+ set applyBackgroundUpdates(val) {
+ let addon = addonFor(this);
+ if (
+ val != lazy.AddonManager.AUTOUPDATE_DEFAULT &&
+ val != lazy.AddonManager.AUTOUPDATE_DISABLE &&
+ val != lazy.AddonManager.AUTOUPDATE_ENABLE
+ ) {
+ val = val
+ ? lazy.AddonManager.AUTOUPDATE_DEFAULT
+ : lazy.AddonManager.AUTOUPDATE_DISABLE;
+ }
+
+ if (val == addon.applyBackgroundUpdates) {
+ return;
+ }
+
+ XPIDatabase.setAddonProperties(addon, {
+ applyBackgroundUpdates: val,
+ });
+ lazy.AddonManagerPrivate.callAddonListeners("onPropertyChanged", this, [
+ "applyBackgroundUpdates",
+ ]);
+ }
+
+ set syncGUID(val) {
+ let addon = addonFor(this);
+ if (addon.syncGUID == val) {
+ return;
+ }
+
+ if (addon.inDatabase) {
+ XPIDatabase.setAddonSyncGUID(addon, val);
+ }
+
+ addon.syncGUID = val;
+ }
+
+ get install() {
+ let addon = addonFor(this);
+ if (!("_install" in addon) || !addon._install) {
+ return null;
+ }
+ return addon._install.wrapper;
+ }
+
+ get updateInstall() {
+ let addon = addonFor(this);
+ return addon._updateInstall ? addon._updateInstall.wrapper : null;
+ }
+
+ get pendingUpgrade() {
+ let addon = addonFor(this);
+ return addon.pendingUpgrade ? addon.pendingUpgrade.wrapper : null;
+ }
+
+ get scope() {
+ let addon = addonFor(this);
+ if (addon.location) {
+ return addon.location.scope;
+ }
+
+ return lazy.AddonManager.SCOPE_PROFILE;
+ }
+
+ get pendingOperations() {
+ let addon = addonFor(this);
+ let pending = 0;
+ if (!addon.inDatabase) {
+ // Add-on is pending install if there is no associated install (shouldn't
+ // happen here) or if the install is in the process of or has successfully
+ // completed the install. If an add-on is pending install then we ignore
+ // any other pending operations.
+ if (
+ !addon._install ||
+ addon._install.state == lazy.AddonManager.STATE_INSTALLING ||
+ addon._install.state == lazy.AddonManager.STATE_INSTALLED
+ ) {
+ return lazy.AddonManager.PENDING_INSTALL;
+ }
+ } else if (addon.pendingUninstall) {
+ // If an add-on is pending uninstall then we ignore any other pending
+ // operations
+ return lazy.AddonManager.PENDING_UNINSTALL;
+ }
+
+ if (addon.active && addon.disabled) {
+ pending |= lazy.AddonManager.PENDING_DISABLE;
+ } else if (!addon.active && !addon.disabled) {
+ pending |= lazy.AddonManager.PENDING_ENABLE;
+ }
+
+ if (addon.pendingUpgrade) {
+ pending |= lazy.AddonManager.PENDING_UPGRADE;
+ }
+
+ return pending;
+ }
+
+ get operationsRequiringRestart() {
+ return 0;
+ }
+
+ get isDebuggable() {
+ return this.isActive;
+ }
+
+ get permissions() {
+ return addonFor(this).permissions();
+ }
+
+ get isActive() {
+ let addon = addonFor(this);
+ if (!addon.active) {
+ return false;
+ }
+ if (!Services.appinfo.inSafeMode) {
+ return true;
+ }
+ return lazy.XPIInternal.canRunInSafeMode(addon);
+ }
+
+ get startupPromise() {
+ let addon = addonFor(this);
+ if (!this.isActive) {
+ return null;
+ }
+
+ let activeAddon = lazy.XPIProvider.activeAddons.get(addon.id);
+ if (activeAddon) {
+ return activeAddon.startupPromise || null;
+ }
+ return null;
+ }
+
+ updateBlocklistState(applySoftBlock = true) {
+ return addonFor(this).updateBlocklistState({ applySoftBlock });
+ }
+
+ get userDisabled() {
+ let addon = addonFor(this);
+ return addon.softDisabled || addon.userDisabled;
+ }
+
+ /**
+ * Get the embedderDisabled property for this addon.
+ *
+ * This is intended for embedders of Gecko like GeckoView apps to control
+ * which addons are usable on their app.
+ *
+ * @returns {boolean}
+ */
+ get embedderDisabled() {
+ if (!lazy.AddonSettings.IS_EMBEDDED) {
+ return undefined;
+ }
+
+ return addonFor(this).embedderDisabled;
+ }
+
+ /**
+ * Set the embedderDisabled property for this addon.
+ *
+ * This is intended for embedders of Gecko like GeckoView apps to control
+ * which addons are usable on their app.
+ *
+ * Embedders can disable addons for various reasons, e.g. the addon is not
+ * compatible with their implementation of the WebExtension API.
+ *
+ * When an addon is embedderDisabled it will behave like it was appDisabled.
+ *
+ * @param {boolean} val
+ * whether this addon should be embedder disabled or not.
+ */
+ async setEmbedderDisabled(val) {
+ if (!lazy.AddonSettings.IS_EMBEDDED) {
+ throw new Error("Setting embedder disabled while not embedding.");
+ }
+
+ let addon = addonFor(this);
+ if (addon.embedderDisabled == val) {
+ return val;
+ }
+
+ if (addon.inDatabase) {
+ await XPIDatabase.updateAddonDisabledState(addon, {
+ embedderDisabled: val,
+ });
+ } else {
+ addon.embedderDisabled = val;
+ }
+
+ return val;
+ }
+
+ enable(options = {}) {
+ const { allowSystemAddons = false } = options;
+ return addonFor(this).setUserDisabled(false, allowSystemAddons);
+ }
+
+ disable(options = {}) {
+ const { allowSystemAddons = false } = options;
+ return addonFor(this).setUserDisabled(true, allowSystemAddons);
+ }
+
+ async setSoftDisabled(val) {
+ let addon = addonFor(this);
+ if (val == addon.softDisabled) {
+ return val;
+ }
+
+ if (addon.inDatabase) {
+ // When softDisabling a theme just enable the active theme
+ if (addon.type === "theme" && val && !addon.userDisabled) {
+ if (addon.isWebExtension) {
+ await XPIDatabase.updateAddonDisabledState(addon, {
+ softDisabled: val,
+ });
+ }
+ } else {
+ await XPIDatabase.updateAddonDisabledState(addon, {
+ softDisabled: val,
+ });
+ }
+ } else if (!addon.userDisabled) {
+ // Only set softDisabled if not already disabled
+ addon.softDisabled = val;
+ }
+
+ return val;
+ }
+
+ get isPrivileged() {
+ return addonFor(this).isPrivileged;
+ }
+
+ get hidden() {
+ return addonFor(this).hidden;
+ }
+
+ get isSystem() {
+ let addon = addonFor(this);
+ return addon.location.isSystem;
+ }
+
+ get isBuiltin() {
+ return addonFor(this).location.isBuiltin;
+ }
+
+ // Returns true if Firefox Sync should sync this addon. Only addons
+ // in the profile install location are considered syncable.
+ get isSyncable() {
+ let addon = addonFor(this);
+ return addon.location.name == KEY_APP_PROFILE;
+ }
+
+ get userPermissions() {
+ return addonFor(this).userPermissions;
+ }
+
+ get optionalPermissions() {
+ return addonFor(this).optionalPermissions;
+ }
+
+ isCompatibleWith(aAppVersion, aPlatformVersion) {
+ return addonFor(this).isCompatibleWith(aAppVersion, aPlatformVersion);
+ }
+
+ async uninstall(alwaysAllowUndo) {
+ let addon = addonFor(this);
+ return lazy.XPIInstall.uninstallAddon(addon, alwaysAllowUndo);
+ }
+
+ cancelUninstall() {
+ let addon = addonFor(this);
+ lazy.XPIInstall.cancelUninstallAddon(addon);
+ }
+
+ findUpdates(aListener, aReason, aAppVersion, aPlatformVersion) {
+ new lazy.UpdateChecker(
+ addonFor(this),
+ aListener,
+ aReason,
+ aAppVersion,
+ aPlatformVersion
+ );
+ }
+
+ // Returns true if there was an update in progress, false if there was no update to cancel
+ cancelUpdate() {
+ let addon = addonFor(this);
+ if (addon._updateCheck) {
+ addon._updateCheck.cancel();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Reloads the add-on.
+ *
+ * For temporarily installed add-ons, this uninstalls and re-installs the
+ * add-on. Otherwise, the addon is disabled and then re-enabled, and the cache
+ * is flushed.
+ */
+ async reload() {
+ const addon = addonFor(this);
+
+ logger.debug(`reloading add-on ${addon.id}`);
+
+ if (!this.temporarilyInstalled) {
+ await XPIDatabase.updateAddonDisabledState(addon, { userDisabled: true });
+ await XPIDatabase.updateAddonDisabledState(addon, {
+ userDisabled: false,
+ });
+ } else {
+ // This function supports re-installing an existing add-on.
+ await lazy.AddonManager.installTemporaryAddon(addon._sourceBundle);
+ }
+ }
+
+ /**
+ * Returns a URI to the selected resource or to the add-on bundle if aPath
+ * is null. URIs to the bundle will always be file: URIs. URIs to resources
+ * will be file: URIs if the add-on is unpacked or jar: URIs if the add-on is
+ * still an XPI file.
+ *
+ * @param {string?} aPath
+ * The path in the add-on to get the URI for or null to get a URI to
+ * the file or directory the add-on is installed as.
+ * @returns {nsIURI}
+ */
+ getResourceURI(aPath) {
+ let addon = addonFor(this);
+ let url = Services.io.newURI(addon.rootURI);
+ if (aPath) {
+ if (aPath.startsWith("/")) {
+ throw new Error("getResourceURI() must receive a relative path");
+ }
+ url = Services.io.newURI(aPath, null, url);
+ }
+ return url;
+ }
+};
+
+function chooseValue(aAddon, aObj, aProp) {
+ let repositoryAddon = aAddon._repositoryAddon;
+ let objValue = aObj[aProp];
+
+ if (
+ repositoryAddon &&
+ aProp in repositoryAddon &&
+ (aProp === "creator" || objValue == null)
+ ) {
+ return [repositoryAddon[aProp], true];
+ }
+
+ return [objValue, false];
+}
+
+function defineAddonWrapperProperty(name, getter) {
+ Object.defineProperty(AddonWrapper.prototype, name, {
+ get: getter,
+ enumerable: true,
+ });
+}
+
+[
+ "id",
+ "syncGUID",
+ "version",
+ "type",
+ "isWebExtension",
+ "isCompatible",
+ "isPlatformCompatible",
+ "providesUpdatesSecurely",
+ "blocklistState",
+ "appDisabled",
+ "softDisabled",
+ "skinnable",
+ "foreignInstall",
+ "strictCompatibility",
+ "updateURL",
+ "installOrigins",
+ "manifestVersion",
+ "validInstallOrigins",
+ "dependencies",
+ "signedState",
+ "sitePermissions",
+ "siteOrigin",
+ "isCorrectlySigned",
+ "isBuiltinColorwayTheme",
+].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+ return aProp in addon ? addon[aProp] : undefined;
+ });
+});
+
+[
+ "fullDescription",
+ "developerComments",
+ "supportURL",
+ "contributionURL",
+ "averageRating",
+ "reviewCount",
+ "reviewURL",
+ "weeklyDownloads",
+].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+ if (addon._repositoryAddon) {
+ return addon._repositoryAddon[aProp];
+ }
+
+ return null;
+ });
+});
+
+["installDate", "updateDate"].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+ // installDate is always set, updateDate is sometimes missing.
+ return new Date(addon[aProp] ?? addon.installDate);
+ });
+});
+
+defineAddonWrapperProperty("signedDate", function () {
+ let addon = addonFor(this);
+ let { signedDate } = addon;
+ if (signedDate != null) {
+ return new Date(signedDate);
+ }
+ return null;
+});
+
+["sourceURI", "releaseNotesURI"].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+
+ // Temporary Installed Addons do not have a "sourceURI",
+ // But we can use the "_sourceBundle" as an alternative,
+ // which points to the path of the addon xpi installed
+ // or its source dir (if it has been installed from a
+ // directory).
+ if (aProp == "sourceURI" && this.temporarilyInstalled) {
+ return Services.io.newFileURI(addon._sourceBundle);
+ }
+
+ let [target, fromRepo] = chooseValue(addon, addon, aProp);
+ if (!target) {
+ return null;
+ }
+ if (fromRepo) {
+ return target;
+ }
+ return Services.io.newURI(target);
+ });
+});
+
+// Add to this Map if you need to change an addon's Fluent ID. Keep it in sync
+// with the list in browser_verify_l10n_strings.js
+const updatedAddonFluentIds = new Map([
+ ["extension-default-theme-name", "extension-default-theme-name-auto"],
+]);
+
+["name", "description", "creator", "homepageURL"].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+
+ let formattedMessage;
+ // We want to make sure that all built-in themes that are localizable can
+ // actually localized, particularly those for thunderbird and desktop.
+ if (
+ (aProp === "name" || aProp === "description") &&
+ addon.location.name === KEY_APP_BUILTINS &&
+ addon.type === "theme"
+ ) {
+ // Built-in themes are localized with Fluent instead of the WebExtension API.
+ let addonIdPrefix = addon.id.replace("@mozilla.org", "");
+ const colorwaySuffix = "colorway";
+ if (addonIdPrefix.endsWith(colorwaySuffix)) {
+ // FIXME: Depending on BuiltInThemes here is sort of a hack. Bug 1733466
+ // would provide a more generalized way of doing this.
+ if (aProp == "description") {
+ return BuiltInThemesHelpers.getLocalizedColorwayDescription(addon.id);
+ }
+ // Colorway collections are usually divided into and presented as
+ // "groups". A group either contains closely related colorways, e.g.
+ // stemming from the same base color but with different intensities, or
+ // if the current collection doesn't have intensities, each colorway is
+ // their own group. Colorway names combine the group name with an
+ // intensity. Their ids have the format
+ // {colorwayGroup}-{intensity}-colorway@mozilla.org or
+ // {colorwayGroupName}-colorway@mozilla.org). L10n for colorway group
+ // names is optional and falls back on the unlocalized name from the
+ // theme's manifest. The intensity part, if present, must be localized.
+ let localizedColorwayGroupName =
+ BuiltInThemesHelpers.getLocalizedColorwayGroupName(addon.id);
+ let [colorwayGroupName, intensity] = addonIdPrefix.split("-", 2);
+ if (intensity == colorwaySuffix) {
+ // This theme doesn't have an intensity.
+ return localizedColorwayGroupName || addon.defaultLocale.name;
+ }
+ // We're not using toLocaleUpperCase because these color names are
+ // always in English.
+ colorwayGroupName =
+ localizedColorwayGroupName ||
+ colorwayGroupName[0].toUpperCase() + colorwayGroupName.slice(1);
+ let defaultFluentId = `extension-colorways-${intensity}-name`;
+ let fluentId =
+ updatedAddonFluentIds.get(defaultFluentId) || defaultFluentId;
+ [formattedMessage] = l10n.formatMessagesSync([
+ {
+ id: fluentId,
+ args: {
+ "colorway-name": colorwayGroupName,
+ },
+ },
+ ]);
+ } else {
+ let defaultFluentId = `extension-${addonIdPrefix}-${aProp}`;
+ let fluentId =
+ updatedAddonFluentIds.get(defaultFluentId) || defaultFluentId;
+ [formattedMessage] = l10n.formatMessagesSync([{ id: fluentId }]);
+ }
+
+ return formattedMessage.value;
+ }
+
+ let [result, usedRepository] = chooseValue(
+ addon,
+ addon.selectedLocale,
+ aProp
+ );
+
+ if (result == null) {
+ // Legacy add-ons may be partially localized. Fall back to the default
+ // locale ensure that the result is a string where possible.
+ [result, usedRepository] = chooseValue(addon, addon.defaultLocale, aProp);
+ }
+
+ if (result && !usedRepository && aProp == "creator") {
+ return new lazy.AddonManagerPrivate.AddonAuthor(result);
+ }
+
+ return result;
+ });
+});
+
+["developers", "translators", "contributors"].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+
+ let [results, usedRepository] = chooseValue(
+ addon,
+ addon.selectedLocale,
+ aProp
+ );
+
+ if (results && !usedRepository) {
+ results = results.map(function (aResult) {
+ return new lazy.AddonManagerPrivate.AddonAuthor(aResult);
+ });
+ }
+
+ return results;
+ });
+});
+
+/**
+ * @typedef {Map<string, AddonInternal>} AddonDB
+ */
+
+/**
+ * Internal interface: find an addon from an already loaded addonDB.
+ *
+ * @param {AddonDB} addonDB
+ * The add-on database.
+ * @param {function(AddonInternal) : boolean} aFilter
+ * The filter predecate. The first add-on for which it returns
+ * true will be returned.
+ * @returns {AddonInternal?}
+ * The first matching add-on, if one is found.
+ */
+function _findAddon(addonDB, aFilter) {
+ for (let addon of addonDB.values()) {
+ if (aFilter(addon)) {
+ return addon;
+ }
+ }
+ return null;
+}
+
+/**
+ * Internal interface to get a filtered list of addons from a loaded addonDB
+ *
+ * @param {AddonDB} addonDB
+ * The add-on database.
+ * @param {function(AddonInternal) : boolean} aFilter
+ * The filter predecate. Add-ons which match this predicate will
+ * be returned.
+ * @returns {Array<AddonInternal>}
+ * The list of matching add-ons.
+ */
+function _filterDB(addonDB, aFilter) {
+ return Array.from(addonDB.values()).filter(aFilter);
+}
+
+const XPIDatabase = {
+ // true if the database connection has been opened
+ initialized: false,
+ // The database file
+ jsonFile: FileUtils.getFile(KEY_PROFILEDIR, [FILE_JSON_DB], true),
+ rebuildingDatabase: false,
+ syncLoadingDB: false,
+ // Add-ons from the database in locations which are no longer
+ // supported.
+ orphanedAddons: [],
+
+ _saveTask: null,
+
+ // Saved error object if we fail to read an existing database
+ _loadError: null,
+
+ // Saved error object if we fail to save the database
+ _saveError: null,
+
+ // Error reported by our most recent attempt to read or write the database, if any
+ get lastError() {
+ if (this._loadError) {
+ return this._loadError;
+ }
+ if (this._saveError) {
+ return this._saveError;
+ }
+ return null;
+ },
+
+ async _saveNow() {
+ try {
+ let path = this.jsonFile.path;
+ await IOUtils.writeJSON(path, this, { tmpPath: `${path}.tmp` });
+
+ if (!this._schemaVersionSet) {
+ // Update the XPIDB schema version preference the first time we
+ // successfully save the database.
+ logger.debug(
+ "XPI Database saved, setting schema version preference to " +
+ lazy.XPIInternal.DB_SCHEMA
+ );
+ Services.prefs.setIntPref(PREF_DB_SCHEMA, lazy.XPIInternal.DB_SCHEMA);
+ this._schemaVersionSet = true;
+
+ // Reading the DB worked once, so we don't need the load error
+ this._loadError = null;
+ }
+ } catch (error) {
+ logger.warn("Failed to save XPI database", error);
+ this._saveError = error;
+
+ if (!DOMException.isInstance(error) || error.name !== "AbortError") {
+ throw error;
+ }
+ }
+ },
+
+ /**
+ * Mark the current stored data dirty, and schedule a flush to disk
+ */
+ saveChanges() {
+ if (!this.initialized) {
+ throw new Error("Attempt to use XPI database when it is not initialized");
+ }
+
+ if (lazy.XPIProvider._closing) {
+ // use an Error here so we get a stack trace.
+ let err = new Error("XPI database modified after shutdown began");
+ logger.warn(err);
+ lazy.AddonManagerPrivate.recordSimpleMeasure(
+ "XPIDB_late_stack",
+ Log.stackTrace(err)
+ );
+ }
+
+ if (!this._saveTask) {
+ this._saveTask = new lazy.DeferredTask(
+ () => this._saveNow(),
+ ASYNC_SAVE_DELAY_MS
+ );
+ }
+
+ this._saveTask.arm();
+ },
+
+ async finalize() {
+ // handle the "in memory only" and "saveChanges never called" cases
+ if (!this._saveTask) {
+ return;
+ }
+
+ await this._saveTask.finalize();
+ },
+
+ /**
+ * Converts the current internal state of the XPI addon database to
+ * a JSON.stringify()-ready structure
+ *
+ * @returns {Object}
+ */
+ toJSON() {
+ if (!this.addonDB) {
+ // We never loaded the database?
+ throw new Error("Attempt to save database without loading it first");
+ }
+
+ let toSave = {
+ schemaVersion: lazy.XPIInternal.DB_SCHEMA,
+ addons: Array.from(this.addonDB.values()).filter(
+ addon => !addon.location.isTemporary
+ ),
+ };
+ return toSave;
+ },
+
+ /**
+ * Synchronously loads the database, by running the normal async load
+ * operation with idle dispatch disabled, and spinning the event loop
+ * until it finishes.
+ *
+ * @param {boolean} aRebuildOnError
+ * A boolean indicating whether add-on information should be loaded
+ * from the install locations if the database needs to be rebuilt.
+ * (if false, caller is XPIProvider.checkForChanges() which will rebuild)
+ */
+ syncLoadDB(aRebuildOnError) {
+ let err = new Error("Synchronously loading the add-ons database");
+ logger.debug(err.message);
+ lazy.AddonManagerPrivate.recordSimpleMeasure(
+ "XPIDB_sync_stack",
+ Log.stackTrace(err)
+ );
+ try {
+ this.syncLoadingDB = true;
+ lazy.XPIInternal.awaitPromise(this.asyncLoadDB(aRebuildOnError));
+ } finally {
+ this.syncLoadingDB = false;
+ }
+ },
+
+ _recordStartupError(reason) {
+ lazy.AddonManagerPrivate.recordSimpleMeasure("XPIDB_startupError", reason);
+ },
+
+ /**
+ * Parse loaded data, reconstructing the database if the loaded data is not valid
+ *
+ * @param {object} aInputAddons
+ * The add-on JSON to parse.
+ * @param {boolean} aRebuildOnError
+ * If true, synchronously reconstruct the database from installed add-ons
+ */
+ async parseDB(aInputAddons, aRebuildOnError) {
+ try {
+ let parseTimer = lazy.AddonManagerPrivate.simpleTimer("XPIDB_parseDB_MS");
+
+ if (!("schemaVersion" in aInputAddons) || !("addons" in aInputAddons)) {
+ let error = new Error("Bad JSON file contents");
+ error.rebuildReason = "XPIDB_rebuildBadJSON_MS";
+ throw error;
+ }
+
+ if (aInputAddons.schemaVersion <= 27) {
+ // Types were translated in bug 857456.
+ for (let addon of aInputAddons.addons) {
+ lazy.XPIInternal.migrateAddonLoader(addon);
+ }
+ } else if (aInputAddons.schemaVersion != lazy.XPIInternal.DB_SCHEMA) {
+ // For now, we assume compatibility for JSON data with a
+ // mismatched schema version, though we throw away any fields we
+ // don't know about (bug 902956)
+ this._recordStartupError(
+ `schemaMismatch-${aInputAddons.schemaVersion}`
+ );
+ logger.debug(
+ `JSON schema mismatch: expected ${lazy.XPIInternal.DB_SCHEMA}, actual ${aInputAddons.schemaVersion}`
+ );
+ }
+
+ let forEach = this.syncLoadingDB ? arrayForEach : idleForEach;
+
+ // If we got here, we probably have good data
+ // Make AddonInternal instances from the loaded data and save them
+ let addonDB = new Map();
+ await forEach(aInputAddons.addons, loadedAddon => {
+ if (loadedAddon.path) {
+ try {
+ loadedAddon._sourceBundle = new nsIFile(loadedAddon.path);
+ } catch (e) {
+ // We can fail here when the path is invalid, usually from the
+ // wrong OS
+ logger.warn(
+ "Could not find source bundle for add-on " + loadedAddon.id,
+ e
+ );
+ }
+ }
+ loadedAddon.location = lazy.XPIInternal.XPIStates.getLocation(
+ loadedAddon.location
+ );
+
+ let newAddon = new AddonInternal(loadedAddon);
+ if (loadedAddon.location) {
+ addonDB.set(newAddon._key, newAddon);
+ } else {
+ this.orphanedAddons.push(newAddon);
+ }
+ });
+
+ parseTimer.done();
+ this.addonDB = addonDB;
+ logger.debug("Successfully read XPI database");
+ this.initialized = true;
+ } catch (e) {
+ if (e.name == "SyntaxError") {
+ logger.error("Syntax error parsing saved XPI JSON data");
+ this._recordStartupError("syntax");
+ } else {
+ logger.error("Failed to load XPI JSON data from profile", e);
+ this._recordStartupError("other");
+ }
+
+ this.timeRebuildDatabase(
+ e.rebuildReason || "XPIDB_rebuildReadFailed_MS",
+ aRebuildOnError
+ );
+ }
+ },
+
+ async maybeIdleDispatch() {
+ if (!this.syncLoadingDB) {
+ await promiseIdleSlice();
+ }
+ },
+
+ /**
+ * Open and read the XPI database asynchronously, upgrading if
+ * necessary. If any DB load operation fails, we need to
+ * synchronously rebuild the DB from the installed extensions.
+ *
+ * @param {boolean} [aRebuildOnError = true]
+ * A boolean indicating whether add-on information should be loaded
+ * from the install locations if the database needs to be rebuilt.
+ * (if false, caller is XPIProvider.checkForChanges() which will rebuild)
+ * @returns {Promise<AddonDB>}
+ * Resolves to the Map of loaded JSON data stored in
+ * this.addonDB; rejects in case of shutdown.
+ */
+ asyncLoadDB(aRebuildOnError = true) {
+ // Already started (and possibly finished) loading
+ if (this._dbPromise) {
+ return this._dbPromise;
+ }
+
+ if (lazy.XPIProvider._closing) {
+ // use an Error here so we get a stack trace.
+ let err = new Error(
+ "XPIDatabase.asyncLoadDB attempt after XPIProvider shutdown."
+ );
+ logger.warn("Fail to load AddonDB: ${error}", { error: err });
+ lazy.AddonManagerPrivate.recordSimpleMeasure(
+ "XPIDB_late_load",
+ Log.stackTrace(err)
+ );
+ this._dbPromise = Promise.reject(err);
+
+ lazy.XPIInternal.resolveDBReady(this._dbPromise);
+
+ return this._dbPromise;
+ }
+
+ logger.debug(`Starting async load of XPI database ${this.jsonFile.path}`);
+ this._dbPromise = (async () => {
+ try {
+ let json = await IOUtils.readJSON(this.jsonFile.path);
+
+ logger.debug("Finished async read of XPI database, parsing...");
+ await this.maybeIdleDispatch();
+ await this.parseDB(json, true);
+ } catch (error) {
+ if (DOMException.isInstance(error) && error.name === "NotFoundError") {
+ if (Services.prefs.getIntPref(PREF_DB_SCHEMA, 0)) {
+ this._recordStartupError("dbMissing");
+ }
+ } else {
+ logger.warn(
+ `Extensions database ${this.jsonFile.path} exists but is not readable; rebuilding`,
+ error
+ );
+ this._loadError = error;
+ }
+ this.timeRebuildDatabase(
+ "XPIDB_rebuildUnreadableDB_MS",
+ aRebuildOnError
+ );
+ }
+ return this.addonDB;
+ })();
+
+ lazy.XPIInternal.resolveDBReady(this._dbPromise);
+
+ return this._dbPromise;
+ },
+
+ timeRebuildDatabase(timerName, rebuildOnError) {
+ lazy.AddonManagerPrivate.recordTiming(timerName, () => {
+ return this.rebuildDatabase(rebuildOnError);
+ });
+ },
+
+ /**
+ * Rebuild the database from addon install directories.
+ *
+ * @param {boolean} aRebuildOnError
+ * A boolean indicating whether add-on information should be loaded
+ * from the install locations if the database needs to be rebuilt.
+ * (if false, caller is XPIProvider.checkForChanges() which will rebuild)
+ */
+ rebuildDatabase(aRebuildOnError) {
+ this.addonDB = new Map();
+ this.initialized = true;
+
+ if (lazy.XPIInternal.XPIStates.size == 0) {
+ // No extensions installed, so we're done
+ logger.debug("Rebuilding XPI database with no extensions");
+ return;
+ }
+
+ this.rebuildingDatabase = !!aRebuildOnError;
+
+ if (aRebuildOnError) {
+ logger.warn("Rebuilding add-ons database from installed extensions.");
+ try {
+ XPIDatabaseReconcile.processFileChanges({}, false);
+ } catch (e) {
+ logger.error(
+ "Failed to rebuild XPI database from installed extensions",
+ e
+ );
+ }
+ // Make sure to update the active add-ons and add-ons list on shutdown
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
+ }
+ },
+
+ /**
+ * Shuts down the database connection and releases all cached objects.
+ * Return: Promise{integer} resolves / rejects with the result of the DB
+ * flush after the database is flushed and
+ * all cleanup is done
+ */
+ async shutdown() {
+ logger.debug("shutdown");
+ if (this.initialized) {
+ // If our last database I/O had an error, try one last time to save.
+ if (this.lastError) {
+ this.saveChanges();
+ }
+
+ this.initialized = false;
+
+ // If we're shutting down while still loading, finish loading
+ // before everything else!
+ if (this._dbPromise) {
+ await this._dbPromise;
+ }
+
+ // Await any pending DB writes and finish cleaning up.
+ await this.finalize();
+
+ if (this._saveError) {
+ // If our last attempt to read or write the DB failed, force a new
+ // extensions.ini to be written to disk on the next startup
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
+ }
+
+ // Clear out the cached addons data loaded from JSON
+ delete this.addonDB;
+ delete this._dbPromise;
+ // same for the deferred save
+ delete this._saveTask;
+ // re-enable the schema version setter
+ delete this._schemaVersionSet;
+ }
+ },
+
+ /**
+ * Verifies that all installed add-ons are still correctly signed.
+ */
+ async verifySignatures() {
+ try {
+ let addons = await this.getAddonList(a => true);
+
+ let changes = {
+ enabled: [],
+ disabled: [],
+ };
+
+ for (let addon of addons) {
+ // The add-on might have vanished, we'll catch that on the next startup
+ if (!addon._sourceBundle || !addon._sourceBundle.exists()) {
+ continue;
+ }
+
+ let signedState = await lazy.verifyBundleSignedState(
+ addon._sourceBundle,
+ addon
+ );
+
+ if (signedState != addon.signedState) {
+ addon.signedState = signedState;
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onPropertyChanged",
+ addon.wrapper,
+ ["signedState"]
+ );
+ }
+
+ let disabled = await this.updateAddonDisabledState(addon);
+ if (disabled !== undefined) {
+ changes[disabled ? "disabled" : "enabled"].push(addon.id);
+ }
+ }
+
+ this.saveChanges();
+
+ Services.obs.notifyObservers(
+ null,
+ "xpi-signature-changed",
+ JSON.stringify(changes)
+ );
+ } catch (err) {
+ logger.error("XPI_verifySignature: " + err);
+ }
+ },
+
+ /**
+ * Imports the xpinstall permissions from preferences into the permissions
+ * manager for the user to change later.
+ */
+ importPermissions() {
+ lazy.PermissionsUtils.importFromPrefs(
+ PREF_XPI_PERMISSIONS_BRANCH,
+ lazy.XPIInternal.XPI_PERMISSION
+ );
+ },
+
+ /**
+ * Called when a new add-on has been enabled when only one add-on of that type
+ * can be enabled.
+ *
+ * @param {string} aId
+ * The ID of the newly enabled add-on
+ * @param {string} aType
+ * The type of the newly enabled add-on
+ */
+ async addonChanged(aId, aType) {
+ // We only care about themes in this provider
+ if (aType !== "theme") {
+ return;
+ }
+
+ Services.prefs.setCharPref(
+ "extensions.activeThemeID",
+ aId || DEFAULT_THEME_ID
+ );
+
+ let enableTheme;
+
+ let addons = this.getAddonsByType("theme");
+ let updateDisabledStatePromises = [];
+
+ for (let theme of addons) {
+ if (theme.visible) {
+ if (!aId && theme.id == DEFAULT_THEME_ID) {
+ enableTheme = theme;
+ } else if (theme.id != aId && !theme.pendingUninstall) {
+ updateDisabledStatePromises.push(
+ this.updateAddonDisabledState(theme, {
+ userDisabled: true,
+ becauseSelecting: true,
+ })
+ );
+ }
+ }
+ }
+
+ await Promise.all(updateDisabledStatePromises);
+
+ if (enableTheme) {
+ await this.updateAddonDisabledState(enableTheme, {
+ userDisabled: false,
+ becauseSelecting: true,
+ });
+ }
+ },
+
+ SIGNED_TYPES,
+
+ /**
+ * Asynchronously list all addons that match the filter function
+ *
+ * @param {function(AddonInternal) : boolean} aFilter
+ * Function that takes an addon instance and returns
+ * true if that addon should be included in the selected array
+ *
+ * @returns {Array<AddonInternal>}
+ * A Promise that resolves to the list of add-ons matching
+ * aFilter or an empty array if none match
+ */
+ async getAddonList(aFilter) {
+ try {
+ let addonDB = await this.asyncLoadDB();
+ let addonList = _filterDB(addonDB, aFilter);
+ let addons = await Promise.all(
+ addonList.map(addon => getRepositoryAddon(addon))
+ );
+ return addons;
+ } catch (error) {
+ logger.error("getAddonList failed", error);
+ return [];
+ }
+ },
+
+ /**
+ * Get the first addon that matches the filter function
+ *
+ * @param {function(AddonInternal) : boolean} aFilter
+ * Function that takes an addon instance and returns
+ * true if that addon should be selected
+ * @returns {Promise<AddonInternal?>}
+ */
+ getAddon(aFilter) {
+ return this.asyncLoadDB()
+ .then(addonDB => getRepositoryAddon(_findAddon(addonDB, aFilter)))
+ .catch(error => {
+ logger.error("getAddon failed", error);
+ });
+ },
+
+ /**
+ * Asynchronously gets an add-on with a particular ID in a particular
+ * install location.
+ *
+ * @param {string} aId
+ * The ID of the add-on to retrieve
+ * @param {string} aLocation
+ * The name of the install location
+ * @returns {Promise<AddonInternal?>}
+ */
+ getAddonInLocation(aId, aLocation) {
+ return this.asyncLoadDB().then(addonDB =>
+ getRepositoryAddon(addonDB.get(aLocation + ":" + aId))
+ );
+ },
+
+ /**
+ * Asynchronously get all the add-ons in a particular install location.
+ *
+ * @param {string} aLocation
+ * The name of the install location
+ * @returns {Promise<Array<AddonInternal>>}
+ */
+ getAddonsInLocation(aLocation) {
+ return this.getAddonList(aAddon => aAddon.location.name == aLocation);
+ },
+
+ /**
+ * Asynchronously gets the add-on with the specified ID that is visible.
+ *
+ * @param {string} aId
+ * The ID of the add-on to retrieve
+ * @returns {Promise<AddonInternal?>}
+ */
+ getVisibleAddonForID(aId) {
+ return this.getAddon(aAddon => aAddon.id == aId && aAddon.visible);
+ },
+
+ /**
+ * Asynchronously gets the visible add-ons, optionally restricting by type.
+ *
+ * @param {Set<string>?} aTypes
+ * An array of types to include or null to include all types
+ * @returns {Promise<Array<AddonInternal>>}
+ */
+ getVisibleAddons(aTypes) {
+ return this.getAddonList(
+ aAddon => aAddon.visible && (!aTypes || aTypes.has(aAddon.type))
+ );
+ },
+
+ /**
+ * Synchronously gets all add-ons of a particular type(s).
+ *
+ * @param {Array<string>} aTypes
+ * The type(s) of add-on to retrieve
+ * @returns {Array<AddonInternal>}
+ */
+ getAddonsByType(...aTypes) {
+ if (!this.addonDB) {
+ // jank-tastic! Must synchronously load DB if the theme switches from
+ // an XPI theme to a lightweight theme before the DB has loaded,
+ // because we're called from sync XPIProvider.addonChanged
+ logger.warn(
+ `Synchronous load of XPI database due to ` +
+ `getAddonsByType([${aTypes.join(", ")}]) ` +
+ `Stack: ${Error().stack}`
+ );
+ this.syncLoadDB(true);
+ }
+
+ return _filterDB(this.addonDB, aAddon => aTypes.includes(aAddon.type));
+ },
+
+ /**
+ * Asynchronously gets all add-ons with pending operations.
+ *
+ * @param {Set<string>?} aTypes
+ * The types of add-ons to retrieve or null to get all types
+ * @returns {Promise<Array<AddonInternal>>}
+ */
+ getVisibleAddonsWithPendingOperations(aTypes) {
+ return this.getAddonList(
+ aAddon =>
+ aAddon.visible &&
+ aAddon.pendingUninstall &&
+ (!aTypes || aTypes.has(aAddon.type))
+ );
+ },
+
+ /**
+ * Synchronously gets all add-ons in the database.
+ * This is only called from the preference observer for the default
+ * compatibility version preference, so we can return an empty list if
+ * we haven't loaded the database yet.
+ *
+ * @returns {Array<AddonInternal>}
+ */
+ getAddons() {
+ if (!this.addonDB) {
+ return [];
+ }
+ return _filterDB(this.addonDB, aAddon => true);
+ },
+
+ /**
+ * Called to get an Addon with a particular ID.
+ *
+ * @param {string} aId
+ * The ID of the add-on to retrieve
+ * @returns {Addon?}
+ */
+ async getAddonByID(aId) {
+ let aAddon = await this.getVisibleAddonForID(aId);
+ return aAddon ? aAddon.wrapper : null;
+ },
+
+ /**
+ * Obtain an Addon having the specified Sync GUID.
+ *
+ * @param {string} aGUID
+ * String GUID of add-on to retrieve
+ * @returns {Addon?}
+ */
+ async getAddonBySyncGUID(aGUID) {
+ let addon = await this.getAddon(aAddon => aAddon.syncGUID == aGUID);
+ return addon ? addon.wrapper : null;
+ },
+
+ /**
+ * Called to get Addons of a particular type.
+ *
+ * @param {Array<string>?} aTypes
+ * An array of types to fetch. Can be null to get all types.
+ * @returns {Addon[]}
+ */
+ async getAddonsByTypes(aTypes) {
+ let addons = await this.getVisibleAddons(aTypes ? new Set(aTypes) : null);
+ return addons.map(a => a.wrapper);
+ },
+
+ /**
+ * Returns true if signing is required for the given add-on type.
+ *
+ * @param {string} aType
+ * The add-on type to check.
+ * @returns {boolean}
+ */
+ mustSign(aType) {
+ if (!SIGNED_TYPES.has(aType)) {
+ return false;
+ }
+
+ if (aType == "locale") {
+ return lazy.AddonSettings.LANGPACKS_REQUIRE_SIGNING;
+ }
+
+ return lazy.AddonSettings.REQUIRE_SIGNING;
+ },
+
+ /**
+ * Determine if this addon should be disabled due to being legacy
+ *
+ * @param {Addon} addon The addon to check
+ *
+ * @returns {boolean} Whether the addon should be disabled for being legacy
+ */
+ isDisabledLegacy(addon) {
+ // We still have tests that use a legacy addon type, allow them
+ // if we're in automation. Otherwise, disable if not a webextension.
+ if (!Cu.isInAutomation) {
+ return !addon.isWebExtension;
+ }
+
+ return (
+ !addon.isWebExtension &&
+ addon.type === "extension" &&
+ // Test addons are privileged unless forced otherwise.
+ addon.signedState !== lazy.AddonManager.SIGNEDSTATE_PRIVILEGED
+ );
+ },
+
+ /**
+ * Calculates whether an add-on should be appDisabled or not.
+ *
+ * @param {AddonInternal} aAddon
+ * The add-on to check
+ * @returns {boolean}
+ * True if the add-on should not be appDisabled
+ */
+ isUsableAddon(aAddon) {
+ if (this.mustSign(aAddon.type) && !aAddon.isCorrectlySigned) {
+ logger.warn(`Add-on ${aAddon.id} is not correctly signed.`);
+ if (Services.prefs.getBoolPref(PREF_XPI_SIGNATURES_DEV_ROOT, false)) {
+ logger.warn(`Preference ${PREF_XPI_SIGNATURES_DEV_ROOT} is set.`);
+ }
+ return false;
+ }
+
+ if (aAddon.blocklistState == nsIBlocklistService.STATE_BLOCKED) {
+ logger.warn(`Add-on ${aAddon.id} is blocklisted.`);
+ return false;
+ }
+
+ // If we can't read it, it's not usable:
+ if (aAddon.brokenManifest) {
+ return false;
+ }
+
+ if (
+ lazy.AddonManager.checkUpdateSecurity &&
+ !aAddon.providesUpdatesSecurely
+ ) {
+ logger.warn(
+ `Updates for add-on ${aAddon.id} must be provided over HTTPS.`
+ );
+ return false;
+ }
+
+ if (!aAddon.isPlatformCompatible) {
+ logger.warn(`Add-on ${aAddon.id} is not compatible with platform.`);
+ return false;
+ }
+
+ if (aAddon.dependencies.length) {
+ let isActive = id => {
+ let active = lazy.XPIProvider.activeAddons.get(id);
+ return active && !active._pendingDisable;
+ };
+
+ if (aAddon.dependencies.some(id => !isActive(id))) {
+ return false;
+ }
+ }
+
+ if (this.isDisabledLegacy(aAddon)) {
+ logger.warn(`disabling legacy extension ${aAddon.id}`);
+ return false;
+ }
+
+ if (lazy.AddonManager.checkCompatibility) {
+ if (!aAddon.isCompatible) {
+ logger.warn(
+ `Add-on ${aAddon.id} is not compatible with application version.`
+ );
+ return false;
+ }
+ } else {
+ let app = aAddon.matchingTargetApplication;
+ if (!app) {
+ logger.warn(
+ `Add-on ${aAddon.id} is not compatible with target application.`
+ );
+ return false;
+ }
+ }
+
+ if (aAddon.location.isSystem || aAddon.location.isBuiltin) {
+ return true;
+ }
+
+ if (Services.policies && !Services.policies.mayInstallAddon(aAddon)) {
+ return false;
+ }
+
+ return true;
+ },
+
+ /**
+ * Synchronously adds an AddonInternal's metadata to the database.
+ *
+ * @param {AddonInternal} aAddon
+ * AddonInternal to add
+ * @param {string} aPath
+ * The file path of the add-on
+ * @returns {AddonInternal}
+ * the AddonInternal that was added to the database
+ */
+ addToDatabase(aAddon, aPath) {
+ aAddon.addedToDatabase();
+ aAddon.path = aPath;
+ this.addonDB.set(aAddon._key, aAddon);
+ if (aAddon.visible) {
+ this.makeAddonVisible(aAddon);
+ }
+
+ this.saveChanges();
+ return aAddon;
+ },
+
+ /**
+ * Synchronously updates an add-on's metadata in the database. Currently just
+ * removes and recreates.
+ *
+ * @param {AddonInternal} aOldAddon
+ * The AddonInternal to be replaced
+ * @param {AddonInternal} aNewAddon
+ * The new AddonInternal to add
+ * @param {string} aPath
+ * The file path of the add-on
+ * @returns {AddonInternal}
+ * The AddonInternal that was added to the database
+ */
+ updateAddonMetadata(aOldAddon, aNewAddon, aPath) {
+ this.removeAddonMetadata(aOldAddon);
+ aNewAddon.syncGUID = aOldAddon.syncGUID;
+ aNewAddon.installDate = aOldAddon.installDate;
+ aNewAddon.applyBackgroundUpdates = aOldAddon.applyBackgroundUpdates;
+ aNewAddon.foreignInstall = aOldAddon.foreignInstall;
+ aNewAddon.seen = aOldAddon.seen;
+ aNewAddon.active =
+ aNewAddon.visible && !aNewAddon.disabled && !aNewAddon.pendingUninstall;
+ aNewAddon.installTelemetryInfo = aOldAddon.installTelemetryInfo;
+
+ return this.addToDatabase(aNewAddon, aPath);
+ },
+
+ /**
+ * Synchronously removes an add-on from the database.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal being removed
+ */
+ removeAddonMetadata(aAddon) {
+ this.addonDB.delete(aAddon._key);
+ this.saveChanges();
+ },
+
+ updateXPIStates(addon) {
+ let state = addon.location && addon.location.get(addon.id);
+ if (state) {
+ state.syncWithDB(addon);
+ lazy.XPIInternal.XPIStates.save();
+ }
+ },
+
+ /**
+ * Synchronously marks a AddonInternal as visible marking all other
+ * instances with the same ID as not visible.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal to make visible
+ */
+ makeAddonVisible(aAddon) {
+ logger.debug("Make addon " + aAddon._key + " visible");
+ for (let [, otherAddon] of this.addonDB) {
+ if (otherAddon.id == aAddon.id && otherAddon._key != aAddon._key) {
+ logger.debug("Hide addon " + otherAddon._key);
+ otherAddon.visible = false;
+ otherAddon.active = false;
+
+ this.updateXPIStates(otherAddon);
+ }
+ }
+ aAddon.visible = true;
+ this.updateXPIStates(aAddon);
+ this.saveChanges();
+ },
+
+ /**
+ * Synchronously marks a given add-on ID visible in a given location,
+ * instances with the same ID as not visible.
+ *
+ * @param {string} aId
+ * The ID of the add-on to make visible
+ * @param {XPIStateLocation} aLocation
+ * The location in which to make the add-on visible.
+ * @returns {AddonInternal?}
+ * The add-on instance which was marked visible, if any.
+ */
+ makeAddonLocationVisible(aId, aLocation) {
+ logger.debug(`Make addon ${aId} visible in location ${aLocation}`);
+ let result;
+ for (let [, addon] of this.addonDB) {
+ if (addon.id != aId) {
+ continue;
+ }
+ if (addon.location == aLocation) {
+ logger.debug("Reveal addon " + addon._key);
+ addon.visible = true;
+ addon.active = true;
+ this.updateXPIStates(addon);
+ result = addon;
+ } else {
+ logger.debug("Hide addon " + addon._key);
+ addon.visible = false;
+ addon.active = false;
+ this.updateXPIStates(addon);
+ }
+ }
+ this.saveChanges();
+ return result;
+ },
+
+ /**
+ * Synchronously sets properties for an add-on.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal being updated
+ * @param {Object} aProperties
+ * A dictionary of properties to set
+ */
+ setAddonProperties(aAddon, aProperties) {
+ for (let key in aProperties) {
+ aAddon[key] = aProperties[key];
+ }
+ this.saveChanges();
+ },
+
+ /**
+ * Synchronously sets the Sync GUID for an add-on.
+ * Only called when the database is already loaded.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal being updated
+ * @param {string} aGUID
+ * GUID string to set the value to
+ * @throws if another addon already has the specified GUID
+ */
+ setAddonSyncGUID(aAddon, aGUID) {
+ // Need to make sure no other addon has this GUID
+ function excludeSyncGUID(otherAddon) {
+ return otherAddon._key != aAddon._key && otherAddon.syncGUID == aGUID;
+ }
+ let otherAddon = _findAddon(this.addonDB, excludeSyncGUID);
+ if (otherAddon) {
+ throw new Error(
+ "Addon sync GUID conflict for addon " +
+ aAddon._key +
+ ": " +
+ otherAddon._key +
+ " already has GUID " +
+ aGUID
+ );
+ }
+ aAddon.syncGUID = aGUID;
+ this.saveChanges();
+ },
+
+ /**
+ * Synchronously updates an add-on's active flag in the database.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal to update
+ * @param {boolean} aActive
+ * The new active state for the add-on.
+ */
+ updateAddonActive(aAddon, aActive) {
+ logger.debug(
+ "Updating active state for add-on " + aAddon.id + " to " + aActive
+ );
+
+ aAddon.active = aActive;
+ this.saveChanges();
+ },
+
+ /**
+ * Synchronously calculates and updates all the active flags in the database.
+ */
+ updateActiveAddons() {
+ logger.debug("Updating add-on states");
+ for (let [, addon] of this.addonDB) {
+ let newActive =
+ addon.visible && !addon.disabled && !addon.pendingUninstall;
+ if (newActive != addon.active) {
+ addon.active = newActive;
+ this.saveChanges();
+ }
+ }
+
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, false);
+ },
+
+ /**
+ * Updates the disabled state for an add-on. Its appDisabled property will be
+ * calculated and if the add-on is changed the database will be saved and
+ * appropriate notifications will be sent out to the registered AddonListeners.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal to update
+ * @param {Object} properties - Properties to set on the addon
+ * @param {boolean?} [properties.userDisabled]
+ * Value for the userDisabled property. If undefined the value will
+ * not change
+ * @param {boolean?} [properties.softDisabled]
+ * Value for the softDisabled property. If undefined the value will
+ * not change. If true this will force userDisabled to be true
+ * @param {boolean?} [properties.embedderDisabled]
+ * Value for the embedderDisabled property. If undefined the value will
+ * not change.
+ * @param {boolean?} [properties.becauseSelecting]
+ * True if we're disabling this add-on because we're selecting
+ * another.
+ * @returns {Promise<boolean?>}
+ * A tri-state indicating the action taken for the add-on:
+ * - undefined: The add-on did not change state
+ * - true: The add-on became disabled
+ * - false: The add-on became enabled
+ * @throws if addon is not a AddonInternal
+ */
+ async updateAddonDisabledState(
+ aAddon,
+ { userDisabled, softDisabled, embedderDisabled, becauseSelecting } = {}
+ ) {
+ if (!aAddon.inDatabase) {
+ throw new Error("Can only update addon states for installed addons.");
+ }
+ if (userDisabled !== undefined && softDisabled !== undefined) {
+ throw new Error(
+ "Cannot change userDisabled and softDisabled at the same time"
+ );
+ }
+
+ if (userDisabled === undefined) {
+ userDisabled = aAddon.userDisabled;
+ } else if (!userDisabled) {
+ // If enabling the add-on then remove softDisabled
+ softDisabled = false;
+ }
+
+ // If not changing softDisabled or the add-on is already userDisabled then
+ // use the existing value for softDisabled
+ if (softDisabled === undefined || userDisabled) {
+ softDisabled = aAddon.softDisabled;
+ }
+
+ if (!lazy.AddonSettings.IS_EMBEDDED) {
+ // If embedderDisabled was accidentally set somehow, this will revert it
+ // back to false.
+ embedderDisabled = false;
+ } else if (embedderDisabled === undefined) {
+ embedderDisabled = aAddon.embedderDisabled;
+ }
+
+ let appDisabled = !this.isUsableAddon(aAddon);
+ // No change means nothing to do here
+ if (
+ aAddon.userDisabled == userDisabled &&
+ aAddon.appDisabled == appDisabled &&
+ aAddon.softDisabled == softDisabled &&
+ aAddon.embedderDisabled == embedderDisabled
+ ) {
+ return undefined;
+ }
+
+ let wasDisabled = aAddon.disabled;
+ let isDisabled =
+ userDisabled || softDisabled || appDisabled || embedderDisabled;
+
+ // If appDisabled changes but addon.disabled doesn't,
+ // no onDisabling/onEnabling is sent - so send a onPropertyChanged.
+ let appDisabledChanged = aAddon.appDisabled != appDisabled;
+
+ // Update the properties in the database.
+ this.setAddonProperties(aAddon, {
+ userDisabled,
+ appDisabled,
+ softDisabled,
+ embedderDisabled,
+ });
+
+ let wrapper = aAddon.wrapper;
+
+ if (appDisabledChanged) {
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onPropertyChanged",
+ wrapper,
+ ["appDisabled"]
+ );
+ }
+
+ // If the add-on is not visible or the add-on is not changing state then
+ // there is no need to do anything else
+ if (!aAddon.visible || wasDisabled == isDisabled) {
+ return undefined;
+ }
+
+ // Flag that active states in the database need to be updated on shutdown
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
+
+ this.updateXPIStates(aAddon);
+
+ // Have we just gone back to the current state?
+ if (isDisabled != aAddon.active) {
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onOperationCancelled",
+ wrapper
+ );
+ } else {
+ if (isDisabled) {
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onDisabling",
+ wrapper,
+ false
+ );
+ } else {
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onEnabling",
+ wrapper,
+ false
+ );
+ }
+
+ this.updateAddonActive(aAddon, !isDisabled);
+
+ let bootstrap = lazy.XPIInternal.BootstrapScope.get(aAddon);
+ if (isDisabled) {
+ await bootstrap.disable();
+ lazy.AddonManagerPrivate.callAddonListeners("onDisabled", wrapper);
+ } else {
+ await bootstrap.startup(
+ lazy.XPIInternal.BOOTSTRAP_REASONS.ADDON_ENABLE
+ );
+ lazy.AddonManagerPrivate.callAddonListeners("onEnabled", wrapper);
+ }
+ }
+
+ // Notify any other providers that a new theme has been enabled
+ if (aAddon.type === "theme") {
+ if (!isDisabled) {
+ await lazy.AddonManagerPrivate.notifyAddonChanged(
+ aAddon.id,
+ aAddon.type
+ );
+ } else if (isDisabled && !becauseSelecting) {
+ await lazy.AddonManagerPrivate.notifyAddonChanged(null, "theme");
+ }
+ }
+
+ return isDisabled;
+ },
+
+ /**
+ * Update the appDisabled property for all add-ons.
+ */
+ updateAddonAppDisabledStates() {
+ for (let addon of this.getAddons()) {
+ this.updateAddonDisabledState(addon);
+ }
+ },
+
+ /**
+ * Update the repositoryAddon property for all add-ons.
+ */
+ async updateAddonRepositoryData() {
+ let addons = await this.getVisibleAddons(null);
+ logger.debug(
+ "updateAddonRepositoryData found " + addons.length + " visible add-ons"
+ );
+
+ await Promise.all(
+ addons.map(addon =>
+ lazy.AddonRepository.getCachedAddonByID(addon.id).then(aRepoAddon => {
+ if (aRepoAddon) {
+ logger.debug("updateAddonRepositoryData got info for " + addon.id);
+ addon._repositoryAddon = aRepoAddon;
+ return this.updateAddonDisabledState(addon);
+ }
+ return undefined;
+ })
+ )
+ );
+ },
+
+ /**
+ * Adds the add-on's name and creator to the telemetry payload.
+ *
+ * @param {AddonInternal} aAddon
+ * The addon to record
+ */
+ recordAddonTelemetry(aAddon) {
+ let locale = aAddon.defaultLocale;
+ lazy.XPIProvider.addTelemetry(aAddon.id, {
+ name: locale.name,
+ creator: locale.creator,
+ });
+ },
+};
+
+const XPIDatabaseReconcile = {
+ /**
+ * Returns a map of ID -> add-on. When the same add-on ID exists in multiple
+ * install locations the highest priority location is chosen.
+ *
+ * @param {Map<String, AddonInternal>} addonMap
+ * The add-on map to flatten.
+ * @param {string?} [hideLocation]
+ * An optional location from which to hide any add-ons.
+ * @returns {Map<string, AddonInternal>}
+ */
+ flattenByID(addonMap, hideLocation) {
+ let map = new Map();
+
+ for (let loc of lazy.XPIInternal.XPIStates.locations()) {
+ if (loc.name == hideLocation) {
+ continue;
+ }
+
+ let locationMap = addonMap.get(loc.name);
+ if (!locationMap) {
+ continue;
+ }
+
+ for (let [id, addon] of locationMap) {
+ if (!map.has(id)) {
+ map.set(id, addon);
+ }
+ }
+ }
+
+ return map;
+ },
+
+ /**
+ * Finds the visible add-ons from the map.
+ *
+ * @param {Map<String, AddonInternal>} addonMap
+ * The add-on map to filter.
+ * @returns {Map<string, AddonInternal>}
+ */
+ getVisibleAddons(addonMap) {
+ let map = new Map();
+
+ for (let addons of addonMap.values()) {
+ for (let [id, addon] of addons) {
+ if (!addon.visible) {
+ continue;
+ }
+
+ if (map.has(id)) {
+ logger.warn(
+ "Previous database listed more than one visible add-on with id " +
+ id
+ );
+ continue;
+ }
+
+ map.set(id, addon);
+ }
+ }
+
+ return map;
+ },
+
+ /**
+ * Called to add the metadata for an add-on in one of the install locations
+ * to the database. This can be called in three different cases. Either an
+ * add-on has been dropped into the location from outside of Firefox, or
+ * an add-on has been installed through the application, or the database
+ * has been upgraded or become corrupt and add-on data has to be reloaded
+ * into it.
+ *
+ * @param {XPIStateLocation} aLocation
+ * The install location containing the add-on
+ * @param {string} aId
+ * The ID of the add-on
+ * @param {XPIState} aAddonState
+ * The new state of the add-on
+ * @param {AddonInternal?} [aNewAddon]
+ * The manifest for the new add-on if it has already been loaded
+ * @param {string?} [aOldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @param {string?} [aOldPlatformVersion]
+ * The version of the platform last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @returns {boolean}
+ * A boolean indicating if flushing caches is required to complete
+ * changing this add-on
+ */
+ addMetadata(
+ aLocation,
+ aId,
+ aAddonState,
+ aNewAddon,
+ aOldAppVersion,
+ aOldPlatformVersion
+ ) {
+ logger.debug(`New add-on ${aId} installed in ${aLocation.name}`);
+
+ // We treat this is a new install if,
+ //
+ // a) It was explicitly registered as a staged install in the last
+ // session, or,
+ // b) We're not currently migrating or rebuilding a corrupt database. In
+ // that case, we can assume this add-on was found during a routine
+ // directory scan.
+ let isNewInstall = !!aNewAddon || !XPIDatabase.rebuildingDatabase;
+
+ // If it's a new install and we haven't yet loaded the manifest then it
+ // must be something dropped directly into the install location
+ let isDetectedInstall = isNewInstall && !aNewAddon;
+
+ // Load the manifest if necessary and sanity check the add-on ID
+ let unsigned;
+ try {
+ // Do not allow third party installs if xpinstall is disabled by policy
+ if (
+ isDetectedInstall &&
+ Services.policies &&
+ !Services.policies.isAllowed("xpinstall")
+ ) {
+ throw new Error(
+ "Extension installs are disabled by enterprise policy."
+ );
+ }
+
+ if (!aNewAddon) {
+ // Load the manifest from the add-on.
+ aNewAddon = lazy.XPIInstall.syncLoadManifest(aAddonState, aLocation);
+ }
+ // The add-on in the manifest should match the add-on ID.
+ if (aNewAddon.id != aId) {
+ throw new Error(
+ `Invalid addon ID: expected addon ID ${aId}, found ${aNewAddon.id} in manifest`
+ );
+ }
+
+ unsigned =
+ XPIDatabase.mustSign(aNewAddon.type) && !aNewAddon.isCorrectlySigned;
+ if (unsigned) {
+ throw Error(`Extension ${aNewAddon.id} is not correctly signed`);
+ }
+ } catch (e) {
+ logger.warn(`addMetadata: Add-on ${aId} is invalid`, e);
+
+ // Remove the invalid add-on from the install location if the install
+ // location isn't locked
+ if (aLocation.isLinkedAddon(aId)) {
+ logger.warn("Not uninstalling invalid item because it is a proxy file");
+ } else if (aLocation.locked) {
+ logger.warn(
+ "Could not uninstall invalid item from locked install location"
+ );
+ } else if (unsigned && !isNewInstall) {
+ logger.warn("Not uninstalling existing unsigned add-on");
+ } else if (aLocation.name == KEY_APP_BUILTINS) {
+ // If a builtin has been removed from the build, we need to remove it from our
+ // data sets. We cannot use location.isBuiltin since the system addon locations
+ // mix it up.
+ XPIDatabase.removeAddonMetadata(aAddonState);
+ aLocation.removeAddon(aId);
+ } else {
+ aLocation.installer.uninstallAddon(aId);
+ }
+ return null;
+ }
+
+ // Update the AddonInternal properties.
+ aNewAddon.installDate = aAddonState.mtime;
+ aNewAddon.updateDate = aAddonState.mtime;
+
+ // Assume that add-ons in the system add-ons install location aren't
+ // foreign and should default to enabled.
+ aNewAddon.foreignInstall =
+ isDetectedInstall && !aLocation.isSystem && !aLocation.isBuiltin;
+
+ // appDisabled depends on whether the add-on is a foreignInstall so update
+ aNewAddon.appDisabled = !XPIDatabase.isUsableAddon(aNewAddon);
+
+ if (isDetectedInstall && aNewAddon.foreignInstall) {
+ // Add the installation source info for the sideloaded extension.
+ aNewAddon.installTelemetryInfo = {
+ source: aLocation.name,
+ method: "sideload",
+ };
+
+ // If the add-on is a foreign install and is in a scope where add-ons
+ // that were dropped in should default to disabled then disable it
+ let disablingScopes = Services.prefs.getIntPref(
+ PREF_EM_AUTO_DISABLED_SCOPES,
+ 0
+ );
+ if (aLocation.scope & disablingScopes) {
+ logger.warn(
+ `Disabling foreign installed add-on ${aNewAddon.id} in ${aLocation.name}`
+ );
+ aNewAddon.userDisabled = true;
+ aNewAddon.seen = false;
+ }
+ }
+
+ return XPIDatabase.addToDatabase(aNewAddon, aAddonState.path);
+ },
+
+ /**
+ * Called when an add-on has been removed.
+ *
+ * @param {AddonInternal} aOldAddon
+ * The AddonInternal as it appeared the last time the application
+ * ran
+ */
+ removeMetadata(aOldAddon) {
+ // This add-on has disappeared
+ logger.debug(
+ "Add-on " + aOldAddon.id + " removed from " + aOldAddon.location.name
+ );
+ XPIDatabase.removeAddonMetadata(aOldAddon);
+ },
+
+ /**
+ * Updates an add-on's metadata and determines. This is called when either the
+ * add-on's install directory path or last modified time has changed.
+ *
+ * @param {XPIStateLocation} aLocation
+ * The install location containing the add-on
+ * @param {AddonInternal} aOldAddon
+ * The AddonInternal as it appeared the last time the application
+ * ran
+ * @param {XPIState} aAddonState
+ * The new state of the add-on
+ * @param {AddonInternal?} [aNewAddon]
+ * The manifest for the new add-on if it has already been loaded
+ * @returns {AddonInternal}
+ * The AddonInternal that was added to the database
+ */
+ updateMetadata(aLocation, aOldAddon, aAddonState, aNewAddon) {
+ logger.debug(`Add-on ${aOldAddon.id} modified in ${aLocation.name}`);
+
+ try {
+ // If there isn't an updated install manifest for this add-on then load it.
+ if (!aNewAddon) {
+ aNewAddon = lazy.XPIInstall.syncLoadManifest(
+ aAddonState,
+ aLocation,
+ aOldAddon
+ );
+ } else {
+ aNewAddon.rootURI = aOldAddon.rootURI;
+ }
+
+ // The ID in the manifest that was loaded must match the ID of the old
+ // add-on.
+ if (aNewAddon.id != aOldAddon.id) {
+ throw new Error(
+ `Incorrect id in install manifest for existing add-on ${aOldAddon.id}`
+ );
+ }
+ } catch (e) {
+ logger.warn(`updateMetadata: Add-on ${aOldAddon.id} is invalid`, e);
+
+ XPIDatabase.removeAddonMetadata(aOldAddon);
+ aOldAddon.location.removeAddon(aOldAddon.id);
+
+ if (!aLocation.locked) {
+ aLocation.installer.uninstallAddon(aOldAddon.id);
+ } else {
+ logger.warn(
+ "Could not uninstall invalid item from locked install location"
+ );
+ }
+
+ return null;
+ }
+
+ // Set the additional properties on the new AddonInternal
+ aNewAddon.updateDate = aAddonState.mtime;
+
+ lazy.XPIProvider.persistStartupData(aNewAddon, aAddonState);
+
+ // Update the database
+ return XPIDatabase.updateAddonMetadata(
+ aOldAddon,
+ aNewAddon,
+ aAddonState.path
+ );
+ },
+
+ /**
+ * Updates an add-on's path for when the add-on has moved in the
+ * filesystem but hasn't changed in any other way.
+ *
+ * @param {XPIStateLocation} aLocation
+ * The install location containing the add-on
+ * @param {AddonInternal} aOldAddon
+ * The AddonInternal as it appeared the last time the application
+ * ran
+ * @param {XPIState} aAddonState
+ * The new state of the add-on
+ * @returns {AddonInternal}
+ */
+ updatePath(aLocation, aOldAddon, aAddonState) {
+ logger.debug(`Add-on ${aOldAddon.id} moved to ${aAddonState.path}`);
+ aOldAddon.path = aAddonState.path;
+ aOldAddon._sourceBundle = new nsIFile(aAddonState.path);
+ aOldAddon.rootURI = lazy.XPIInternal.getURIForResourceInFile(
+ aOldAddon._sourceBundle,
+ ""
+ ).spec;
+
+ return aOldAddon;
+ },
+
+ /**
+ * Called when no change has been detected for an add-on's metadata but the
+ * application has changed so compatibility may have changed.
+ *
+ * @param {XPIStateLocation} aLocation
+ * The install location containing the add-on
+ * @param {AddonInternal} aOldAddon
+ * The AddonInternal as it appeared the last time the application
+ * ran
+ * @param {XPIState} aAddonState
+ * The new state of the add-on
+ * @param {boolean} [aReloadMetadata = false]
+ * A boolean which indicates whether metadata should be reloaded from
+ * the addon manifests. Default to false.
+ * @returns {AddonInternal}
+ * The new addon.
+ */
+ updateCompatibility(aLocation, aOldAddon, aAddonState, aReloadMetadata) {
+ logger.debug(
+ `Updating compatibility for add-on ${aOldAddon.id} in ${aLocation.name}`
+ );
+
+ let checkSigning =
+ aOldAddon.signedState === undefined && SIGNED_TYPES.has(aOldAddon.type);
+ // signedDate must be set if signedState is set.
+ let signedDateMissing =
+ aOldAddon.signedDate === undefined &&
+ (aOldAddon.signedState || checkSigning);
+
+ // If maxVersion was inadvertently updated for a locale, force a reload
+ // from the manifest. See Bug 1646016 for details.
+ if (
+ !aReloadMetadata &&
+ aOldAddon.type === "locale" &&
+ aOldAddon.matchingTargetApplication
+ ) {
+ aReloadMetadata = aOldAddon.matchingTargetApplication.maxVersion === "*";
+ }
+
+ let manifest = null;
+ if (checkSigning || aReloadMetadata || signedDateMissing) {
+ try {
+ manifest = lazy.XPIInstall.syncLoadManifest(aAddonState, aLocation);
+ } catch (err) {
+ // If we can no longer read the manifest, it is no longer compatible.
+ aOldAddon.brokenManifest = true;
+ aOldAddon.appDisabled = true;
+ return aOldAddon;
+ }
+ }
+
+ // If updating from a version of the app that didn't support signedState
+ // then update that property now
+ if (checkSigning) {
+ aOldAddon.signedState = manifest.signedState;
+ }
+
+ if (signedDateMissing) {
+ aOldAddon.signedDate = manifest.signedDate;
+ }
+
+ // May be updating from a version of the app that didn't support all the
+ // properties of the currently-installed add-ons.
+ if (aReloadMetadata) {
+ // Avoid re-reading these properties from manifest,
+ // use existing addon instead.
+ let remove = [
+ "syncGUID",
+ "foreignInstall",
+ "visible",
+ "active",
+ "userDisabled",
+ "embedderDisabled",
+ "applyBackgroundUpdates",
+ "sourceURI",
+ "releaseNotesURI",
+ "installTelemetryInfo",
+ ];
+
+ // TODO - consider re-scanning for targetApplications for other addon types.
+ if (aOldAddon.type !== "locale") {
+ remove.push("targetApplications");
+ }
+
+ let props = PROP_JSON_FIELDS.filter(a => !remove.includes(a));
+ copyProperties(manifest, props, aOldAddon);
+ }
+
+ aOldAddon.appDisabled = !XPIDatabase.isUsableAddon(aOldAddon);
+
+ return aOldAddon;
+ },
+
+ /**
+ * Returns true if this install location is part of the application
+ * bundle. Add-ons in these locations are expected to change whenever
+ * the application updates.
+ *
+ * @param {XPIStateLocation} location
+ * The install location to check.
+ * @returns {boolean}
+ * True if this location is part of the application bundle.
+ */
+ isAppBundledLocation(location) {
+ return (
+ location.name == KEY_APP_GLOBAL ||
+ location.name == KEY_APP_SYSTEM_DEFAULTS ||
+ location.name == KEY_APP_BUILTINS
+ );
+ },
+
+ /**
+ * Returns true if this install location holds system addons.
+ *
+ * @param {XPIStateLocation} location
+ * The install location to check.
+ * @returns {boolean}
+ * True if this location contains system add-ons.
+ */
+ isSystemAddonLocation(location) {
+ return (
+ location.name === KEY_APP_SYSTEM_DEFAULTS ||
+ location.name === KEY_APP_SYSTEM_ADDONS
+ );
+ },
+
+ /**
+ * Updates the databse metadata for an existing add-on during database
+ * reconciliation.
+ *
+ * @param {AddonInternal} oldAddon
+ * The existing database add-on entry.
+ * @param {XPIState} xpiState
+ * The XPIStates entry for this add-on.
+ * @param {AddonInternal?} newAddon
+ * The new add-on metadata for the add-on, as loaded from a
+ * staged update in addonStartup.json.
+ * @param {boolean} aUpdateCompatibility
+ * true to update add-ons appDisabled property when the application
+ * version has changed
+ * @param {boolean} aSchemaChange
+ * The schema has changed and all add-on manifests should be re-read.
+ * @returns {AddonInternal?}
+ * The updated AddonInternal object for the add-on, if one
+ * could be created.
+ */
+ updateExistingAddon(
+ oldAddon,
+ xpiState,
+ newAddon,
+ aUpdateCompatibility,
+ aSchemaChange
+ ) {
+ XPIDatabase.recordAddonTelemetry(oldAddon);
+
+ let installLocation = oldAddon.location;
+
+ // Update the add-on's database metadata from on-disk metadata if:
+ //
+ // a) The add-on was staged for install in the last session,
+ // b) The add-on has been modified since the last session, or,
+ // c) The app has been updated since the last session, and the
+ // add-on is part of the application bundle (and has therefore
+ // likely been replaced in the update process).
+ if (
+ newAddon ||
+ oldAddon.updateDate != xpiState.mtime ||
+ (aUpdateCompatibility && this.isAppBundledLocation(installLocation))
+ ) {
+ newAddon = this.updateMetadata(
+ installLocation,
+ oldAddon,
+ xpiState,
+ newAddon
+ );
+ } else if (oldAddon.path != xpiState.path) {
+ newAddon = this.updatePath(installLocation, oldAddon, xpiState);
+ } else if (aUpdateCompatibility || aSchemaChange) {
+ newAddon = this.updateCompatibility(
+ installLocation,
+ oldAddon,
+ xpiState,
+ aSchemaChange
+ );
+ } else {
+ newAddon = oldAddon;
+ }
+
+ if (newAddon) {
+ newAddon.rootURI = newAddon.rootURI || xpiState.rootURI;
+ }
+
+ return newAddon;
+ },
+
+ /**
+ * Compares the add-ons that are currently installed to those that were
+ * known to be installed when the application last ran and applies any
+ * changes found to the database.
+ * Always called after XPIDatabase.jsm and extensions.json have been loaded.
+ *
+ * @param {Object} aManifests
+ * A dictionary of cached AddonInstalls for add-ons that have been
+ * installed
+ * @param {boolean} aUpdateCompatibility
+ * true to update add-ons appDisabled property when the application
+ * version has changed
+ * @param {string?} [aOldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @param {string?} [aOldPlatformVersion]
+ * The version of the platform last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @param {boolean} aSchemaChange
+ * The schema has changed and all add-on manifests should be re-read.
+ * @returns {boolean}
+ * A boolean indicating if a change requiring flushing the caches was
+ * detected
+ */
+ processFileChanges(
+ aManifests,
+ aUpdateCompatibility,
+ aOldAppVersion,
+ aOldPlatformVersion,
+ aSchemaChange
+ ) {
+ let findManifest = (loc, id) => {
+ return (aManifests[loc.name] && aManifests[loc.name][id]) || null;
+ };
+
+ let previousAddons = new lazy.ExtensionUtils.DefaultMap(() => new Map());
+ let currentAddons = new lazy.ExtensionUtils.DefaultMap(() => new Map());
+
+ // Get the previous add-ons from the database and put them into maps by location
+ for (let addon of XPIDatabase.getAddons()) {
+ previousAddons.get(addon.location.name).set(addon.id, addon);
+ }
+
+ // Keep track of add-ons whose blocklist status may have changed. We'll check this
+ // after everything else.
+ let addonsToCheckAgainstBlocklist = [];
+
+ // Build the list of current add-ons into similar maps. When add-ons are still
+ // present we re-use the add-on objects from the database and update their
+ // details directly
+ let addonStates = new Map();
+ for (let location of lazy.XPIInternal.XPIStates.locations()) {
+ let locationAddons = currentAddons.get(location.name);
+
+ // Get all the on-disk XPI states for this location, and keep track of which
+ // ones we see in the database.
+ let dbAddons = previousAddons.get(location.name) || new Map();
+ for (let [id, oldAddon] of dbAddons) {
+ // Check if the add-on is still installed
+ let xpiState = location.get(id);
+ if (xpiState && !xpiState.missing) {
+ let newAddon = this.updateExistingAddon(
+ oldAddon,
+ xpiState,
+ findManifest(location, id),
+ aUpdateCompatibility,
+ aSchemaChange
+ );
+ if (newAddon) {
+ locationAddons.set(newAddon.id, newAddon);
+
+ // We need to do a blocklist check later, but the add-on may have changed by then.
+ // Avoid storing the current copy and just get one when we need one instead.
+ addonsToCheckAgainstBlocklist.push(newAddon.id);
+ }
+ } else {
+ // The add-on is in the DB, but not in xpiState (and thus not on disk).
+ this.removeMetadata(oldAddon);
+ }
+ }
+
+ for (let [id, xpiState] of location) {
+ if (locationAddons.has(id) || xpiState.missing) {
+ continue;
+ }
+ let newAddon = findManifest(location, id);
+ let addon = this.addMetadata(
+ location,
+ id,
+ xpiState,
+ newAddon,
+ aOldAppVersion,
+ aOldPlatformVersion
+ );
+ if (addon) {
+ locationAddons.set(addon.id, addon);
+ addonStates.set(addon, xpiState);
+ }
+ }
+
+ if (this.isSystemAddonLocation(location)) {
+ for (let [id, addon] of locationAddons.entries()) {
+ const pref = `extensions.${id.split("@")[0]}.enabled`;
+ addon.userDisabled = !Services.prefs.getBoolPref(pref, true);
+ }
+ }
+ }
+
+ // Validate the updated system add-ons
+ let hideLocation;
+ {
+ let systemAddonLocation = lazy.XPIInternal.XPIStates.getLocation(
+ KEY_APP_SYSTEM_ADDONS
+ );
+ let addons = currentAddons.get(systemAddonLocation.name);
+
+ if (!systemAddonLocation.installer.isValid(addons)) {
+ // Hide the system add-on updates if any are invalid.
+ logger.info(
+ "One or more updated system add-ons invalid, falling back to defaults."
+ );
+ hideLocation = systemAddonLocation.name;
+ }
+ }
+
+ // Apply startup changes to any currently-visible add-ons, and
+ // uninstall any which were previously visible, but aren't anymore.
+ let previousVisible = this.getVisibleAddons(previousAddons);
+ let currentVisible = this.flattenByID(currentAddons, hideLocation);
+
+ for (let addon of XPIDatabase.orphanedAddons.splice(0)) {
+ if (addon.visible) {
+ previousVisible.set(addon.id, addon);
+ }
+ }
+
+ let promises = [];
+ for (let [id, addon] of currentVisible) {
+ // If we have a stored manifest for the add-on, it came from the
+ // startup data cache, and supersedes any previous XPIStates entry.
+ let xpiState =
+ !findManifest(addon.location, id) && addonStates.get(addon);
+
+ promises.push(
+ this.applyStartupChange(addon, previousVisible.get(id), xpiState)
+ );
+ previousVisible.delete(id);
+ }
+
+ if (promises.some(p => p)) {
+ lazy.XPIInternal.awaitPromise(Promise.all(promises));
+ }
+
+ for (let [id, addon] of previousVisible) {
+ if (addon.location) {
+ if (addon.location.name == KEY_APP_BUILTINS) {
+ continue;
+ }
+ lazy.XPIInternal.BootstrapScope.get(addon).uninstall();
+ addon.location.removeAddon(id);
+ addon.visible = false;
+ addon.active = false;
+ }
+
+ lazy.AddonManagerPrivate.addStartupChange(
+ lazy.AddonManager.STARTUP_CHANGE_UNINSTALLED,
+ id
+ );
+ }
+
+ // Finally update XPIStates to match everything
+ for (let [locationName, locationAddons] of currentAddons) {
+ for (let [id, addon] of locationAddons) {
+ let xpiState = lazy.XPIInternal.XPIStates.getAddon(locationName, id);
+ xpiState.syncWithDB(addon);
+ }
+ }
+ lazy.XPIInternal.XPIStates.save();
+ XPIDatabase.saveChanges();
+ XPIDatabase.rebuildingDatabase = false;
+
+ if (aUpdateCompatibility || aSchemaChange) {
+ // Do some blocklist checks. These will happen after we've just saved everything,
+ // because they're async and depend on the blocklist loading. When we're done, save
+ // the data if any of the add-ons' blocklist state has changed.
+ lazy.AddonManager.beforeShutdown.addBlocker(
+ "Update add-on blocklist state into add-on DB",
+ (async () => {
+ // Avoid querying the AddonManager immediately to give startup a chance
+ // to complete.
+ await Promise.resolve();
+
+ let addons = await lazy.AddonManager.getAddonsByIDs(
+ addonsToCheckAgainstBlocklist
+ );
+ await Promise.all(
+ addons.map(async addon => {
+ if (!addon) {
+ return;
+ }
+ let oldState = addon.blocklistState;
+ // TODO 1712316: updateBlocklistState with object parameter only
+ // works if addon is an AddonInternal instance. But addon is an
+ // AddonWrapper instead. Consequently updateDate:false is ignored.
+ await addon.updateBlocklistState({ updateDatabase: false });
+ if (oldState !== addon.blocklistState) {
+ lazy.Blocklist.recordAddonBlockChangeTelemetry(
+ addon,
+ "addon_db_modified"
+ );
+ }
+ })
+ );
+
+ XPIDatabase.saveChanges();
+ })()
+ );
+ }
+
+ return true;
+ },
+
+ /**
+ * Applies a startup change for the given add-on.
+ *
+ * @param {AddonInternal} currentAddon
+ * The add-on as it exists in this session.
+ * @param {AddonInternal?} previousAddon
+ * The add-on as it existed in the previous session.
+ * @param {XPIState?} xpiState
+ * The XPIState entry for this add-on, if one exists.
+ * @returns {Promise?}
+ * If an update was performed, returns a promise which resolves
+ * when the appropriate bootstrap methods have been called.
+ */
+ applyStartupChange(currentAddon, previousAddon, xpiState) {
+ let promise;
+ let { id } = currentAddon;
+
+ let isActive = !currentAddon.disabled;
+ let wasActive = previousAddon ? previousAddon.active : currentAddon.active;
+
+ if (previousAddon) {
+ if (previousAddon !== currentAddon) {
+ lazy.AddonManagerPrivate.addStartupChange(
+ lazy.AddonManager.STARTUP_CHANGE_CHANGED,
+ id
+ );
+
+ // Bug 1664144: If the addon changed on disk we will catch it during
+ // the second scan initiated by getNewSideloads. The addon may have
+ // already started, if so we need to ensure it restarts during the
+ // update, otherwise we're left in a state where the addon is enabled
+ // but not started. We use the bootstrap started state to check that.
+ // isActive alone is not sufficient as that changes the characteristics
+ // of other updates and breaks many tests.
+ let restart =
+ isActive && lazy.XPIInternal.BootstrapScope.get(currentAddon).started;
+ if (restart) {
+ logger.warn(
+ `Updating and restart addon ${previousAddon.id} that changed on disk after being already started.`
+ );
+ }
+ promise = lazy.XPIInternal.BootstrapScope.get(previousAddon).update(
+ currentAddon,
+ restart
+ );
+ }
+
+ if (isActive != wasActive) {
+ let change = isActive
+ ? lazy.AddonManager.STARTUP_CHANGE_ENABLED
+ : lazy.AddonManager.STARTUP_CHANGE_DISABLED;
+ lazy.AddonManagerPrivate.addStartupChange(change, id);
+ }
+ } else if (xpiState && xpiState.wasRestored) {
+ isActive = xpiState.enabled;
+
+ if (currentAddon.isWebExtension && currentAddon.type == "theme") {
+ currentAddon.userDisabled = !isActive;
+ }
+
+ // If the add-on wasn't active and it isn't already disabled in some way
+ // then it was probably either softDisabled or userDisabled
+ if (!isActive && !currentAddon.disabled) {
+ // If the add-on is softblocked then assume it is softDisabled
+ if (
+ currentAddon.blocklistState == Services.blocklist.STATE_SOFTBLOCKED
+ ) {
+ currentAddon.softDisabled = true;
+ } else {
+ currentAddon.userDisabled = true;
+ }
+ }
+ } else {
+ lazy.AddonManagerPrivate.addStartupChange(
+ lazy.AddonManager.STARTUP_CHANGE_INSTALLED,
+ id
+ );
+ let scope = lazy.XPIInternal.BootstrapScope.get(currentAddon);
+ scope.install();
+ }
+
+ XPIDatabase.makeAddonVisible(currentAddon);
+ currentAddon.active = isActive;
+ return promise;
+ },
+};
diff --git a/toolkit/mozapps/extensions/internal/XPIInstall.jsm b/toolkit/mozapps/extensions/internal/XPIInstall.jsm
new file mode 100644
index 0000000000..b678a4f95b
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/XPIInstall.jsm
@@ -0,0 +1,4845 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * This file contains most of the logic required to install extensions.
+ * In general, we try to avoid loading it until extension installation
+ * or update is required. Please keep that in mind when deciding whether
+ * to add code here or elsewhere.
+ */
+
+/**
+ * @typedef {number} integer
+ */
+
+/* eslint "valid-jsdoc": [2, {requireReturn: false, requireReturnDescription: false, prefer: {return: "returns"}}] */
+
+var EXPORTED_SYMBOLS = [
+ "UpdateChecker",
+ "XPIInstall",
+ "verifyBundleSignedState",
+];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { computeSha256HashAsString, getHashStringForCrypto } =
+ ChromeUtils.importESModule(
+ "resource://gre/modules/addons/crypto-utils.sys.mjs"
+ );
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { AddonManager, AddonManagerPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
+ AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs",
+ CertUtils: "resource://gre/modules/CertUtils.sys.mjs",
+ ExtensionData: "resource://gre/modules/Extension.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ ProductAddonChecker:
+ "resource://gre/modules/addons/ProductAddonChecker.sys.mjs",
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ BuiltInThemesHelpers: "resource://gre/modules/addons/XPIDatabase.jsm",
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+ AddonInternal: "resource://gre/modules/addons/XPIDatabase.jsm",
+ XPIDatabase: "resource://gre/modules/addons/XPIDatabase.jsm",
+ XPIInternal: "resource://gre/modules/addons/XPIProvider.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "IconDetails", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+ ).ExtensionParent.IconDetails;
+});
+
+const { nsIBlocklistService } = Ci;
+
+const nsIFile = Components.Constructor(
+ "@mozilla.org/file/local;1",
+ "nsIFile",
+ "initWithPath"
+);
+
+const BinaryOutputStream = Components.Constructor(
+ "@mozilla.org/binaryoutputstream;1",
+ "nsIBinaryOutputStream",
+ "setOutputStream"
+);
+const CryptoHash = Components.Constructor(
+ "@mozilla.org/security/hash;1",
+ "nsICryptoHash",
+ "initWithString"
+);
+const FileInputStream = Components.Constructor(
+ "@mozilla.org/network/file-input-stream;1",
+ "nsIFileInputStream",
+ "init"
+);
+const FileOutputStream = Components.Constructor(
+ "@mozilla.org/network/file-output-stream;1",
+ "nsIFileOutputStream",
+ "init"
+);
+const ZipReader = Components.Constructor(
+ "@mozilla.org/libjar/zip-reader;1",
+ "nsIZipReader",
+ "open"
+);
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ gCertDB: ["@mozilla.org/security/x509certdb;1", "nsIX509CertDB"],
+});
+
+const PREF_INSTALL_REQUIRESECUREORIGIN =
+ "extensions.install.requireSecureOrigin";
+const PREF_PENDING_OPERATIONS = "extensions.pendingOperations";
+const PREF_SYSTEM_ADDON_UPDATE_URL = "extensions.systemAddon.update.url";
+const PREF_XPI_ENABLED = "xpinstall.enabled";
+const PREF_XPI_DIRECT_WHITELISTED = "xpinstall.whitelist.directRequest";
+const PREF_XPI_FILE_WHITELISTED = "xpinstall.whitelist.fileRequest";
+const PREF_XPI_WHITELIST_REQUIRED = "xpinstall.whitelist.required";
+
+const PREF_SELECTED_THEME = "extensions.activeThemeID";
+
+const TOOLKIT_ID = "toolkit@mozilla.org";
+
+/**
+ * Returns a nsIFile instance for the given path, relative to the given
+ * base file, if provided.
+ *
+ * @param {string} path
+ * The (possibly relative) path of the file.
+ * @param {nsIFile} [base]
+ * An optional file to use as a base path if `path` is relative.
+ * @returns {nsIFile}
+ */
+function getFile(path, base = null) {
+ // First try for an absolute path, as we get in the case of proxy
+ // files. Ideally we would try a relative path first, but on Windows,
+ // paths which begin with a drive letter are valid as relative paths,
+ // and treated as such.
+ try {
+ return new nsIFile(path);
+ } catch (e) {
+ // Ignore invalid relative paths. The only other error we should see
+ // here is EOM, and either way, any errors that we care about should
+ // be re-thrown below.
+ }
+
+ // If the path isn't absolute, we must have a base path.
+ let file = base.clone();
+ file.appendRelativePath(path);
+ return file;
+}
+
+/**
+ * Sends local and remote notifications to flush a JAR file cache entry
+ *
+ * @param {nsIFile} aJarFile
+ * The ZIP/XPI/JAR file as a nsIFile
+ */
+function flushJarCache(aJarFile) {
+ Services.obs.notifyObservers(aJarFile, "flush-cache-entry");
+ Services.ppmm.broadcastAsyncMessage(MSG_JAR_FLUSH, {
+ path: aJarFile.path,
+ });
+}
+
+const PREF_EM_UPDATE_BACKGROUND_URL = "extensions.update.background.url";
+const PREF_EM_UPDATE_URL = "extensions.update.url";
+const PREF_XPI_SIGNATURES_DEV_ROOT = "xpinstall.signatures.dev-root";
+
+const KEY_TEMPDIR = "TmpD";
+
+// This is a random number array that can be used as "salt" when generating
+// an automatic ID based on the directory path of an add-on. It will prevent
+// someone from creating an ID for a permanent add-on that could be replaced
+// by a temporary add-on (because that would be confusing, I guess).
+const TEMP_INSTALL_ID_GEN_SESSION = new Uint8Array(
+ Float64Array.of(Math.random()).buffer
+);
+
+const MSG_JAR_FLUSH = "Extension:FlushJarCache";
+
+/**
+ * Valid IDs fit this pattern.
+ */
+var gIDTest =
+ /^(\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}|[a-z0-9-\._]*\@[a-z0-9-\._]+)$/i;
+
+const { Log } = ChromeUtils.importESModule(
+ "resource://gre/modules/Log.sys.mjs"
+);
+const LOGGER_ID = "addons.xpi";
+
+// Create a new logger for use by all objects in this Addons XPI Provider module
+// (Requires AddonManager.jsm)
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+// Stores the ID of the theme which was selected during the last session,
+// if any. When installing a new built-in theme with this ID, it will be
+// automatically enabled.
+let lastSelectedTheme = null;
+
+function getJarURI(file, path = "") {
+ if (file instanceof Ci.nsIFile) {
+ file = Services.io.newFileURI(file);
+ }
+ if (file instanceof Ci.nsIURI) {
+ file = file.spec;
+ }
+ return Services.io.newURI(`jar:${file}!/${path}`);
+}
+
+let DirPackage;
+let XPIPackage;
+class Package {
+ static get(file) {
+ if (file.isFile()) {
+ return new XPIPackage(file);
+ }
+ return new DirPackage(file);
+ }
+
+ constructor(file, rootURI) {
+ this.file = file;
+ this.filePath = file.path;
+ this.rootURI = rootURI;
+ }
+
+ close() {}
+
+ async readString(...path) {
+ let buffer = await this.readBinary(...path);
+ return new TextDecoder().decode(buffer);
+ }
+
+ async verifySignedState(addonId, addonType, addonLocation) {
+ if (!shouldVerifySignedState(addonType, addonLocation)) {
+ return {
+ signedState: AddonManager.SIGNEDSTATE_NOT_REQUIRED,
+ cert: null,
+ };
+ }
+
+ let root = Ci.nsIX509CertDB.AddonsPublicRoot;
+ if (
+ !AppConstants.MOZ_REQUIRE_SIGNING &&
+ Services.prefs.getBoolPref(PREF_XPI_SIGNATURES_DEV_ROOT, false)
+ ) {
+ root = Ci.nsIX509CertDB.AddonsStageRoot;
+ }
+
+ return this.verifySignedStateForRoot(addonId, root);
+ }
+
+ flushCache() {}
+}
+
+DirPackage = class DirPackage extends Package {
+ constructor(file) {
+ super(file, Services.io.newFileURI(file));
+ }
+
+ hasResource(...path) {
+ return IOUtils.exists(PathUtils.join(this.filePath, ...path));
+ }
+
+ async iterDirectory(path, callback) {
+ let fullPath = PathUtils.join(this.filePath, ...path);
+
+ let children = await IOUtils.getChildren(fullPath);
+ for (let path of children) {
+ let { type } = await IOUtils.stat(path);
+ callback({
+ isDir: type == "directory",
+ name: PathUtils.filename(path),
+ path,
+ });
+ }
+ }
+
+ iterFiles(callback, path = []) {
+ return this.iterDirectory(path, async entry => {
+ let entryPath = [...path, entry.name];
+ if (entry.isDir) {
+ callback({
+ path: entryPath.join("/"),
+ isDir: true,
+ });
+ await this.iterFiles(callback, entryPath);
+ } else {
+ callback({
+ path: entryPath.join("/"),
+ isDir: false,
+ });
+ }
+ });
+ }
+
+ readBinary(...path) {
+ return IOUtils.read(PathUtils.join(this.filePath, ...path));
+ }
+
+ async verifySignedStateForRoot(addonId, root) {
+ return { signedState: AddonManager.SIGNEDSTATE_UNKNOWN, cert: null };
+ }
+};
+
+XPIPackage = class XPIPackage extends Package {
+ constructor(file) {
+ super(file, getJarURI(file));
+
+ this.zipReader = new ZipReader(file);
+ }
+
+ close() {
+ this.zipReader.close();
+ this.zipReader = null;
+ this.flushCache();
+ }
+
+ async hasResource(...path) {
+ return this.zipReader.hasEntry(path.join("/"));
+ }
+
+ async iterFiles(callback) {
+ for (let path of this.zipReader.findEntries("*")) {
+ let entry = this.zipReader.getEntry(path);
+ callback({
+ path,
+ isDir: entry.isDirectory,
+ });
+ }
+ }
+
+ async readBinary(...path) {
+ let response = await fetch(this.rootURI.resolve(path.join("/")));
+ return response.arrayBuffer();
+ }
+
+ verifySignedStateForRoot(addonId, root) {
+ return new Promise(resolve => {
+ let callback = {
+ openSignedAppFileFinished(aRv, aZipReader, aCert) {
+ if (aZipReader) {
+ aZipReader.close();
+ }
+ resolve({
+ signedState: getSignedStatus(aRv, aCert, addonId),
+ cert: aCert,
+ });
+ },
+ };
+ // This allows the certificate DB to get the raw JS callback object so the
+ // test code can pass through objects that XPConnect would reject.
+ callback.wrappedJSObject = callback;
+
+ lazy.gCertDB.openSignedAppFileAsync(root, this.file, callback);
+ });
+ }
+
+ flushCache() {
+ flushJarCache(this.file);
+ }
+};
+
+/**
+ * Return an object that implements enough of the Package interface
+ * to allow loadManifest() to work for a built-in addon (ie, one loaded
+ * from a resource: url)
+ *
+ * @param {nsIURL} baseURL The URL for the root of the add-on.
+ * @returns {object}
+ */
+function builtinPackage(baseURL) {
+ return {
+ rootURI: baseURL,
+ filePath: baseURL.spec,
+ file: null,
+ verifySignedState() {
+ return {
+ signedState: AddonManager.SIGNEDSTATE_NOT_REQUIRED,
+ cert: null,
+ };
+ },
+ async hasResource(path) {
+ try {
+ let response = await fetch(this.rootURI.resolve(path));
+ return response.ok;
+ } catch (e) {
+ return false;
+ }
+ },
+ };
+}
+
+/**
+ * Determine the reason to pass to an extension's bootstrap methods when
+ * switch between versions.
+ *
+ * @param {string} oldVersion The version of the existing extension instance.
+ * @param {string} newVersion The version of the extension being installed.
+ *
+ * @returns {integer}
+ * BOOSTRAP_REASONS.ADDON_UPGRADE or BOOSTRAP_REASONS.ADDON_DOWNGRADE
+ */
+function newVersionReason(oldVersion, newVersion) {
+ return Services.vc.compare(oldVersion, newVersion) <= 0
+ ? lazy.XPIInternal.BOOTSTRAP_REASONS.ADDON_UPGRADE
+ : lazy.XPIInternal.BOOTSTRAP_REASONS.ADDON_DOWNGRADE;
+}
+
+// Behaves like Promise.all except waits for all promises to resolve/reject
+// before resolving/rejecting itself
+function waitForAllPromises(promises) {
+ return new Promise((resolve, reject) => {
+ let shouldReject = false;
+ let rejectValue = null;
+
+ let newPromises = promises.map(p =>
+ p.catch(value => {
+ shouldReject = true;
+ rejectValue = value;
+ })
+ );
+ Promise.all(newPromises).then(results =>
+ shouldReject ? reject(rejectValue) : resolve(results)
+ );
+ });
+}
+
+/**
+ * Reads an AddonInternal object from a webextension manifest.json
+ *
+ * @param {Package} aPackage
+ * The install package for the add-on
+ * @param {XPIStateLocation} aLocation
+ * The install location the add-on is installed in, or will be
+ * installed to.
+ * @returns {{ addon: AddonInternal, verifiedSignedState: object}}
+ * @throws if the install manifest in the stream is corrupt or could not
+ * be read
+ */
+async function loadManifestFromWebManifest(aPackage, aLocation) {
+ let verifiedSignedState;
+ const temporarilyInstalled = aLocation.isTemporary;
+ let extension = await lazy.ExtensionData.constructAsync({
+ rootURI: lazy.XPIInternal.maybeResolveURI(aPackage.rootURI),
+ temporarilyInstalled,
+ async checkPrivileged(type, id) {
+ verifiedSignedState = await aPackage.verifySignedState(
+ id,
+ type,
+ aLocation
+ );
+ return lazy.ExtensionData.getIsPrivileged({
+ signedState: verifiedSignedState.signedState,
+ builtIn: aLocation.isBuiltin,
+ temporarilyInstalled,
+ });
+ },
+ });
+
+ let manifest = await extension.loadManifest();
+
+ // Read the list of available locales, and pre-load messages for
+ // all locales.
+ let locales = !extension.errors.length
+ ? await extension.initAllLocales()
+ : null;
+
+ if (extension.errors.length) {
+ let error = new Error("Extension is invalid");
+ // Add detailed errors on the error object so that the front end can display them
+ // if needed (eg in about:debugging).
+ error.additionalErrors = extension.errors;
+ throw error;
+ }
+
+ // Internally, we use the `applications` key but it is because we assign the value
+ // of `browser_specific_settings` to `applications` in `ExtensionData.parseManifest()`.
+ // Yet, as of MV3, only `browser_specific_settings` is accepted in manifest.json files.
+ let bss = manifest.applications?.gecko || {};
+
+ // A * is illegal in strict_min_version
+ if (bss.strict_min_version?.split(".").some(part => part == "*")) {
+ throw new Error("The use of '*' in strict_min_version is invalid");
+ }
+
+ let addon = new lazy.AddonInternal();
+ addon.id = bss.id;
+ addon.version = manifest.version;
+ addon.manifestVersion = manifest.manifest_version;
+ addon.type = extension.type;
+ addon.loader = null;
+ addon.strictCompatibility = true;
+ addon.internalName = null;
+ addon.updateURL = bss.update_url;
+ addon.installOrigins = manifest.install_origins;
+ addon.optionsBrowserStyle = true;
+ addon.optionsURL = null;
+ addon.optionsType = null;
+ addon.aboutURL = null;
+ addon.dependencies = Object.freeze(Array.from(extension.dependencies));
+ addon.startupData = extension.startupData;
+ addon.hidden = extension.isPrivileged && manifest.hidden;
+ addon.incognito = manifest.incognito;
+
+ if (addon.type === "theme" && (await aPackage.hasResource("preview.png"))) {
+ addon.previewImage = "preview.png";
+ }
+
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ if (addon.type == "sitepermission-deprecated") {
+ addon.sitePermissions = manifest.site_permissions;
+ addon.siteOrigin = manifest.install_origins[0];
+ }
+
+ if (manifest.options_ui) {
+ // Store just the relative path here, the AddonWrapper getURL
+ // wrapper maps this to a full URL.
+ addon.optionsURL = manifest.options_ui.page;
+ if (manifest.options_ui.open_in_tab) {
+ addon.optionsType = AddonManager.OPTIONS_TYPE_TAB;
+ } else {
+ addon.optionsType = AddonManager.OPTIONS_TYPE_INLINE_BROWSER;
+ }
+
+ addon.optionsBrowserStyle = manifest.options_ui.browser_style;
+ }
+
+ // WebExtensions don't use iconURLs
+ addon.iconURL = null;
+ addon.icons = manifest.icons || {};
+ addon.userPermissions = extension.manifestPermissions;
+ addon.optionalPermissions = extension.manifestOptionalPermissions;
+ addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT;
+
+ function getLocale(aLocale) {
+ // Use the raw manifest, here, since we need values with their
+ // localization placeholders still in place.
+ let rawManifest = extension.rawManifest;
+
+ // As a convenience, allow author to be set if its a string bug 1313567.
+ let creator =
+ typeof rawManifest.author === "string" ? rawManifest.author : null;
+ let homepageURL = rawManifest.homepage_url;
+
+ // Allow developer to override creator and homepage_url.
+ if (rawManifest.developer) {
+ if (rawManifest.developer.name) {
+ creator = rawManifest.developer.name;
+ }
+ if (rawManifest.developer.url) {
+ homepageURL = rawManifest.developer.url;
+ }
+ }
+
+ let result = {
+ name: extension.localize(rawManifest.name, aLocale),
+ description: extension.localize(rawManifest.description, aLocale),
+ creator: extension.localize(creator, aLocale),
+ homepageURL: extension.localize(homepageURL, aLocale),
+
+ developers: null,
+ translators: null,
+ contributors: null,
+ locales: [aLocale],
+ };
+ return result;
+ }
+
+ addon.defaultLocale = getLocale(extension.defaultLocale);
+ addon.locales = Array.from(locales.keys(), getLocale);
+
+ delete addon.defaultLocale.locales;
+
+ addon.targetApplications = [
+ {
+ id: TOOLKIT_ID,
+ minVersion: bss.strict_min_version,
+ maxVersion: bss.strict_max_version,
+ },
+ ];
+
+ addon.targetPlatforms = [];
+ // Themes are disabled by default, except when they're installed from a web page.
+ addon.userDisabled = extension.type === "theme";
+ addon.softDisabled =
+ addon.blocklistState == nsIBlocklistService.STATE_SOFTBLOCKED;
+
+ return { addon, verifiedSignedState };
+}
+
+async function readRecommendationStates(aPackage, aAddonID) {
+ let recommendationData;
+ try {
+ recommendationData = await aPackage.readString(
+ "mozilla-recommendation.json"
+ );
+ } catch (e) {
+ // Ignore I/O errors.
+ return null;
+ }
+
+ try {
+ recommendationData = JSON.parse(recommendationData);
+ } catch (e) {
+ logger.warn("Failed to parse recommendation", e);
+ }
+
+ if (recommendationData) {
+ let { addon_id, states, validity } = recommendationData;
+
+ if (addon_id === aAddonID && Array.isArray(states) && validity) {
+ let validNotAfter = Date.parse(validity.not_after);
+ let validNotBefore = Date.parse(validity.not_before);
+ if (validNotAfter && validNotBefore) {
+ return {
+ validNotAfter,
+ validNotBefore,
+ states,
+ };
+ }
+ }
+ logger.warn(
+ `Invalid recommendation for ${aAddonID}: ${JSON.stringify(
+ recommendationData
+ )}`
+ );
+ }
+
+ return null;
+}
+
+function defineSyncGUID(aAddon) {
+ // Define .syncGUID as a lazy property which is also settable
+ Object.defineProperty(aAddon, "syncGUID", {
+ get: () => {
+ aAddon.syncGUID = Services.uuid.generateUUID().toString();
+ return aAddon.syncGUID;
+ },
+ set: val => {
+ delete aAddon.syncGUID;
+ aAddon.syncGUID = val;
+ },
+ configurable: true,
+ enumerable: true,
+ });
+}
+
+// Generate a unique ID based on the path to this temporary add-on location.
+function generateTemporaryInstallID(aFile) {
+ const hasher = CryptoHash("sha1");
+ const data = new TextEncoder().encode(aFile.path);
+ // Make it so this ID cannot be guessed.
+ const sess = TEMP_INSTALL_ID_GEN_SESSION;
+ hasher.update(sess, sess.length);
+ hasher.update(data, data.length);
+ let id = `${getHashStringForCrypto(hasher)}${
+ lazy.XPIInternal.TEMPORARY_ADDON_SUFFIX
+ }`;
+ logger.info(`Generated temp id ${id} (${sess.join("")}) for ${aFile.path}`);
+ return id;
+}
+
+var loadManifest = async function (aPackage, aLocation, aOldAddon) {
+ let addon;
+ let verifiedSignedState;
+ if (await aPackage.hasResource("manifest.json")) {
+ ({ addon, verifiedSignedState } = await loadManifestFromWebManifest(
+ aPackage,
+ aLocation
+ ));
+ } else {
+ // TODO bug 1674799: Remove this unused branch.
+ for (let loader of AddonManagerPrivate.externalExtensionLoaders.values()) {
+ if (await aPackage.hasResource(loader.manifestFile)) {
+ addon = await loader.loadManifest(aPackage);
+ addon.loader = loader.name;
+ verifiedSignedState = await aPackage.verifySignedState(
+ addon.id,
+ addon.type,
+ aLocation
+ );
+ break;
+ }
+ }
+ }
+
+ if (!addon) {
+ throw new Error(
+ `File ${aPackage.filePath} does not contain a valid manifest`
+ );
+ }
+
+ addon._sourceBundle = aPackage.file;
+ addon.rootURI = aPackage.rootURI.spec;
+ addon.location = aLocation;
+
+ let { signedState, cert } = verifiedSignedState;
+ addon.signedState = signedState;
+ addon.signedDate = cert?.validity?.notBefore / 1000 || null;
+
+ if (!addon.id) {
+ if (cert) {
+ addon.id = cert.commonName;
+ if (!gIDTest.test(addon.id)) {
+ throw new Error(`Extension is signed with an invalid id (${addon.id})`);
+ }
+ }
+ if (!addon.id && aLocation.isTemporary) {
+ addon.id = generateTemporaryInstallID(aPackage.file);
+ }
+ }
+
+ addon.propagateDisabledState(aOldAddon);
+ if (!aLocation.isSystem && !aLocation.isBuiltin) {
+ if (addon.type === "extension" && !aLocation.isTemporary) {
+ addon.recommendationState = await readRecommendationStates(
+ aPackage,
+ addon.id
+ );
+ }
+
+ await addon.updateBlocklistState();
+ addon.appDisabled = !lazy.XPIDatabase.isUsableAddon(addon);
+
+ // Always report when there is an attempt to install a blocked add-on.
+ // (transitions from STATE_BLOCKED to STATE_NOT_BLOCKED are checked
+ // in the individual AddonInstall subclasses).
+ if (addon.blocklistState == nsIBlocklistService.STATE_BLOCKED) {
+ addon.recordAddonBlockChangeTelemetry(
+ aOldAddon ? "addon_update" : "addon_install"
+ );
+ }
+ }
+
+ defineSyncGUID(addon);
+
+ return addon;
+};
+
+/**
+ * Loads an add-on's manifest from the given file or directory.
+ *
+ * @param {nsIFile} aFile
+ * The file to load the manifest from.
+ * @param {XPIStateLocation} aLocation
+ * The install location the add-on is installed in, or will be
+ * installed to.
+ * @param {AddonInternal?} aOldAddon
+ * The currently-installed add-on with the same ID, if one exist.
+ * This is used to migrate user settings like the add-on's
+ * disabled state.
+ * @returns {AddonInternal}
+ * The parsed Addon object for the file's manifest.
+ */
+var loadManifestFromFile = async function (aFile, aLocation, aOldAddon) {
+ let pkg = Package.get(aFile);
+ try {
+ let addon = await loadManifest(pkg, aLocation, aOldAddon);
+ return addon;
+ } finally {
+ pkg.close();
+ }
+};
+
+/*
+ * A synchronous method for loading an add-on's manifest. Do not use
+ * this.
+ */
+function syncLoadManifest(state, location, oldAddon) {
+ if (location.name == "app-builtin") {
+ let pkg = builtinPackage(Services.io.newURI(state.rootURI));
+ return lazy.XPIInternal.awaitPromise(loadManifest(pkg, location, oldAddon));
+ }
+
+ let file = new nsIFile(state.path);
+ let pkg = Package.get(file);
+ return lazy.XPIInternal.awaitPromise(
+ (async () => {
+ try {
+ let addon = await loadManifest(pkg, location, oldAddon);
+ addon.rootURI = lazy.XPIInternal.getURIForResourceInFile(file, "").spec;
+ return addon;
+ } finally {
+ pkg.close();
+ }
+ })()
+ );
+}
+
+/**
+ * Creates and returns a new unique temporary file. The caller should delete
+ * the file when it is no longer needed.
+ *
+ * @returns {nsIFile}
+ * An nsIFile that points to a randomly named, initially empty file in
+ * the OS temporary files directory
+ */
+function getTemporaryFile() {
+ let file = lazy.FileUtils.getDir(KEY_TEMPDIR, []);
+ let random = Math.round(Math.random() * 36 ** 3).toString(36);
+ file.append(`tmp-${random}.xpi`);
+ file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, lazy.FileUtils.PERMS_FILE);
+ return file;
+}
+
+function getHashForFile(file, algorithm) {
+ let crypto = CryptoHash(algorithm);
+ let fis = new FileInputStream(file, -1, -1, false);
+ try {
+ crypto.updateFromStream(fis, file.fileSize);
+ } finally {
+ fis.close();
+ }
+ return getHashStringForCrypto(crypto);
+}
+
+/**
+ * Returns the signedState for a given return code and certificate by verifying
+ * it against the expected ID.
+ *
+ * @param {nsresult} aRv
+ * The result code returned by the signature checker for the
+ * signature check operation.
+ * @param {nsIX509Cert?} aCert
+ * The certificate the add-on was signed with, if a valid
+ * certificate exists.
+ * @param {string?} aAddonID
+ * The expected ID of the add-on. If passed, this must match the
+ * ID in the certificate's CN field.
+ * @returns {number}
+ * A SIGNEDSTATE result code constant, as defined on the
+ * AddonManager class.
+ */
+function getSignedStatus(aRv, aCert, aAddonID) {
+ let expectedCommonName = aAddonID;
+ if (aAddonID && aAddonID.length > 64) {
+ expectedCommonName = computeSha256HashAsString(aAddonID);
+ }
+
+ switch (aRv) {
+ case Cr.NS_OK:
+ if (expectedCommonName && expectedCommonName != aCert.commonName) {
+ return AddonManager.SIGNEDSTATE_BROKEN;
+ }
+
+ if (aCert.organizationalUnit == "Mozilla Components") {
+ return AddonManager.SIGNEDSTATE_SYSTEM;
+ }
+
+ if (aCert.organizationalUnit == "Mozilla Extensions") {
+ return AddonManager.SIGNEDSTATE_PRIVILEGED;
+ }
+
+ return /preliminary/i.test(aCert.organizationalUnit)
+ ? AddonManager.SIGNEDSTATE_PRELIMINARY
+ : AddonManager.SIGNEDSTATE_SIGNED;
+ case Cr.NS_ERROR_SIGNED_JAR_NOT_SIGNED:
+ return AddonManager.SIGNEDSTATE_MISSING;
+ case Cr.NS_ERROR_SIGNED_JAR_MANIFEST_INVALID:
+ case Cr.NS_ERROR_SIGNED_JAR_ENTRY_INVALID:
+ case Cr.NS_ERROR_SIGNED_JAR_ENTRY_MISSING:
+ case Cr.NS_ERROR_SIGNED_JAR_ENTRY_TOO_LARGE:
+ case Cr.NS_ERROR_SIGNED_JAR_UNSIGNED_ENTRY:
+ case Cr.NS_ERROR_SIGNED_JAR_MODIFIED_ENTRY:
+ return AddonManager.SIGNEDSTATE_BROKEN;
+ default:
+ // Any other error indicates that either the add-on isn't signed or it
+ // is signed by a signature that doesn't chain to the trusted root.
+ return AddonManager.SIGNEDSTATE_UNKNOWN;
+ }
+}
+
+function shouldVerifySignedState(aAddonType, aLocation) {
+ // TODO when KEY_APP_SYSTEM_DEFAULTS and KEY_APP_SYSTEM_ADDONS locations
+ // are removed, we need to reorganize the logic here. At that point we
+ // should:
+ // if builtin or MOZ_UNSIGNED_SCOPES return false
+ // if system return true
+ // return SIGNED_TYPES.has(type)
+
+ // We don't care about signatures for default system add-ons
+ if (aLocation.name == lazy.XPIInternal.KEY_APP_SYSTEM_DEFAULTS) {
+ return false;
+ }
+
+ // Updated system add-ons should always have their signature checked
+ if (aLocation.isSystem) {
+ return true;
+ }
+
+ if (
+ aLocation.isBuiltin ||
+ aLocation.scope & AppConstants.MOZ_UNSIGNED_SCOPES
+ ) {
+ return false;
+ }
+
+ // Otherwise only check signatures if the add-on is one of the signed
+ // types.
+ return lazy.XPIDatabase.SIGNED_TYPES.has(aAddonType);
+}
+
+/**
+ * Verifies that a bundle's contents are all correctly signed by an
+ * AMO-issued certificate
+ *
+ * @param {nsIFile} aBundle
+ * The nsIFile for the bundle to check, either a directory or zip file.
+ * @param {AddonInternal} aAddon
+ * The add-on object to verify.
+ * @returns {Promise<number>}
+ * A Promise that resolves to an AddonManager.SIGNEDSTATE_* constant.
+ */
+var verifyBundleSignedState = async function (aBundle, aAddon) {
+ let pkg = Package.get(aBundle);
+ try {
+ let { signedState } = await pkg.verifySignedState(
+ aAddon.id,
+ aAddon.type,
+ aAddon.location
+ );
+ return signedState;
+ } finally {
+ pkg.close();
+ }
+};
+
+/**
+ * Replaces %...% strings in an addon url (update and updateInfo) with
+ * appropriate values.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal representing the add-on
+ * @param {string} aUri
+ * The URI to escape
+ * @param {integer?} aUpdateType
+ * An optional number representing the type of update, only applicable
+ * when creating a url for retrieving an update manifest
+ * @param {string?} aAppVersion
+ * The optional application version to use for %APP_VERSION%
+ * @returns {string}
+ * The appropriately escaped URI.
+ */
+function escapeAddonURI(aAddon, aUri, aUpdateType, aAppVersion) {
+ let uri = AddonManager.escapeAddonURI(aAddon, aUri, aAppVersion);
+
+ // If there is an updateType then replace the UPDATE_TYPE string
+ if (aUpdateType) {
+ uri = uri.replace(/%UPDATE_TYPE%/g, aUpdateType);
+ }
+
+ // If this add-on has compatibility information for either the current
+ // application or toolkit then replace the ITEM_MAXAPPVERSION with the
+ // maxVersion
+ let app = aAddon.matchingTargetApplication;
+ if (app) {
+ var maxVersion = app.maxVersion;
+ } else {
+ maxVersion = "";
+ }
+ uri = uri.replace(/%ITEM_MAXAPPVERSION%/g, maxVersion);
+
+ let compatMode = "normal";
+ if (!AddonManager.checkCompatibility) {
+ compatMode = "ignore";
+ } else if (AddonManager.strictCompatibility) {
+ compatMode = "strict";
+ }
+ uri = uri.replace(/%COMPATIBILITY_MODE%/g, compatMode);
+
+ return uri;
+}
+
+/**
+ * Converts an iterable of addon objects into a map with the add-on's ID as key.
+ *
+ * @param {sequence<AddonInternal>} addons
+ * A sequence of AddonInternal objects.
+ *
+ * @returns {Map<string, AddonInternal>}
+ */
+function addonMap(addons) {
+ return new Map(addons.map(a => [a.id, a]));
+}
+
+async function removeAsync(aFile) {
+ await IOUtils.remove(aFile.path, { ignoreAbsent: true, recursive: true });
+}
+
+/**
+ * Recursively removes a directory or file fixing permissions when necessary.
+ *
+ * @param {nsIFile} aFile
+ * The nsIFile to remove
+ */
+function recursiveRemove(aFile) {
+ let isDir = null;
+
+ try {
+ isDir = aFile.isDirectory();
+ } catch (e) {
+ // If the file has already gone away then don't worry about it, this can
+ // happen on OSX where the resource fork is automatically moved with the
+ // data fork for the file. See bug 733436.
+ if (e.result == Cr.NS_ERROR_FILE_NOT_FOUND) {
+ return;
+ }
+
+ throw e;
+ }
+
+ setFilePermissions(
+ aFile,
+ isDir ? lazy.FileUtils.PERMS_DIRECTORY : lazy.FileUtils.PERMS_FILE
+ );
+
+ try {
+ aFile.remove(true);
+ return;
+ } catch (e) {
+ if (!aFile.isDirectory() || aFile.isSymlink()) {
+ logger.error("Failed to remove file " + aFile.path, e);
+ throw e;
+ }
+ }
+
+ // Use a snapshot of the directory contents to avoid possible issues with
+ // iterating over a directory while removing files from it (the YAFFS2
+ // embedded filesystem has this issue, see bug 772238), and to remove
+ // normal files before their resource forks on OSX (see bug 733436).
+ let entries = Array.from(lazy.XPIInternal.iterDirectory(aFile));
+ entries.forEach(recursiveRemove);
+
+ try {
+ aFile.remove(true);
+ } catch (e) {
+ logger.error("Failed to remove empty directory " + aFile.path, e);
+ throw e;
+ }
+}
+
+/**
+ * Sets permissions on a file
+ *
+ * @param {nsIFile} aFile
+ * The file or directory to operate on.
+ * @param {integer} aPermissions
+ * The permissions to set
+ */
+function setFilePermissions(aFile, aPermissions) {
+ try {
+ aFile.permissions = aPermissions;
+ } catch (e) {
+ logger.warn(
+ "Failed to set permissions " +
+ aPermissions.toString(8) +
+ " on " +
+ aFile.path,
+ e
+ );
+ }
+}
+
+/**
+ * Write a given string to a file
+ *
+ * @param {nsIFile} file
+ * The nsIFile instance to write into
+ * @param {string} string
+ * The string to write
+ */
+function writeStringToFile(file, string) {
+ let fileStream = new FileOutputStream(
+ file,
+ lazy.FileUtils.MODE_WRONLY |
+ lazy.FileUtils.MODE_CREATE |
+ lazy.FileUtils.MODE_TRUNCATE,
+ lazy.FileUtils.PERMS_FILE,
+ 0
+ );
+
+ try {
+ let binStream = new BinaryOutputStream(fileStream);
+
+ binStream.writeByteArray(new TextEncoder().encode(string));
+ } finally {
+ fileStream.close();
+ }
+}
+
+/**
+ * A safe way to install a file or the contents of a directory to a new
+ * directory. The file or directory is moved or copied recursively and if
+ * anything fails an attempt is made to rollback the entire operation. The
+ * operation may also be rolled back to its original state after it has
+ * completed by calling the rollback method.
+ *
+ * Operations can be chained. Calling move or copy multiple times will remember
+ * the whole set and if one fails all of the operations will be rolled back.
+ */
+function SafeInstallOperation() {
+ this._installedFiles = [];
+ this._createdDirs = [];
+}
+
+SafeInstallOperation.prototype = {
+ _installedFiles: null,
+ _createdDirs: null,
+
+ _installFile(aFile, aTargetDirectory, aCopy) {
+ let oldFile = aCopy ? null : aFile.clone();
+ let newFile = aFile.clone();
+ try {
+ if (aCopy) {
+ newFile.copyTo(aTargetDirectory, null);
+ // copyTo does not update the nsIFile with the new.
+ newFile = getFile(aFile.leafName, aTargetDirectory);
+ // Windows roaming profiles won't properly sync directories if a new file
+ // has an older lastModifiedTime than a previous file, so update.
+ newFile.lastModifiedTime = Date.now();
+ } else {
+ newFile.moveTo(aTargetDirectory, null);
+ }
+ } catch (e) {
+ logger.error(
+ "Failed to " +
+ (aCopy ? "copy" : "move") +
+ " file " +
+ aFile.path +
+ " to " +
+ aTargetDirectory.path,
+ e
+ );
+ throw e;
+ }
+ this._installedFiles.push({ oldFile, newFile });
+ },
+
+ /**
+ * Moves a file or directory into a new directory. If an error occurs then all
+ * files that have been moved will be moved back to their original location.
+ *
+ * @param {nsIFile} aFile
+ * The file or directory to be moved.
+ * @param {nsIFile} aTargetDirectory
+ * The directory to move into, this is expected to be an empty
+ * directory.
+ */
+ moveUnder(aFile, aTargetDirectory) {
+ try {
+ this._installFile(aFile, aTargetDirectory, false);
+ } catch (e) {
+ this.rollback();
+ throw e;
+ }
+ },
+
+ /**
+ * Renames a file to a new location. If an error occurs then all
+ * files that have been moved will be moved back to their original location.
+ *
+ * @param {nsIFile} aOldLocation
+ * The old location of the file.
+ * @param {nsIFile} aNewLocation
+ * The new location of the file.
+ */
+ moveTo(aOldLocation, aNewLocation) {
+ try {
+ let oldFile = aOldLocation.clone(),
+ newFile = aNewLocation.clone();
+ oldFile.moveTo(newFile.parent, newFile.leafName);
+ this._installedFiles.push({ oldFile, newFile, isMoveTo: true });
+ } catch (e) {
+ this.rollback();
+ throw e;
+ }
+ },
+
+ /**
+ * Copies a file or directory into a new directory. If an error occurs then
+ * all new files that have been created will be removed.
+ *
+ * @param {nsIFile} aFile
+ * The file or directory to be copied.
+ * @param {nsIFile} aTargetDirectory
+ * The directory to copy into, this is expected to be an empty
+ * directory.
+ */
+ copy(aFile, aTargetDirectory) {
+ try {
+ this._installFile(aFile, aTargetDirectory, true);
+ } catch (e) {
+ this.rollback();
+ throw e;
+ }
+ },
+
+ /**
+ * Rolls back all the moves that this operation performed. If an exception
+ * occurs here then both old and new directories are left in an indeterminate
+ * state
+ */
+ rollback() {
+ while (this._installedFiles.length) {
+ let move = this._installedFiles.pop();
+ if (move.isMoveTo) {
+ move.newFile.moveTo(move.oldDir.parent, move.oldDir.leafName);
+ } else if (move.newFile.isDirectory() && !move.newFile.isSymlink()) {
+ let oldDir = getFile(move.oldFile.leafName, move.oldFile.parent);
+ oldDir.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ lazy.FileUtils.PERMS_DIRECTORY
+ );
+ } else if (!move.oldFile) {
+ // No old file means this was a copied file
+ move.newFile.remove(true);
+ } else {
+ move.newFile.moveTo(move.oldFile.parent, null);
+ }
+ }
+
+ while (this._createdDirs.length) {
+ recursiveRemove(this._createdDirs.pop());
+ }
+ },
+};
+
+// A hash algorithm if the caller of AddonInstall did not specify one.
+const DEFAULT_HASH_ALGO = "sha256";
+
+/**
+ * Base class for objects that manage the installation of an addon.
+ * This class isn't instantiated directly, see the derived classes below.
+ */
+class AddonInstall {
+ /**
+ * Instantiates an AddonInstall.
+ *
+ * @param {XPIStateLocation} installLocation
+ * The install location the add-on will be installed into
+ * @param {nsIURL} url
+ * The nsIURL to get the add-on from. If this is an nsIFileURL then
+ * the add-on will not need to be downloaded
+ * @param {Object} [options = {}]
+ * Additional options for the install
+ * @param {string} [options.hash]
+ * An optional hash for the add-on
+ * @param {AddonInternal} [options.existingAddon]
+ * The add-on this install will update if known
+ * @param {string} [options.name]
+ * An optional name for the add-on
+ * @param {string} [options.type]
+ * An optional type for the add-on
+ * @param {object} [options.icons]
+ * Optional icons for the add-on
+ * @param {string} [options.version]
+ * The expected version for the add-on.
+ * Required for updates, i.e. when existingAddon is set.
+ * @param {Object?} [options.telemetryInfo]
+ * An optional object which provides details about the installation source
+ * included in the addon manager telemetry events.
+ * @param {boolean} [options.isUserRequestedUpdate]
+ * An optional boolean, true if the install object is related to a user triggered update.
+ * @param {nsIURL} [options.releaseNotesURI]
+ * An optional nsIURL that release notes where release notes can be retrieved.
+ * @param {function(string) : Promise<void>} [options.promptHandler]
+ * A callback to prompt the user before installing.
+ */
+ constructor(installLocation, url, options = {}) {
+ this.wrapper = new AddonInstallWrapper(this);
+ this.location = installLocation;
+ this.sourceURI = url;
+
+ if (options.hash) {
+ let hashSplit = options.hash.toLowerCase().split(":");
+ this.originalHash = {
+ algorithm: hashSplit[0],
+ data: hashSplit[1],
+ };
+ }
+ this.hash = this.originalHash;
+ this.fileHash = null;
+ this.existingAddon = options.existingAddon || null;
+ this.promptHandler = options.promptHandler || (() => Promise.resolve());
+ this.releaseNotesURI = options.releaseNotesURI || null;
+
+ this._startupPromise = null;
+
+ this._installPromise = new Promise(resolve => {
+ this._resolveInstallPromise = resolve;
+ });
+ // Ignore uncaught rejections for this promise, since they're
+ // handled by install listeners.
+ this._installPromise.catch(() => {});
+
+ this.listeners = [];
+ this.icons = options.icons || {};
+ this.error = 0;
+
+ this.progress = 0;
+ this.maxProgress = -1;
+
+ // Giving each instance of AddonInstall a reference to the logger.
+ this.logger = logger;
+
+ this.name = options.name || null;
+ this.type = options.type || null;
+ this.version = options.version || null;
+ this.isUserRequestedUpdate = options.isUserRequestedUpdate;
+ this.installTelemetryInfo = null;
+
+ if (options.telemetryInfo) {
+ this.installTelemetryInfo = options.telemetryInfo;
+ } else if (this.existingAddon) {
+ // Inherits the installTelemetryInfo on updates (so that the source of the original
+ // installation telemetry data is being preserved across the extension updates).
+ this.installTelemetryInfo = this.existingAddon.installTelemetryInfo;
+ this.existingAddon._updateInstall = this;
+ }
+
+ this.file = null;
+ this.ownsTempFile = null;
+
+ this.addon = null;
+ this.state = null;
+
+ XPIInstall.installs.add(this);
+ }
+
+ /**
+ * Called when we are finished with this install and are ready to remove
+ * any external references to it.
+ */
+ _cleanup() {
+ XPIInstall.installs.delete(this);
+ if (this.addon && this.addon._install) {
+ if (this.addon._install === this) {
+ this.addon._install = null;
+ } else {
+ Cu.reportError(new Error("AddonInstall mismatch"));
+ }
+ }
+ if (this.existingAddon && this.existingAddon._updateInstall) {
+ if (this.existingAddon._updateInstall === this) {
+ this.existingAddon._updateInstall = null;
+ } else {
+ Cu.reportError(new Error("AddonInstall existingAddon mismatch"));
+ }
+ }
+ }
+
+ /**
+ * Starts installation of this add-on from whatever state it is currently at
+ * if possible.
+ *
+ * Note this method is overridden to handle additional state in
+ * the subclassses below.
+ *
+ * @returns {Promise<Addon>}
+ * @throws if installation cannot proceed from the current state
+ */
+ install() {
+ switch (this.state) {
+ case AddonManager.STATE_DOWNLOADED:
+ this.checkPrompt();
+ break;
+ case AddonManager.STATE_PROMPTS_DONE:
+ this.checkForBlockers();
+ break;
+ case AddonManager.STATE_READY:
+ this.startInstall();
+ break;
+ case AddonManager.STATE_POSTPONED:
+ logger.debug(`Postponing install of ${this.addon.id}`);
+ break;
+ case AddonManager.STATE_DOWNLOADING:
+ case AddonManager.STATE_CHECKING_UPDATE:
+ case AddonManager.STATE_INSTALLING:
+ // Installation is already running
+ break;
+ default:
+ throw new Error("Cannot start installing from this state");
+ }
+ return this._installPromise;
+ }
+
+ continuePostponedInstall() {
+ if (this.state !== AddonManager.STATE_POSTPONED) {
+ throw new Error("AddonInstall not in postponed state");
+ }
+
+ // Force the postponed install to continue.
+ logger.info(`${this.addon.id} has resumed a previously postponed upgrade`);
+ this.state = AddonManager.STATE_READY;
+ this.install();
+ }
+
+ /**
+ * Called during XPIProvider shutdown so that we can do any necessary
+ * pre-shutdown cleanup.
+ */
+ onShutdown() {
+ switch (this.state) {
+ case AddonManager.STATE_POSTPONED:
+ this.removeTemporaryFile();
+ break;
+ }
+ }
+
+ /**
+ * Cancels installation of this add-on.
+ *
+ * Note this method is overridden to handle additional state in
+ * the subclass DownloadAddonInstall.
+ *
+ * @throws if installation cannot be cancelled from the current state
+ */
+ cancel() {
+ switch (this.state) {
+ case AddonManager.STATE_AVAILABLE:
+ case AddonManager.STATE_DOWNLOADED:
+ logger.debug("Cancelling download of " + this.sourceURI.spec);
+ this.state = AddonManager.STATE_CANCELLED;
+ this._cleanup();
+ this._callInstallListeners("onDownloadCancelled");
+ this.removeTemporaryFile();
+ break;
+ case AddonManager.STATE_POSTPONED:
+ logger.debug(`Cancelling postponed install of ${this.addon.id}`);
+ this.state = AddonManager.STATE_CANCELLED;
+ this._cleanup();
+ this._callInstallListeners("onInstallCancelled");
+ this.removeTemporaryFile();
+
+ let stagingDir = this.location.installer.getStagingDir();
+ let stagedAddon = stagingDir.clone();
+
+ this.unstageInstall(stagedAddon);
+ break;
+ default:
+ throw new Error(
+ "Cannot cancel install of " +
+ this.sourceURI.spec +
+ " from this state (" +
+ this.state +
+ ")"
+ );
+ }
+ }
+
+ /**
+ * Adds an InstallListener for this instance if the listener is not already
+ * registered.
+ *
+ * @param {InstallListener} aListener
+ * The InstallListener to add
+ */
+ addListener(aListener) {
+ if (
+ !this.listeners.some(function (i) {
+ return i == aListener;
+ })
+ ) {
+ this.listeners.push(aListener);
+ }
+ }
+
+ /**
+ * Removes an InstallListener for this instance if it is registered.
+ *
+ * @param {InstallListener} aListener
+ * The InstallListener to remove
+ */
+ removeListener(aListener) {
+ this.listeners = this.listeners.filter(function (i) {
+ return i != aListener;
+ });
+ }
+
+ /**
+ * Removes the temporary file owned by this AddonInstall if there is one.
+ */
+ removeTemporaryFile() {
+ // Only proceed if this AddonInstall owns its XPI file
+ if (!this.ownsTempFile) {
+ this.logger.debug(
+ `removeTemporaryFile: ${this.sourceURI.spec} does not own temp file`
+ );
+ return;
+ }
+
+ try {
+ this.logger.debug(
+ `removeTemporaryFile: ${this.sourceURI.spec} removing temp file ` +
+ this.file.path
+ );
+ flushJarCache(this.file);
+ this.file.remove(true);
+ this.ownsTempFile = false;
+ } catch (e) {
+ this.logger.warn(
+ `Failed to remove temporary file ${this.file.path} for addon ` +
+ this.sourceURI.spec,
+ e
+ );
+ }
+ }
+
+ _setFileHash(calculatedHash) {
+ this.fileHash = {
+ algorithm: this.hash ? this.hash.algorithm : DEFAULT_HASH_ALGO,
+ data: calculatedHash,
+ };
+
+ if (this.hash && calculatedHash != this.hash.data) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Updates the addon metadata that has to be propagated across restarts.
+ */
+ updatePersistedMetadata() {
+ this.addon.sourceURI = this.sourceURI.spec;
+
+ if (this.releaseNotesURI) {
+ this.addon.releaseNotesURI = this.releaseNotesURI.spec;
+ }
+
+ if (this.installTelemetryInfo) {
+ this.addon.installTelemetryInfo = this.installTelemetryInfo;
+ }
+ }
+
+ /**
+ * Called after the add-on is a local file and the signature and install
+ * manifest can be read.
+ *
+ * @param {nsIFile} file
+ * The file from which to load the manifest.
+ * @returns {Promise<void>}
+ */
+ async loadManifest(file) {
+ let pkg;
+ try {
+ pkg = Package.get(file);
+ } catch (e) {
+ return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, e]);
+ }
+
+ try {
+ try {
+ this.addon = await loadManifest(pkg, this.location, this.existingAddon);
+ } catch (e) {
+ return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, e]);
+ }
+
+ if (!this.addon.id) {
+ let msg = `Cannot find id for addon ${file.path}.`;
+ if (Services.prefs.getBoolPref(PREF_XPI_SIGNATURES_DEV_ROOT, false)) {
+ msg += ` Preference ${PREF_XPI_SIGNATURES_DEV_ROOT} is set.`;
+ }
+
+ return Promise.reject([
+ AddonManager.ERROR_CORRUPT_FILE,
+ new Error(msg),
+ ]);
+ }
+
+ if (this.existingAddon) {
+ // Check various conditions related to upgrades
+ if (this.addon.id != this.existingAddon.id) {
+ return Promise.reject([
+ AddonManager.ERROR_INCORRECT_ID,
+ `Refusing to upgrade addon ${this.existingAddon.id} to different ID ${this.addon.id}`,
+ ]);
+ }
+
+ if (this.existingAddon.isWebExtension && !this.addon.isWebExtension) {
+ // This condition is never met on regular Firefox builds.
+ // Remove it along with externalExtensionLoaders (bug 1674799).
+ return Promise.reject([
+ AddonManager.ERROR_UNEXPECTED_ADDON_TYPE,
+ "WebExtensions may not be updated to other extension types",
+ ]);
+ }
+ if (this.existingAddon.type != this.addon.type) {
+ return Promise.reject([
+ AddonManager.ERROR_UNEXPECTED_ADDON_TYPE,
+ `Refusing to change addon type from ${this.existingAddon.type} to ${this.addon.type}`,
+ ]);
+ }
+
+ if (this.version !== this.addon.version) {
+ return Promise.reject([
+ AddonManager.ERROR_UNEXPECTED_ADDON_VERSION,
+ `Expected addon version ${this.version} instead of ${this.addon.version}`,
+ ]);
+ }
+ }
+
+ if (lazy.XPIDatabase.mustSign(this.addon.type)) {
+ if (this.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING) {
+ // This add-on isn't properly signed by a signature that chains to the
+ // trusted root.
+ let state = this.addon.signedState;
+ this.addon = null;
+
+ if (state == AddonManager.SIGNEDSTATE_MISSING) {
+ return Promise.reject([
+ AddonManager.ERROR_SIGNEDSTATE_REQUIRED,
+ "signature is required but missing",
+ ]);
+ }
+
+ return Promise.reject([
+ AddonManager.ERROR_CORRUPT_FILE,
+ "signature verification failed",
+ ]);
+ }
+ }
+ } finally {
+ pkg.close();
+ }
+
+ this.updatePersistedMetadata();
+
+ this.addon._install = this;
+ this.name = this.addon.selectedLocale.name;
+ this.type = this.addon.type;
+ this.version = this.addon.version;
+
+ // Setting the iconURL to something inside the XPI locks the XPI and
+ // makes it impossible to delete on Windows.
+
+ // Try to load from the existing cache first
+ let repoAddon = await lazy.AddonRepository.getCachedAddonByID(
+ this.addon.id
+ );
+
+ // It wasn't there so try to re-download it
+ if (!repoAddon) {
+ try {
+ [repoAddon] = await lazy.AddonRepository.cacheAddons([this.addon.id]);
+ } catch (err) {
+ logger.debug(
+ `Error getting metadata for ${this.addon.id}: ${err.message}`
+ );
+ }
+ }
+
+ this.addon._repositoryAddon = repoAddon;
+ this.name = this.name || this.addon._repositoryAddon.name;
+ this.addon.appDisabled = !lazy.XPIDatabase.isUsableAddon(this.addon);
+ return undefined;
+ }
+
+ getIcon(desiredSize = 64) {
+ if (!this.addon.icons || !this.file) {
+ return null;
+ }
+
+ let { icon } = lazy.IconDetails.getPreferredIcon(
+ this.addon.icons,
+ null,
+ desiredSize
+ );
+ if (icon.startsWith("chrome://")) {
+ return icon;
+ }
+ return getJarURI(this.file, icon).spec;
+ }
+
+ /**
+ * This method should be called when the XPI is ready to be installed,
+ * i.e., when a download finishes or when a local file has been verified.
+ * It should only be called from install() when the install is in
+ * STATE_DOWNLOADED (which actually means that the file is available
+ * and has been verified).
+ */
+ checkPrompt() {
+ (async () => {
+ if (this.promptHandler) {
+ let info = {
+ existingAddon: this.existingAddon ? this.existingAddon.wrapper : null,
+ addon: this.addon.wrapper,
+ icon: this.getIcon(),
+ // Used in AMTelemetry to detect the install flow related to this prompt.
+ install: this.wrapper,
+ };
+
+ try {
+ await this.promptHandler(info);
+ } catch (err) {
+ if (this.error < 0) {
+ logger.info(`Install of ${this.addon.id} failed ${this.error}`);
+ this.state = AddonManager.STATE_INSTALL_FAILED;
+ this._cleanup();
+ // In some cases onOperationCancelled is called during failures
+ // to install/uninstall/enable/disable addons. We may need to
+ // do that here in the future.
+ this._callInstallListeners("onInstallFailed");
+ this.removeTemporaryFile();
+ } else {
+ logger.info(`Install of ${this.addon.id} cancelled by user`);
+ this.state = AddonManager.STATE_CANCELLED;
+ this._cleanup();
+ this._callInstallListeners("onInstallCancelled");
+ }
+ return;
+ }
+ }
+ this.state = AddonManager.STATE_PROMPTS_DONE;
+ this.install();
+ })();
+ }
+
+ /**
+ * This method should be called when we have the XPI and any needed
+ * permissions prompts have been completed. If there are any upgrade
+ * listeners, they are invoked and the install moves into STATE_POSTPONED.
+ * Otherwise, the install moves into STATE_INSTALLING
+ */
+ checkForBlockers() {
+ // If an upgrade listener is registered for this add-on, pass control
+ // over the upgrade to the add-on.
+ if (AddonManagerPrivate.hasUpgradeListener(this.addon.id)) {
+ logger.info(
+ `add-on ${this.addon.id} has an upgrade listener, postponing upgrade until restart`
+ );
+ let resumeFn = () => {
+ this.continuePostponedInstall();
+ };
+ this.postpone(resumeFn);
+ return;
+ }
+
+ this.state = AddonManager.STATE_READY;
+ this.install();
+ }
+
+ /**
+ * Installs the add-on into the install location.
+ */
+ async startInstall() {
+ this.state = AddonManager.STATE_INSTALLING;
+ if (!this._callInstallListeners("onInstallStarted")) {
+ this.state = AddonManager.STATE_DOWNLOADED;
+ this.removeTemporaryFile();
+ this._cleanup();
+ this._callInstallListeners("onInstallCancelled");
+ return;
+ }
+
+ // Reinstall existing user-disabled addon (of the same installed version).
+ // If addon is marked to be uninstalled - don't reinstall it.
+ if (
+ this.existingAddon &&
+ this.existingAddon.location === this.location &&
+ this.existingAddon.version === this.addon.version &&
+ this.existingAddon.userDisabled &&
+ !this.existingAddon.pendingUninstall
+ ) {
+ await lazy.XPIDatabase.updateAddonDisabledState(this.existingAddon, {
+ userDisabled: false,
+ });
+ this.state = AddonManager.STATE_INSTALLED;
+ this._callInstallListeners("onInstallEnded", this.existingAddon.wrapper);
+ this._cleanup();
+ return;
+ }
+
+ let isSameLocation = this.existingAddon?.location == this.location;
+ let willActivate =
+ isSameLocation ||
+ !this.existingAddon ||
+ this.location.hasPrecedence(this.existingAddon.location);
+
+ logger.debug(
+ "Starting install of " + this.addon.id + " from " + this.sourceURI.spec
+ );
+ AddonManagerPrivate.callAddonListeners(
+ "onInstalling",
+ this.addon.wrapper,
+ false
+ );
+
+ let stagedAddon = this.location.installer.getStagingDir();
+
+ try {
+ await this.location.installer.requestStagingDir();
+
+ // remove any previously staged files
+ await this.unstageInstall(stagedAddon);
+
+ stagedAddon.append(`${this.addon.id}.xpi`);
+
+ await this.stageInstall(false, stagedAddon, isSameLocation);
+
+ this._cleanup();
+
+ let install = async () => {
+ // Mark this instance of the addon as inactive if it is being
+ // superseded by an addon in a different location.
+ if (
+ willActivate &&
+ this.existingAddon &&
+ this.existingAddon.active &&
+ !isSameLocation
+ ) {
+ lazy.XPIDatabase.updateAddonActive(this.existingAddon, false);
+ }
+
+ // Install the new add-on into its final location
+ let file = await this.location.installer.installAddon({
+ id: this.addon.id,
+ source: stagedAddon,
+ });
+
+ // Update the metadata in the database
+ this.addon.sourceBundle = file;
+ // If this addon will be the active addon, make it visible.
+ this.addon.visible = willActivate;
+
+ if (isSameLocation) {
+ this.addon = lazy.XPIDatabase.updateAddonMetadata(
+ this.existingAddon,
+ this.addon,
+ file.path
+ );
+ let state = this.location.get(this.addon.id);
+ if (state) {
+ state.syncWithDB(this.addon, true);
+ } else {
+ logger.warn(
+ "Unexpected missing XPI state for add-on ${id}",
+ this.addon
+ );
+ }
+ } else {
+ this.addon.active = this.addon.visible && !this.addon.disabled;
+ this.addon = lazy.XPIDatabase.addToDatabase(this.addon, file.path);
+ lazy.XPIInternal.XPIStates.addAddon(this.addon);
+ this.addon.installDate = this.addon.updateDate;
+ lazy.XPIDatabase.saveChanges();
+ }
+ lazy.XPIInternal.XPIStates.save();
+
+ AddonManagerPrivate.callAddonListeners(
+ "onInstalled",
+ this.addon.wrapper
+ );
+
+ logger.debug(`Install of ${this.sourceURI.spec} completed.`);
+ this.state = AddonManager.STATE_INSTALLED;
+ this._callInstallListeners("onInstallEnded", this.addon.wrapper);
+
+ lazy.XPIDatabase.recordAddonTelemetry(this.addon);
+
+ // Notify providers that a new theme has been enabled.
+ if (this.addon.type === "theme" && this.addon.active) {
+ AddonManagerPrivate.notifyAddonChanged(
+ this.addon.id,
+ this.addon.type
+ );
+ }
+
+ // Clear the colorways builtins migrated to a non-builtin themes
+ // form the list of the retained themes.
+ if (
+ this.existingAddon?.isBuiltinColorwayTheme &&
+ !this.addon.isBuiltin &&
+ lazy.BuiltInThemesHelpers.isColorwayMigrationEnabled
+ ) {
+ lazy.BuiltInThemesHelpers.unretainMigratedColorwayTheme(
+ this.addon.id
+ );
+ }
+ };
+
+ this._startupPromise = (async () => {
+ if (!willActivate) {
+ await install();
+ } else if (this.existingAddon) {
+ await lazy.XPIInternal.BootstrapScope.get(this.existingAddon).update(
+ this.addon,
+ !this.addon.disabled,
+ install
+ );
+
+ if (this.addon.disabled) {
+ flushJarCache(this.file);
+ }
+ } else {
+ await install();
+ await lazy.XPIInternal.BootstrapScope.get(this.addon).install(
+ undefined,
+ true
+ );
+ }
+ })();
+
+ await this._startupPromise;
+ } catch (e) {
+ logger.warn(
+ `Failed to install ${this.file.path} from ${this.sourceURI.spec} to ${stagedAddon.path}`,
+ e
+ );
+
+ if (stagedAddon.exists()) {
+ recursiveRemove(stagedAddon);
+ }
+ this.state = AddonManager.STATE_INSTALL_FAILED;
+ this.error = AddonManager.ERROR_FILE_ACCESS;
+ this._cleanup();
+ AddonManagerPrivate.callAddonListeners(
+ "onOperationCancelled",
+ this.addon.wrapper
+ );
+ this._callInstallListeners("onInstallFailed");
+ } finally {
+ this.removeTemporaryFile();
+ this.location.installer.releaseStagingDir();
+ }
+ }
+
+ /**
+ * Stages an add-on for install.
+ *
+ * @param {boolean} restartRequired
+ * If true, the final installation will be deferred until the
+ * next app startup.
+ * @param {nsIFile} stagedAddon
+ * The file where the add-on should be staged.
+ * @param {boolean} isSameLocation
+ * True if this installation is an upgrade for an existing
+ * add-on in the same location.
+ * @throws if the file cannot be staged.
+ */
+ async stageInstall(restartRequired, stagedAddon, isSameLocation) {
+ logger.debug(`Addon ${this.addon.id} will be installed as a packed xpi`);
+ stagedAddon.leafName = `${this.addon.id}.xpi`;
+
+ try {
+ await IOUtils.copy(this.file.path, stagedAddon.path);
+
+ let calculatedHash = getHashForFile(stagedAddon, this.fileHash.algorithm);
+ if (calculatedHash != this.fileHash.data) {
+ logger.warn(
+ `Staged file hash (${calculatedHash}) did not match initial hash (${this.fileHash.data})`
+ );
+ throw new Error("Refusing to stage add-on because it has been damaged");
+ }
+ } catch (e) {
+ await IOUtils.remove(stagedAddon.path, { ignoreAbsent: true });
+ throw e;
+ }
+
+ if (restartRequired) {
+ // Point the add-on to its extracted files as the xpi may get deleted
+ this.addon.sourceBundle = stagedAddon;
+
+ // Cache the AddonInternal as it may have updated compatibility info
+ this.location.stageAddon(this.addon.id, this.addon.toJSON());
+
+ logger.debug(
+ `Staged install of ${this.addon.id} from ${this.sourceURI.spec} ready; waiting for restart.`
+ );
+ if (isSameLocation) {
+ delete this.existingAddon.pendingUpgrade;
+ this.existingAddon.pendingUpgrade = this.addon;
+ }
+ }
+ }
+
+ /**
+ * Removes any previously staged upgrade.
+ *
+ * @param {nsIFile} stagingDir
+ * The staging directory from which to unstage the install.
+ */
+ async unstageInstall(stagingDir) {
+ this.location.unstageAddon(this.addon.id);
+
+ await removeAsync(getFile(this.addon.id, stagingDir));
+
+ await removeAsync(getFile(`${this.addon.id}.xpi`, stagingDir));
+ }
+
+ /**
+ * Postone a pending update, until restart or until the add-on resumes.
+ *
+ * @param {function} resumeFn
+ * A function for the add-on to run when resuming.
+ */
+ async postpone(resumeFn) {
+ this.state = AddonManager.STATE_POSTPONED;
+
+ let stagingDir = this.location.installer.getStagingDir();
+
+ try {
+ await this.location.installer.requestStagingDir();
+ await this.unstageInstall(stagingDir);
+
+ let stagedAddon = getFile(`${this.addon.id}.xpi`, stagingDir);
+
+ await this.stageInstall(true, stagedAddon, true);
+ } catch (e) {
+ logger.warn(`Failed to postpone install of ${this.addon.id}`, e);
+ this.state = AddonManager.STATE_INSTALL_FAILED;
+ this.error = AddonManager.ERROR_FILE_ACCESS;
+ this._cleanup();
+ this.removeTemporaryFile();
+ this.location.installer.releaseStagingDir();
+ this._callInstallListeners("onInstallFailed");
+ return;
+ }
+
+ this._callInstallListeners("onInstallPostponed");
+
+ // upgrade has been staged for restart, provide a way for it to call the
+ // resume function.
+ let callback = AddonManagerPrivate.getUpgradeListener(this.addon.id);
+ if (callback) {
+ callback({
+ version: this.version,
+ install: () => {
+ switch (this.state) {
+ case AddonManager.STATE_POSTPONED:
+ if (resumeFn) {
+ resumeFn();
+ }
+ break;
+ default:
+ logger.warn(
+ `${this.addon.id} cannot resume postponed upgrade from state (${this.state})`
+ );
+ break;
+ }
+ },
+ });
+ }
+ // Release the staging directory lock, but since the staging dir is populated
+ // it will not be removed until resumed or installed by restart.
+ // See also cleanStagingDir()
+ this.location.installer.releaseStagingDir();
+ }
+
+ _callInstallListeners(event, ...args) {
+ switch (event) {
+ case "onDownloadCancelled":
+ case "onDownloadFailed":
+ case "onInstallCancelled":
+ case "onInstallFailed":
+ let rej = Promise.reject(new Error(`Install failed: ${event}`));
+ rej.catch(() => {});
+ this._resolveInstallPromise(rej);
+ break;
+ case "onInstallEnded":
+ this._resolveInstallPromise(
+ Promise.resolve(this._startupPromise).then(() => args[0])
+ );
+ break;
+ }
+ return AddonManagerPrivate.callInstallListeners(
+ event,
+ this.listeners,
+ this.wrapper,
+ ...args
+ );
+ }
+}
+
+var LocalAddonInstall = class extends AddonInstall {
+ /**
+ * Initialises this install to be an install from a local file.
+ */
+ async init() {
+ this.file = this.sourceURI.QueryInterface(Ci.nsIFileURL).file;
+
+ if (!this.file.exists()) {
+ logger.warn("XPI file " + this.file.path + " does not exist");
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_NETWORK_FAILURE;
+ this._cleanup();
+ return;
+ }
+
+ this.state = AddonManager.STATE_DOWNLOADED;
+ this.progress = this.file.fileSize;
+ this.maxProgress = this.file.fileSize;
+
+ let algorithm = this.hash ? this.hash.algorithm : DEFAULT_HASH_ALGO;
+ if (this.hash) {
+ try {
+ CryptoHash(this.hash.algorithm);
+ } catch (e) {
+ logger.warn(
+ "Unknown hash algorithm '" +
+ this.hash.algorithm +
+ "' for addon " +
+ this.sourceURI.spec,
+ e
+ );
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_INCORRECT_HASH;
+ this._cleanup();
+ return;
+ }
+ }
+
+ if (!this._setFileHash(getHashForFile(this.file, algorithm))) {
+ logger.warn(
+ `File hash (${this.fileHash.data}) did not match provided hash (${this.hash.data})`
+ );
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_INCORRECT_HASH;
+ this._cleanup();
+ return;
+ }
+
+ try {
+ await this.loadManifest(this.file);
+ } catch ([error, message]) {
+ logger.warn("Invalid XPI", message);
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = error;
+ this._cleanup();
+ this._callInstallListeners("onNewInstall");
+ flushJarCache(this.file);
+ return;
+ }
+
+ let addon = await lazy.XPIDatabase.getVisibleAddonForID(this.addon.id);
+
+ this.existingAddon = addon;
+ this.addon.propagateDisabledState(this.existingAddon);
+ await this.addon.updateBlocklistState();
+ this.addon.updateDate = Date.now();
+ this.addon.installDate = addon ? addon.installDate : this.addon.updateDate;
+
+ // Report if blocked add-on becomes unblocked through this install.
+ if (
+ addon?.blocklistState === nsIBlocklistService.STATE_BLOCKED &&
+ this.addon.blocklistState === nsIBlocklistService.STATE_NOT_BLOCKED
+ ) {
+ this.addon.recordAddonBlockChangeTelemetry("addon_install");
+ }
+
+ if (!this.addon.isCompatible) {
+ this.state = AddonManager.STATE_CHECKING_UPDATE;
+
+ await new Promise(resolve => {
+ new UpdateChecker(
+ this.addon,
+ {
+ onUpdateFinished: aAddon => {
+ this.state = AddonManager.STATE_DOWNLOADED;
+ this._callInstallListeners("onNewInstall");
+ resolve();
+ },
+ },
+ AddonManager.UPDATE_WHEN_ADDON_INSTALLED
+ );
+ });
+ } else {
+ this._callInstallListeners("onNewInstall");
+ }
+ }
+
+ install() {
+ if (this.state == AddonManager.STATE_DOWNLOAD_FAILED) {
+ // For a local install, this state means that verification of the
+ // file failed (e.g., the hash or signature or manifest contents
+ // were invalid). It doesn't make sense to retry anything in this
+ // case but we have callers who don't know if their AddonInstall
+ // object is a local file or a download so accommodate them here.
+ this._callInstallListeners("onDownloadFailed");
+ return this._installPromise;
+ }
+ return super.install();
+ }
+};
+
+var DownloadAddonInstall = class extends AddonInstall {
+ /**
+ * Instantiates a DownloadAddonInstall
+ *
+ * @param {XPIStateLocation} installLocation
+ * The XPIStateLocation the add-on will be installed into
+ * @param {nsIURL} url
+ * The nsIURL to get the add-on from
+ * @param {Object} [options = {}]
+ * Additional options for the install
+ * @param {string} [options.hash]
+ * An optional hash for the add-on
+ * @param {AddonInternal} [options.existingAddon]
+ * The add-on this install will update if known
+ * @param {XULElement} [options.browser]
+ * The browser performing the install, used to display
+ * authentication prompts.
+ * @param {nsIPrincipal} [options.principal]
+ * The principal to use. If not present, will default to browser.contentPrincipal.
+ * @param {string} [options.name]
+ * An optional name for the add-on
+ * @param {string} [options.type]
+ * An optional type for the add-on
+ * @param {Object} [options.icons]
+ * Optional icons for the add-on
+ * @param {string} [options.version]
+ * The expected version for the add-on.
+ * Required for updates, i.e. when existingAddon is set.
+ * @param {function(string) : Promise<void>} [options.promptHandler]
+ * A callback to prompt the user before installing.
+ * @param {boolean} [options.sendCookies]
+ * Whether cookies should be sent when downloading the add-on.
+ */
+ constructor(installLocation, url, options = {}) {
+ super(installLocation, url, options);
+
+ this.browser = options.browser;
+ this.loadingPrincipal =
+ options.triggeringPrincipal ||
+ (this.browser && this.browser.contentPrincipal) ||
+ Services.scriptSecurityManager.getSystemPrincipal();
+ this.sendCookies = Boolean(options.sendCookies);
+
+ this.state = AddonManager.STATE_AVAILABLE;
+
+ this.stream = null;
+ this.crypto = null;
+ this.badCertHandler = null;
+ this.restartDownload = false;
+ this.downloadStartedAt = null;
+
+ this._callInstallListeners("onNewInstall", this.listeners, this.wrapper);
+ }
+
+ install() {
+ switch (this.state) {
+ case AddonManager.STATE_AVAILABLE:
+ this.startDownload();
+ break;
+ case AddonManager.STATE_DOWNLOAD_FAILED:
+ case AddonManager.STATE_INSTALL_FAILED:
+ case AddonManager.STATE_CANCELLED:
+ this.removeTemporaryFile();
+ this.state = AddonManager.STATE_AVAILABLE;
+ this.error = 0;
+ this.progress = 0;
+ this.maxProgress = -1;
+ this.hash = this.originalHash;
+ this.fileHash = null;
+ this.startDownload();
+ break;
+ default:
+ return super.install();
+ }
+ return this._installPromise;
+ }
+
+ cancel() {
+ // If we're done downloading the file but still processing it we cannot
+ // cancel the installation. We just call the base class which will handle
+ // the request by throwing an error.
+ if (this.channel && this.state == AddonManager.STATE_DOWNLOADING) {
+ logger.debug("Cancelling download of " + this.sourceURI.spec);
+ this.channel.cancel(Cr.NS_BINDING_ABORTED);
+ } else {
+ super.cancel();
+ }
+ }
+
+ observe(aSubject, aTopic, aData) {
+ // Network is going offline
+ this.cancel();
+ }
+
+ /**
+ * Starts downloading the add-on's XPI file.
+ */
+ startDownload() {
+ this.downloadStartedAt = Cu.now();
+
+ this.state = AddonManager.STATE_DOWNLOADING;
+ if (!this._callInstallListeners("onDownloadStarted")) {
+ logger.debug(
+ "onDownloadStarted listeners cancelled installation of addon " +
+ this.sourceURI.spec
+ );
+ this.state = AddonManager.STATE_CANCELLED;
+ this._cleanup();
+ this._callInstallListeners("onDownloadCancelled");
+ return;
+ }
+
+ // If a listener changed our state then do not proceed with the download
+ if (this.state != AddonManager.STATE_DOWNLOADING) {
+ return;
+ }
+
+ if (this.channel) {
+ // A previous download attempt hasn't finished cleaning up yet, signal
+ // that it should restart when complete
+ logger.debug("Waiting for previous download to complete");
+ this.restartDownload = true;
+ return;
+ }
+
+ this.openChannel();
+ }
+
+ openChannel() {
+ this.restartDownload = false;
+
+ try {
+ this.file = getTemporaryFile();
+ this.ownsTempFile = true;
+ this.stream = new FileOutputStream(
+ this.file,
+ lazy.FileUtils.MODE_WRONLY |
+ lazy.FileUtils.MODE_CREATE |
+ lazy.FileUtils.MODE_TRUNCATE,
+ lazy.FileUtils.PERMS_FILE,
+ 0
+ );
+ } catch (e) {
+ logger.warn(
+ "Failed to start download for addon " + this.sourceURI.spec,
+ e
+ );
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_FILE_ACCESS;
+ this._cleanup();
+ this._callInstallListeners("onDownloadFailed");
+ return;
+ }
+
+ let listener = Cc[
+ "@mozilla.org/network/stream-listener-tee;1"
+ ].createInstance(Ci.nsIStreamListenerTee);
+ listener.init(this, this.stream);
+ try {
+ this.badCertHandler = new lazy.CertUtils.BadCertHandler(
+ !lazy.AddonSettings.INSTALL_REQUIREBUILTINCERTS
+ );
+
+ this.channel = lazy.NetUtil.newChannel({
+ uri: this.sourceURI,
+ securityFlags:
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD,
+ loadingPrincipal: this.loadingPrincipal,
+ });
+ this.channel.notificationCallbacks = this;
+ if (this.sendCookies) {
+ if (this.channel instanceof Ci.nsIHttpChannelInternal) {
+ this.channel.forceAllowThirdPartyCookie = true;
+ }
+ } else {
+ this.channel.loadFlags |= Ci.nsIRequest.LOAD_ANONYMOUS;
+ }
+ this.channel.asyncOpen(listener);
+
+ Services.obs.addObserver(this, "network:offline-about-to-go-offline");
+ } catch (e) {
+ logger.warn(
+ "Failed to start download for addon " + this.sourceURI.spec,
+ e
+ );
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_NETWORK_FAILURE;
+ this._cleanup();
+ this._callInstallListeners("onDownloadFailed");
+ }
+ }
+
+ /*
+ * Update the crypto hasher with the new data and call the progress listeners.
+ *
+ * @see nsIStreamListener
+ */
+ onDataAvailable(aRequest, aInputstream, aOffset, aCount) {
+ this.crypto.updateFromStream(aInputstream, aCount);
+ this.progress += aCount;
+ if (!this._callInstallListeners("onDownloadProgress")) {
+ // TODO cancel the download and make it available again (bug 553024)
+ }
+ }
+
+ /*
+ * Check the redirect response for a hash of the target XPI and verify that
+ * we don't end up on an insecure channel.
+ *
+ * @see nsIChannelEventSink
+ */
+ asyncOnChannelRedirect(aOldChannel, aNewChannel, aFlags, aCallback) {
+ if (
+ !this.hash &&
+ aOldChannel.originalURI.schemeIs("https") &&
+ aOldChannel instanceof Ci.nsIHttpChannel
+ ) {
+ try {
+ let hashStr = aOldChannel.getResponseHeader("X-Target-Digest");
+ let hashSplit = hashStr.toLowerCase().split(":");
+ this.hash = {
+ algorithm: hashSplit[0],
+ data: hashSplit[1],
+ };
+ } catch (e) {}
+ }
+
+ // Verify that we don't end up on an insecure channel if we haven't got a
+ // hash to verify with (see bug 537761 for discussion)
+ if (!this.hash) {
+ this.badCertHandler.asyncOnChannelRedirect(
+ aOldChannel,
+ aNewChannel,
+ aFlags,
+ aCallback
+ );
+ } else {
+ aCallback.onRedirectVerifyCallback(Cr.NS_OK);
+ }
+
+ this.channel = aNewChannel;
+ }
+
+ /*
+ * This is the first chance to get at real headers on the channel.
+ *
+ * @see nsIStreamListener
+ */
+ onStartRequest(aRequest) {
+ if (this.hash) {
+ try {
+ this.crypto = CryptoHash(this.hash.algorithm);
+ } catch (e) {
+ logger.warn(
+ "Unknown hash algorithm '" +
+ this.hash.algorithm +
+ "' for addon " +
+ this.sourceURI.spec,
+ e
+ );
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_INCORRECT_HASH;
+ this._cleanup();
+ this._callInstallListeners("onDownloadFailed");
+ aRequest.cancel(Cr.NS_BINDING_ABORTED);
+ return;
+ }
+ } else {
+ // We always need something to consume data from the inputstream passed
+ // to onDataAvailable so just create a dummy cryptohasher to do that.
+ this.crypto = CryptoHash(DEFAULT_HASH_ALGO);
+ }
+
+ this.progress = 0;
+ if (aRequest instanceof Ci.nsIChannel) {
+ try {
+ this.maxProgress = aRequest.contentLength;
+ } catch (e) {}
+ logger.debug(
+ "Download started for " +
+ this.sourceURI.spec +
+ " to file " +
+ this.file.path
+ );
+ }
+ }
+
+ /*
+ * The download is complete.
+ *
+ * @see nsIStreamListener
+ */
+ onStopRequest(aRequest, aStatus) {
+ this.stream.close();
+ this.channel = null;
+ this.badCerthandler = null;
+ Services.obs.removeObserver(this, "network:offline-about-to-go-offline");
+
+ let crypto = this.crypto;
+ this.crypto = null;
+
+ // If the download was cancelled then update the state and send events
+ if (aStatus == Cr.NS_BINDING_ABORTED) {
+ if (this.state == AddonManager.STATE_DOWNLOADING) {
+ logger.debug("Cancelled download of " + this.sourceURI.spec);
+ this.state = AddonManager.STATE_CANCELLED;
+ this._cleanup();
+ this._callInstallListeners("onDownloadCancelled");
+ // If a listener restarted the download then there is no need to
+ // remove the temporary file
+ if (this.state != AddonManager.STATE_CANCELLED) {
+ return;
+ }
+ }
+
+ this.removeTemporaryFile();
+ if (this.restartDownload) {
+ this.openChannel();
+ }
+ return;
+ }
+
+ logger.debug("Download of " + this.sourceURI.spec + " completed.");
+
+ if (Components.isSuccessCode(aStatus)) {
+ if (
+ !(aRequest instanceof Ci.nsIHttpChannel) ||
+ aRequest.requestSucceeded
+ ) {
+ if (!this.hash && aRequest instanceof Ci.nsIChannel) {
+ try {
+ lazy.CertUtils.checkCert(
+ aRequest,
+ !lazy.AddonSettings.INSTALL_REQUIREBUILTINCERTS
+ );
+ } catch (e) {
+ this.downloadFailed(AddonManager.ERROR_NETWORK_FAILURE, e);
+ return;
+ }
+ }
+
+ if (!this._setFileHash(getHashStringForCrypto(crypto))) {
+ this.downloadFailed(
+ AddonManager.ERROR_INCORRECT_HASH,
+ `Downloaded file hash (${this.fileHash.data}) did not match provided hash (${this.hash.data})`
+ );
+ return;
+ }
+
+ this.loadManifest(this.file).then(
+ () => {
+ if (this.addon.isCompatible) {
+ this.downloadCompleted();
+ } else {
+ // TODO Should we send some event here (bug 557716)?
+ this.state = AddonManager.STATE_CHECKING_UPDATE;
+ new UpdateChecker(
+ this.addon,
+ {
+ onUpdateFinished: aAddon => this.downloadCompleted(),
+ },
+ AddonManager.UPDATE_WHEN_ADDON_INSTALLED
+ );
+ }
+ },
+ ([error, message]) => {
+ this.removeTemporaryFile();
+ this.downloadFailed(error, message);
+ }
+ );
+ } else if (aRequest instanceof Ci.nsIHttpChannel) {
+ this.downloadFailed(
+ AddonManager.ERROR_NETWORK_FAILURE,
+ aRequest.responseStatus + " " + aRequest.responseStatusText
+ );
+ } else {
+ this.downloadFailed(AddonManager.ERROR_NETWORK_FAILURE, aStatus);
+ }
+ } else {
+ this.downloadFailed(AddonManager.ERROR_NETWORK_FAILURE, aStatus);
+ }
+ }
+
+ /**
+ * Notify listeners that the download failed.
+ *
+ * @param {string} aReason
+ * Something to log about the failure
+ * @param {integer} aError
+ * The error code to pass to the listeners
+ */
+ downloadFailed(aReason, aError) {
+ logger.warn("Download of " + this.sourceURI.spec + " failed", aError);
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = aReason;
+ this._cleanup();
+ this._callInstallListeners("onDownloadFailed");
+
+ // If the listener hasn't restarted the download then remove any temporary
+ // file
+ if (this.state == AddonManager.STATE_DOWNLOAD_FAILED) {
+ logger.debug(
+ "downloadFailed: removing temp file for " + this.sourceURI.spec
+ );
+ this.removeTemporaryFile();
+ } else {
+ logger.debug(
+ "downloadFailed: listener changed AddonInstall state for " +
+ this.sourceURI.spec +
+ " to " +
+ this.state
+ );
+ }
+ }
+
+ /**
+ * Notify listeners that the download completed.
+ */
+ async downloadCompleted() {
+ let wasUpdate = !!this.existingAddon;
+ let aAddon = await lazy.XPIDatabase.getVisibleAddonForID(this.addon.id);
+ if (aAddon) {
+ this.existingAddon = aAddon;
+ }
+
+ this.state = AddonManager.STATE_DOWNLOADED;
+ this.addon.updateDate = Date.now();
+
+ if (this.existingAddon) {
+ this.addon.installDate = this.existingAddon.installDate;
+ } else {
+ this.addon.installDate = this.addon.updateDate;
+ }
+ this.addon.propagateDisabledState(this.existingAddon);
+ await this.addon.updateBlocklistState();
+
+ // Report if blocked add-on becomes unblocked through this install/update.
+ if (
+ aAddon?.blocklistState === nsIBlocklistService.STATE_BLOCKED &&
+ this.addon.blocklistState === nsIBlocklistService.STATE_NOT_BLOCKED
+ ) {
+ this.addon.recordAddonBlockChangeTelemetry(
+ wasUpdate ? "addon_update" : "addon_install"
+ );
+ }
+
+ if (this._callInstallListeners("onDownloadEnded")) {
+ // If a listener changed our state then do not proceed with the install
+ if (this.state != AddonManager.STATE_DOWNLOADED) {
+ return;
+ }
+
+ // proceed with the install state machine.
+ this.install();
+ }
+ }
+
+ getInterface(iid) {
+ if (iid.equals(Ci.nsIAuthPrompt2)) {
+ let win = null;
+ if (this.browser) {
+ win = this.browser.contentWindow || this.browser.ownerGlobal;
+ }
+
+ let factory = Cc["@mozilla.org/prompter;1"].getService(
+ Ci.nsIPromptFactory
+ );
+ let prompt = factory.getPrompt(win, Ci.nsIAuthPrompt2);
+
+ if (this.browser && prompt instanceof Ci.nsILoginManagerAuthPrompter) {
+ prompt.browser = this.browser;
+ }
+
+ return prompt;
+ } else if (iid.equals(Ci.nsIChannelEventSink)) {
+ return this;
+ }
+
+ return this.badCertHandler.getInterface(iid);
+ }
+};
+
+/**
+ * Creates a new AddonInstall for an update.
+ *
+ * @param {function} aCallback
+ * The callback to pass the new AddonInstall to
+ * @param {AddonInternal} aAddon
+ * The add-on being updated
+ * @param {Object} aUpdate
+ * The metadata about the new version from the update manifest
+ * @param {boolean} isUserRequested
+ * An optional boolean, true if the install object is related to a user triggered update.
+ */
+function createUpdate(aCallback, aAddon, aUpdate, isUserRequested) {
+ let url = Services.io.newURI(aUpdate.updateURL);
+
+ (async function () {
+ let opts = {
+ hash: aUpdate.updateHash,
+ existingAddon: aAddon,
+ name: aAddon.selectedLocale.name,
+ type: aAddon.type,
+ icons: aAddon.icons,
+ version: aUpdate.version,
+ isUserRequestedUpdate: isUserRequested,
+ };
+
+ try {
+ if (aUpdate.updateInfoURL) {
+ opts.releaseNotesURI = Services.io.newURI(
+ escapeAddonURI(aAddon, aUpdate.updateInfoURL)
+ );
+ }
+ } catch (e) {
+ // If the releaseNotesURI cannot be parsed then just ignore it.
+ }
+
+ let install;
+ if (url instanceof Ci.nsIFileURL) {
+ install = new LocalAddonInstall(aAddon.location, url, opts);
+ await install.init();
+ } else {
+ let loc = aAddon.location;
+ if (
+ aAddon.isBuiltinColorwayTheme &&
+ lazy.BuiltInThemesHelpers.isColorwayMigrationEnabled
+ ) {
+ // Builtin colorways theme needs to be updated by installing the version
+ // got from AMO into the profile location and not using the location
+ // where the builtin addon is currently installed.
+ logger.info(
+ `Overriding location to APP_PROFILE on builtin colorway theme update for "${aAddon.id}"`
+ );
+ loc = lazy.XPIInternal.XPIStates.getLocation(
+ lazy.XPIInternal.KEY_APP_PROFILE
+ );
+ }
+ install = new DownloadAddonInstall(loc, url, opts);
+ }
+
+ aCallback(install);
+ })();
+}
+
+// Maps instances of AddonInstall to AddonInstallWrapper
+const wrapperMap = new WeakMap();
+let installFor = wrapper => wrapperMap.get(wrapper);
+
+// Numeric id included in the install telemetry events to correlate multiple events related
+// to the same install or update flow.
+let nextInstallId = 0;
+
+/**
+ * Creates a wrapper for an AddonInstall that only exposes the public API
+ *
+ * @param {AddonInstall} aInstall
+ * The AddonInstall to create a wrapper for
+ */
+function AddonInstallWrapper(aInstall) {
+ wrapperMap.set(this, aInstall);
+ this.installId = ++nextInstallId;
+}
+
+AddonInstallWrapper.prototype = {
+ get __AddonInstallInternal__() {
+ return AppConstants.DEBUG ? installFor(this) : undefined;
+ },
+
+ get error() {
+ return installFor(this).error;
+ },
+
+ set error(err) {
+ installFor(this).error = err;
+ },
+
+ get type() {
+ return installFor(this).type;
+ },
+
+ get iconURL() {
+ return installFor(this).icons[32];
+ },
+
+ get existingAddon() {
+ let install = installFor(this);
+ return install.existingAddon ? install.existingAddon.wrapper : null;
+ },
+
+ get addon() {
+ let install = installFor(this);
+ return install.addon ? install.addon.wrapper : null;
+ },
+
+ get sourceURI() {
+ return installFor(this).sourceURI;
+ },
+
+ set promptHandler(handler) {
+ installFor(this).promptHandler = handler;
+ },
+
+ get promptHandler() {
+ return installFor(this).promptHandler;
+ },
+
+ get installTelemetryInfo() {
+ return installFor(this).installTelemetryInfo;
+ },
+
+ get isUserRequestedUpdate() {
+ return Boolean(installFor(this).isUserRequestedUpdate);
+ },
+
+ get downloadStartedAt() {
+ return installFor(this).downloadStartedAt;
+ },
+
+ get hashedAddonId() {
+ const addon = this.addon;
+
+ if (!addon) {
+ return null;
+ }
+
+ return computeSha256HashAsString(addon.id);
+ },
+
+ install() {
+ return installFor(this).install();
+ },
+
+ postpone(returnFn) {
+ return installFor(this).postpone(returnFn);
+ },
+
+ cancel() {
+ installFor(this).cancel();
+ },
+
+ continuePostponedInstall() {
+ return installFor(this).continuePostponedInstall();
+ },
+
+ addListener(listener) {
+ installFor(this).addListener(listener);
+ },
+
+ removeListener(listener) {
+ installFor(this).removeListener(listener);
+ },
+};
+
+[
+ "name",
+ "version",
+ "icons",
+ "releaseNotesURI",
+ "file",
+ "state",
+ "progress",
+ "maxProgress",
+].forEach(function (aProp) {
+ Object.defineProperty(AddonInstallWrapper.prototype, aProp, {
+ get() {
+ return installFor(this)[aProp];
+ },
+ enumerable: true,
+ });
+});
+
+/**
+ * Creates a new update checker.
+ *
+ * @param {AddonInternal} aAddon
+ * The add-on to check for updates
+ * @param {UpdateListener} aListener
+ * An UpdateListener to notify of updates
+ * @param {integer} aReason
+ * The reason for the update check
+ * @param {string} [aAppVersion]
+ * An optional application version to check for updates for
+ * @param {string} [aPlatformVersion]
+ * An optional platform version to check for updates for
+ * @throws if the aListener or aReason arguments are not valid
+ */
+var AddonUpdateChecker;
+var UpdateChecker = function (
+ aAddon,
+ aListener,
+ aReason,
+ aAppVersion,
+ aPlatformVersion
+) {
+ if (!aListener || !aReason) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ ({ AddonUpdateChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonUpdateChecker.sys.mjs"
+ ));
+
+ this.addon = aAddon;
+ aAddon._updateCheck = this;
+ XPIInstall.doing(this);
+ this.listener = aListener;
+ this.appVersion = aAppVersion;
+ this.platformVersion = aPlatformVersion;
+ this.syncCompatibility =
+ aReason == AddonManager.UPDATE_WHEN_NEW_APP_INSTALLED;
+ this.isUserRequested = aReason == AddonManager.UPDATE_WHEN_USER_REQUESTED;
+
+ let updateURL = aAddon.updateURL;
+ if (!updateURL) {
+ if (
+ aReason == AddonManager.UPDATE_WHEN_PERIODIC_UPDATE &&
+ Services.prefs.getPrefType(PREF_EM_UPDATE_BACKGROUND_URL) ==
+ Services.prefs.PREF_STRING
+ ) {
+ updateURL = Services.prefs.getCharPref(PREF_EM_UPDATE_BACKGROUND_URL);
+ } else {
+ updateURL = Services.prefs.getCharPref(PREF_EM_UPDATE_URL);
+ }
+ }
+
+ const UPDATE_TYPE_COMPATIBILITY = 32;
+ const UPDATE_TYPE_NEWVERSION = 64;
+
+ aReason |= UPDATE_TYPE_COMPATIBILITY;
+ if ("onUpdateAvailable" in this.listener) {
+ aReason |= UPDATE_TYPE_NEWVERSION;
+ }
+
+ let url = escapeAddonURI(aAddon, updateURL, aReason, aAppVersion);
+ this._parser = AddonUpdateChecker.checkForUpdates(aAddon.id, url, this);
+};
+
+UpdateChecker.prototype = {
+ addon: null,
+ listener: null,
+ appVersion: null,
+ platformVersion: null,
+ syncCompatibility: null,
+
+ /**
+ * Calls a method on the listener passing any number of arguments and
+ * consuming any exceptions.
+ *
+ * @param {string} aMethod
+ * The method to call on the listener
+ * @param {any[]} aArgs
+ * Additional arguments to pass to the listener.
+ */
+ callListener(aMethod, ...aArgs) {
+ if (!(aMethod in this.listener)) {
+ return;
+ }
+
+ try {
+ this.listener[aMethod].apply(this.listener, aArgs);
+ } catch (e) {
+ logger.warn("Exception calling UpdateListener method " + aMethod, e);
+ }
+ },
+
+ /**
+ * Called when AddonUpdateChecker completes the update check
+ *
+ * @param {object[]} aUpdates
+ * The list of update details for the add-on
+ */
+ async onUpdateCheckComplete(aUpdates) {
+ XPIInstall.done(this.addon._updateCheck);
+ this.addon._updateCheck = null;
+ let AUC = AddonUpdateChecker;
+ let ignoreMaxVersion = false;
+ // Ignore strict compatibility for dictionaries by default.
+ let ignoreStrictCompat = this.addon.type == "dictionary";
+ if (!AddonManager.checkCompatibility) {
+ ignoreMaxVersion = true;
+ ignoreStrictCompat = true;
+ } else if (
+ !AddonManager.strictCompatibility &&
+ !this.addon.strictCompatibility
+ ) {
+ ignoreMaxVersion = true;
+ }
+
+ // Always apply any compatibility update for the current version
+ let compatUpdate = AUC.getCompatibilityUpdate(
+ aUpdates,
+ this.addon.version,
+ this.syncCompatibility,
+ null,
+ null,
+ ignoreMaxVersion,
+ ignoreStrictCompat
+ );
+ // Apply the compatibility update to the database
+ if (compatUpdate) {
+ this.addon.applyCompatibilityUpdate(compatUpdate, this.syncCompatibility);
+ }
+
+ // If the request is for an application or platform version that is
+ // different to the current application or platform version then look for a
+ // compatibility update for those versions.
+ if (
+ (this.appVersion &&
+ Services.vc.compare(this.appVersion, Services.appinfo.version) != 0) ||
+ (this.platformVersion &&
+ Services.vc.compare(
+ this.platformVersion,
+ Services.appinfo.platformVersion
+ ) != 0)
+ ) {
+ compatUpdate = AUC.getCompatibilityUpdate(
+ aUpdates,
+ this.addon.version,
+ false,
+ this.appVersion,
+ this.platformVersion,
+ ignoreMaxVersion,
+ ignoreStrictCompat
+ );
+ }
+
+ if (compatUpdate) {
+ this.callListener("onCompatibilityUpdateAvailable", this.addon.wrapper);
+ } else {
+ this.callListener("onNoCompatibilityUpdateAvailable", this.addon.wrapper);
+ }
+
+ function sendUpdateAvailableMessages(aSelf, aInstall) {
+ if (aInstall) {
+ aSelf.callListener(
+ "onUpdateAvailable",
+ aSelf.addon.wrapper,
+ aInstall.wrapper
+ );
+ } else {
+ aSelf.callListener("onNoUpdateAvailable", aSelf.addon.wrapper);
+ }
+ aSelf.callListener(
+ "onUpdateFinished",
+ aSelf.addon.wrapper,
+ AddonManager.UPDATE_STATUS_NO_ERROR
+ );
+ }
+
+ let update = await AUC.getNewestCompatibleUpdate(
+ aUpdates,
+ this.addon,
+ this.appVersion,
+ this.platformVersion,
+ ignoreMaxVersion,
+ ignoreStrictCompat
+ );
+
+ if (update && !this.addon.location.locked) {
+ for (let currentInstall of XPIInstall.installs) {
+ // Skip installs that don't match the available update
+ if (
+ currentInstall.existingAddon != this.addon ||
+ currentInstall.version != update.version
+ ) {
+ continue;
+ }
+
+ // If the existing install has not yet started downloading then send an
+ // available update notification. If it is already downloading then
+ // don't send any available update notification
+ if (currentInstall.state == AddonManager.STATE_AVAILABLE) {
+ logger.debug("Found an existing AddonInstall for " + this.addon.id);
+ sendUpdateAvailableMessages(this, currentInstall);
+ } else {
+ sendUpdateAvailableMessages(this, null);
+ }
+ return;
+ }
+
+ createUpdate(
+ aInstall => {
+ sendUpdateAvailableMessages(this, aInstall);
+ },
+ this.addon,
+ update,
+ this.isUserRequested
+ );
+ } else {
+ sendUpdateAvailableMessages(this, null);
+ }
+ },
+
+ /**
+ * Called when AddonUpdateChecker fails the update check
+ *
+ * @param {any} aError
+ * An error status
+ */
+ onUpdateCheckError(aError) {
+ XPIInstall.done(this.addon._updateCheck);
+ this.addon._updateCheck = null;
+ this.callListener("onNoCompatibilityUpdateAvailable", this.addon.wrapper);
+ this.callListener("onNoUpdateAvailable", this.addon.wrapper);
+ this.callListener("onUpdateFinished", this.addon.wrapper, aError);
+ },
+
+ /**
+ * Called to cancel an in-progress update check
+ */
+ cancel() {
+ let parser = this._parser;
+ if (parser) {
+ this._parser = null;
+ // This will call back to onUpdateCheckError with a CANCELLED error
+ parser.cancel();
+ }
+ },
+};
+
+/**
+ * Creates a new AddonInstall to install an add-on from a local file.
+ *
+ * @param {nsIFile} file
+ * The file to install
+ * @param {XPIStateLocation} location
+ * The location to install to
+ * @param {Object?} [telemetryInfo]
+ * An optional object which provides details about the installation source
+ * included in the addon manager telemetry events.
+ * @returns {Promise<AddonInstall>}
+ * A Promise that resolves with the new install object.
+ */
+function createLocalInstall(file, location, telemetryInfo) {
+ if (!location) {
+ location = lazy.XPIInternal.XPIStates.getLocation(
+ lazy.XPIInternal.KEY_APP_PROFILE
+ );
+ }
+ let url = Services.io.newFileURI(file);
+
+ try {
+ let install = new LocalAddonInstall(location, url, { telemetryInfo });
+ return install.init().then(() => install);
+ } catch (e) {
+ logger.error("Error creating install", e);
+ return Promise.resolve(null);
+ }
+}
+
+/**
+ * Uninstall an addon from a location. This allows removing non-visible
+ * addons, such as system addon upgrades, when a higher precedence addon
+ * is installed.
+ *
+ * @param {string} addonID
+ * ID of the addon being removed.
+ * @param {XPIStateLocation} location
+ * The location to remove the addon from.
+ */
+async function uninstallAddonFromLocation(addonID, location) {
+ let existing = await lazy.XPIDatabase.getAddonInLocation(
+ addonID,
+ location.name
+ );
+ if (!existing) {
+ return;
+ }
+ if (existing.active) {
+ let a = await AddonManager.getAddonByID(addonID);
+ if (a) {
+ await a.uninstall();
+ }
+ } else {
+ lazy.XPIDatabase.removeAddonMetadata(existing);
+ location.removeAddon(addonID);
+ lazy.XPIInternal.XPIStates.save();
+ AddonManagerPrivate.callAddonListeners("onUninstalled", existing);
+ }
+}
+
+class DirectoryInstaller {
+ constructor(location) {
+ this.location = location;
+
+ this._stagingDirLock = 0;
+ this._stagingDirPromise = null;
+ }
+
+ get name() {
+ return this.location.name;
+ }
+
+ get dir() {
+ return this.location.dir;
+ }
+ set dir(val) {
+ this.location.dir = val;
+ this.location.path = val.path;
+ }
+
+ /**
+ * Gets the staging directory to put add-ons that are pending install and
+ * uninstall into.
+ *
+ * @returns {nsIFile}
+ */
+ getStagingDir() {
+ return getFile(lazy.XPIInternal.DIR_STAGE, this.dir);
+ }
+
+ requestStagingDir() {
+ this._stagingDirLock++;
+
+ if (this._stagingDirPromise) {
+ return this._stagingDirPromise;
+ }
+
+ let stagepath = PathUtils.join(this.dir.path, lazy.XPIInternal.DIR_STAGE);
+ return (this._stagingDirPromise = IOUtils.makeDirectory(stagepath, {
+ createAncestors: true,
+ ignoreExisting: true,
+ }).catch(e => {
+ logger.error("Failed to create staging directory", e);
+ throw e;
+ }));
+ }
+
+ releaseStagingDir() {
+ this._stagingDirLock--;
+
+ if (this._stagingDirLock == 0) {
+ this._stagingDirPromise = null;
+ this.cleanStagingDir();
+ }
+
+ return Promise.resolve();
+ }
+
+ /**
+ * Removes the specified files or directories in the staging directory and
+ * then if the staging directory is empty attempts to remove it.
+ *
+ * @param {string[]} [aLeafNames = []]
+ * An array of file or directory to remove from the directory, the
+ * array may be empty
+ */
+ cleanStagingDir(aLeafNames = []) {
+ let dir = this.getStagingDir();
+
+ // SystemAddonInstaller getStatingDir may return null if there isn't
+ // any addon set directory returned by SystemAddonInstaller._loadAddonSet.
+ if (!dir) {
+ return;
+ }
+
+ for (let name of aLeafNames) {
+ let file = getFile(name, dir);
+ recursiveRemove(file);
+ }
+
+ if (this._stagingDirLock > 0) {
+ return;
+ }
+
+ // eslint-disable-next-line no-unused-vars
+ for (let file of lazy.XPIInternal.iterDirectory(dir)) {
+ return;
+ }
+
+ try {
+ setFilePermissions(dir, lazy.FileUtils.PERMS_DIRECTORY);
+ dir.remove(false);
+ } catch (e) {
+ logger.warn("Failed to remove staging dir", e);
+ // Failing to remove the staging directory is ignorable
+ }
+ }
+
+ /**
+ * Returns a directory that is normally on the same filesystem as the rest of
+ * the install location and can be used for temporarily storing files during
+ * safe move operations. Calling this method will delete the existing trash
+ * directory and its contents.
+ *
+ * @returns {nsIFile}
+ */
+ getTrashDir() {
+ let trashDir = getFile(lazy.XPIInternal.DIR_TRASH, this.dir);
+ let trashDirExists = trashDir.exists();
+ try {
+ if (trashDirExists) {
+ recursiveRemove(trashDir);
+ }
+ trashDirExists = false;
+ } catch (e) {
+ logger.warn("Failed to remove trash directory", e);
+ }
+ if (!trashDirExists) {
+ trashDir.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ lazy.FileUtils.PERMS_DIRECTORY
+ );
+ }
+
+ return trashDir;
+ }
+
+ /**
+ * Installs an add-on into the install location.
+ *
+ * @param {Object} options
+ * Installation options.
+ * @param {string} options.id
+ * The ID of the add-on to install
+ * @param {nsIFile} options.source
+ * The source nsIFile to install from
+ * @param {string} options.action
+ * What to we do with the given source file:
+ * "move"
+ * Default action, the source files will be moved to the new
+ * location,
+ * "copy"
+ * The source files will be copied,
+ * "proxy"
+ * A "proxy file" is going to refer to the source file path
+ * @returns {nsIFile}
+ * An nsIFile indicating where the add-on was installed to
+ */
+ installAddon({ id, source, action = "move" }) {
+ let trashDir = this.getTrashDir();
+
+ let transaction = new SafeInstallOperation();
+
+ let moveOldAddon = aId => {
+ let file = getFile(aId, this.dir);
+ if (file.exists()) {
+ transaction.moveUnder(file, trashDir);
+ }
+
+ file = getFile(`${aId}.xpi`, this.dir);
+ if (file.exists()) {
+ flushJarCache(file);
+ transaction.moveUnder(file, trashDir);
+ }
+ };
+
+ // If any of these operations fails the finally block will clean up the
+ // temporary directory
+ try {
+ moveOldAddon(id);
+ if (action == "copy") {
+ transaction.copy(source, this.dir);
+ } else if (action == "move") {
+ flushJarCache(source);
+ transaction.moveUnder(source, this.dir);
+ }
+ // Do nothing for the proxy file as we sideload an addon permanently
+ } finally {
+ // It isn't ideal if this cleanup fails but it isn't worth rolling back
+ // the install because of it.
+ try {
+ recursiveRemove(trashDir);
+ } catch (e) {
+ logger.warn(
+ `Failed to remove trash directory when installing ${id}`,
+ e
+ );
+ }
+ }
+
+ let newFile = this.dir.clone();
+
+ if (action == "proxy") {
+ // When permanently installing sideloaded addon, we just put a proxy file
+ // referring to the addon sources
+ newFile.append(id);
+
+ writeStringToFile(newFile, source.path);
+ } else {
+ newFile.append(source.leafName);
+ }
+
+ try {
+ newFile.lastModifiedTime = Date.now();
+ } catch (e) {
+ logger.warn(`failed to set lastModifiedTime on ${newFile.path}`, e);
+ }
+
+ return newFile;
+ }
+
+ /**
+ * Uninstalls an add-on from this location.
+ *
+ * @param {string} aId
+ * The ID of the add-on to uninstall
+ * @throws if the ID does not match any of the add-ons installed
+ */
+ uninstallAddon(aId) {
+ let file = getFile(aId, this.dir);
+ if (!file.exists()) {
+ file.leafName += ".xpi";
+ }
+
+ if (!file.exists()) {
+ logger.warn(
+ `Attempted to remove ${aId} from ${this.name} but it was already gone`
+ );
+ this.location.delete(aId);
+ return;
+ }
+
+ if (file.leafName != aId) {
+ logger.debug(
+ `uninstallAddon: flushing jar cache ${file.path} for addon ${aId}`
+ );
+ flushJarCache(file);
+ }
+
+ // In case this is a foreignInstall we do not want to remove the file if
+ // the location is locked.
+ if (!this.location.locked) {
+ let trashDir = this.getTrashDir();
+ let transaction = new SafeInstallOperation();
+
+ try {
+ transaction.moveUnder(file, trashDir);
+ } finally {
+ // It isn't ideal if this cleanup fails, but it is probably better than
+ // rolling back the uninstall at this point
+ try {
+ recursiveRemove(trashDir);
+ } catch (e) {
+ logger.warn(
+ `Failed to remove trash directory when uninstalling ${aId}`,
+ e
+ );
+ }
+ }
+ }
+
+ this.location.removeAddon(aId);
+ }
+}
+
+class SystemAddonInstaller extends DirectoryInstaller {
+ constructor(location) {
+ super(location);
+
+ this._baseDir = location._baseDir;
+ this._nextDir = null;
+ }
+
+ get _addonSet() {
+ return this.location._addonSet;
+ }
+ set _addonSet(val) {
+ this.location._addonSet = val;
+ }
+
+ /**
+ * Saves the current set of system add-ons
+ *
+ * @param {Object} aAddonSet - object containing schema, directory and set
+ * of system add-on IDs and versions.
+ */
+ static _saveAddonSet(aAddonSet) {
+ Services.prefs.setStringPref(
+ lazy.XPIInternal.PREF_SYSTEM_ADDON_SET,
+ JSON.stringify(aAddonSet)
+ );
+ }
+
+ static _loadAddonSet() {
+ return lazy.XPIInternal.SystemAddonLocation._loadAddonSet();
+ }
+
+ /**
+ * Gets the staging directory to put add-ons that are pending install and
+ * uninstall into.
+ *
+ * @returns {nsIFile}
+ * Staging directory for system add-on upgrades.
+ */
+ getStagingDir() {
+ this._addonSet = SystemAddonInstaller._loadAddonSet();
+ let dir = null;
+ if (this._addonSet.directory) {
+ this.dir = getFile(this._addonSet.directory, this._baseDir);
+ dir = getFile(lazy.XPIInternal.DIR_STAGE, this.dir);
+ } else {
+ logger.info("SystemAddonInstaller directory is missing");
+ }
+
+ return dir;
+ }
+
+ requestStagingDir() {
+ this._addonSet = SystemAddonInstaller._loadAddonSet();
+ if (this._addonSet.directory) {
+ this.dir = getFile(this._addonSet.directory, this._baseDir);
+ }
+ return super.requestStagingDir();
+ }
+
+ isValidAddon(aAddon) {
+ if (aAddon.appDisabled) {
+ logger.warn(
+ `System add-on ${aAddon.id} isn't compatible with the application.`
+ );
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Tests whether the loaded add-on information matches what is expected.
+ *
+ * @param {Map<string, AddonInternal>} aAddons
+ * The set of add-ons to check.
+ * @returns {boolean}
+ * True if all of the given add-ons are valid.
+ */
+ isValid(aAddons) {
+ for (let id of Object.keys(this._addonSet.addons)) {
+ if (!aAddons.has(id)) {
+ logger.warn(
+ `Expected add-on ${id} is missing from the system add-on location.`
+ );
+ return false;
+ }
+
+ let addon = aAddons.get(id);
+ if (addon.version != this._addonSet.addons[id].version) {
+ logger.warn(
+ `Expected system add-on ${id} to be version ${this._addonSet.addons[id].version} but was ${addon.version}.`
+ );
+ return false;
+ }
+
+ if (!this.isValidAddon(addon)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Resets the add-on set so on the next startup the default set will be used.
+ */
+ async resetAddonSet() {
+ logger.info("Removing all system add-on upgrades.");
+
+ // remove everything from the pref first, if uninstall
+ // fails then at least they will not be re-activated on
+ // next restart.
+ let addonSet = this._addonSet;
+ this._addonSet = { schema: 1, addons: {} };
+ SystemAddonInstaller._saveAddonSet(this._addonSet);
+
+ // If this is running at app startup, the pref being cleared
+ // will cause later stages of startup to notice that the
+ // old updates are now gone.
+ //
+ // Updates will only be explicitly uninstalled if they are
+ // removed restartlessly, for instance if they are no longer
+ // part of the latest update set.
+ if (addonSet) {
+ for (let addonID of Object.keys(addonSet.addons)) {
+ await uninstallAddonFromLocation(addonID, this.location);
+ }
+ }
+ }
+
+ /**
+ * Removes any directories not currently in use or pending use after a
+ * restart. Any errors that happen here don't really matter as we'll attempt
+ * to cleanup again next time.
+ */
+ async cleanDirectories() {
+ try {
+ let children = await IOUtils.getChildren(this._baseDir.path, {
+ ignoreAbsent: true,
+ });
+ for (let path of children) {
+ // Skip the directory currently in use
+ if (this.dir && this.dir.path == path) {
+ continue;
+ }
+
+ // Skip the next directory
+ if (this._nextDir && this._nextDir.path == path) {
+ continue;
+ }
+
+ await IOUtils.remove(path, {
+ ignoreAbsent: true,
+ recursive: true,
+ });
+ }
+ } catch (e) {
+ logger.error("Failed to clean updated system add-ons directories.", e);
+ }
+ }
+
+ /**
+ * Installs a new set of system add-ons into the location and updates the
+ * add-on set in prefs.
+ *
+ * @param {Array} aAddons - An array of addons to install.
+ */
+ async installAddonSet(aAddons) {
+ // Make sure the base dir exists
+ await IOUtils.makeDirectory(this._baseDir.path, { ignoreExisting: true });
+
+ let addonSet = SystemAddonInstaller._loadAddonSet();
+
+ // Remove any add-ons that are no longer part of the set.
+ const ids = aAddons.map(a => a.id);
+ for (let addonID of Object.keys(addonSet.addons)) {
+ if (!ids.includes(addonID)) {
+ await uninstallAddonFromLocation(addonID, this.location);
+ }
+ }
+
+ let newDir = this._baseDir.clone();
+ newDir.append("blank");
+
+ while (true) {
+ newDir.leafName = Services.uuid.generateUUID().toString();
+ try {
+ await IOUtils.makeDirectory(newDir.path, { ignoreExisting: false });
+ break;
+ } catch (e) {
+ logger.debug(
+ "Could not create new system add-on updates dir, retrying",
+ e
+ );
+ }
+ }
+
+ // Record the new upgrade directory.
+ let state = { schema: 1, directory: newDir.leafName, addons: {} };
+ SystemAddonInstaller._saveAddonSet(state);
+
+ this._nextDir = newDir;
+
+ let installs = [];
+ for (let addon of aAddons) {
+ let install = await createLocalInstall(
+ addon._sourceBundle,
+ this.location,
+ // Make sure that system addons being installed for the first time through
+ // Balrog have telemetryInfo associated with them (on the contrary the ones
+ // updated through Balrog but part of the build will already have the same
+ // `source`, but we expect no `method` to be set for them).
+ {
+ source: "system-addon",
+ method: "product-updates",
+ }
+ );
+ installs.push(install);
+ }
+
+ async function installAddon(install) {
+ // Make the new install own its temporary file.
+ install.ownsTempFile = true;
+ install.install();
+ }
+
+ async function postponeAddon(install) {
+ install.ownsTempFile = true;
+ let resumeFn;
+ if (AddonManagerPrivate.hasUpgradeListener(install.addon.id)) {
+ logger.info(
+ `system add-on ${install.addon.id} has an upgrade listener, postponing upgrade set until restart`
+ );
+ resumeFn = () => {
+ logger.info(
+ `${install.addon.id} has resumed a previously postponed addon set`
+ );
+ install.location.installer.resumeAddonSet(installs);
+ };
+ }
+ await install.postpone(resumeFn);
+ }
+
+ let previousState;
+
+ try {
+ // All add-ons in position, create the new state and store it in prefs
+ state = { schema: 1, directory: newDir.leafName, addons: {} };
+ for (let addon of aAddons) {
+ state.addons[addon.id] = {
+ version: addon.version,
+ };
+ }
+
+ previousState = SystemAddonInstaller._loadAddonSet();
+ SystemAddonInstaller._saveAddonSet(state);
+
+ let blockers = aAddons.filter(addon =>
+ AddonManagerPrivate.hasUpgradeListener(addon.id)
+ );
+
+ if (blockers.length) {
+ await waitForAllPromises(installs.map(postponeAddon));
+ } else {
+ await waitForAllPromises(installs.map(installAddon));
+ }
+ } catch (e) {
+ // Roll back to previous upgrade set (if present) on restart.
+ if (previousState) {
+ SystemAddonInstaller._saveAddonSet(previousState);
+ }
+ // Otherwise, roll back to built-in set on restart.
+ // TODO try to do these restartlessly
+ await this.resetAddonSet();
+
+ try {
+ await IOUtils.remove(newDir.path, { recursive: true });
+ } catch (e) {
+ logger.warn(
+ `Failed to remove failed system add-on directory ${newDir.path}.`,
+ e
+ );
+ }
+ throw e;
+ }
+ }
+
+ /**
+ * Resumes upgrade of a previously-delayed add-on set.
+ *
+ * @param {AddonInstall[]} installs
+ * The set of installs to resume.
+ */
+ async resumeAddonSet(installs) {
+ async function resumeAddon(install) {
+ install.state = AddonManager.STATE_DOWNLOADED;
+ install.location.installer.releaseStagingDir();
+ install.install();
+ }
+
+ let blockers = installs.filter(install =>
+ AddonManagerPrivate.hasUpgradeListener(install.addon.id)
+ );
+
+ if (blockers.length > 1) {
+ logger.warn(
+ "Attempted to resume system add-on install but upgrade blockers are still present"
+ );
+ } else {
+ await waitForAllPromises(installs.map(resumeAddon));
+ }
+ }
+
+ /**
+ * Returns a directory that is normally on the same filesystem as the rest of
+ * the install location and can be used for temporarily storing files during
+ * safe move operations. Calling this method will delete the existing trash
+ * directory and its contents.
+ *
+ * @returns {nsIFile}
+ */
+ getTrashDir() {
+ let trashDir = getFile(lazy.XPIInternal.DIR_TRASH, this.dir);
+ let trashDirExists = trashDir.exists();
+ try {
+ if (trashDirExists) {
+ recursiveRemove(trashDir);
+ }
+ trashDirExists = false;
+ } catch (e) {
+ logger.warn("Failed to remove trash directory", e);
+ }
+ if (!trashDirExists) {
+ trashDir.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ lazy.FileUtils.PERMS_DIRECTORY
+ );
+ }
+
+ return trashDir;
+ }
+
+ /**
+ * Installs an add-on into the install location.
+ *
+ * @param {string} id
+ * The ID of the add-on to install
+ * @param {nsIFile} source
+ * The source nsIFile to install from
+ * @returns {nsIFile}
+ * An nsIFile indicating where the add-on was installed to
+ */
+ installAddon({ id, source }) {
+ let trashDir = this.getTrashDir();
+ let transaction = new SafeInstallOperation();
+
+ // If any of these operations fails the finally block will clean up the
+ // temporary directory
+ try {
+ flushJarCache(source);
+
+ transaction.moveUnder(source, this.dir);
+ } finally {
+ // It isn't ideal if this cleanup fails but it isn't worth rolling back
+ // the install because of it.
+ try {
+ recursiveRemove(trashDir);
+ } catch (e) {
+ logger.warn(
+ `Failed to remove trash directory when installing ${id}`,
+ e
+ );
+ }
+ }
+
+ let newFile = getFile(source.leafName, this.dir);
+
+ try {
+ newFile.lastModifiedTime = Date.now();
+ } catch (e) {
+ logger.warn("failed to set lastModifiedTime on " + newFile.path, e);
+ }
+
+ return newFile;
+ }
+
+ // old system add-on upgrade dirs get automatically removed
+ uninstallAddon(aAddon) {}
+}
+
+var AppUpdate = {
+ findAddonUpdates(addon, reason, appVersion, platformVersion) {
+ return new Promise((resolve, reject) => {
+ let update = null;
+ addon.findUpdates(
+ {
+ onUpdateAvailable(addon2, install) {
+ update = install;
+ },
+
+ onUpdateFinished(addon2, error) {
+ if (error == AddonManager.UPDATE_STATUS_NO_ERROR) {
+ resolve(update);
+ } else {
+ reject(error);
+ }
+ },
+ },
+ reason,
+ appVersion,
+ platformVersion || appVersion
+ );
+ });
+ },
+
+ stageInstall(installer) {
+ return new Promise((resolve, reject) => {
+ let listener = {
+ onDownloadEnded: install => {
+ install.postpone();
+ },
+ onInstallFailed: install => {
+ install.removeListener(listener);
+ reject();
+ },
+ onInstallEnded: install => {
+ // We shouldn't end up here, but if we do, resolve
+ // since we've installed.
+ install.removeListener(listener);
+ resolve();
+ },
+ onInstallPostponed: install => {
+ // At this point the addon is staged for restart.
+ install.removeListener(listener);
+ resolve();
+ },
+ };
+
+ installer.addListener(listener);
+ installer.install();
+ });
+ },
+
+ async stageLangpackUpdates(nextVersion, nextPlatformVersion) {
+ let updates = [];
+ let addons = await AddonManager.getAddonsByTypes(["locale"]);
+ for (let addon of addons) {
+ updates.push(
+ this.findAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_NEW_APP_DETECTED,
+ nextVersion,
+ nextPlatformVersion
+ )
+ .then(update => update && this.stageInstall(update))
+ .catch(e => {
+ logger.debug(`addon.findUpdate error: ${e}`);
+ })
+ );
+ }
+ return Promise.all(updates);
+ },
+};
+
+var XPIInstall = {
+ // An array of currently active AddonInstalls
+ installs: new Set(),
+
+ createLocalInstall,
+ flushJarCache,
+ newVersionReason,
+ recursiveRemove,
+ syncLoadManifest,
+ loadManifestFromFile,
+ uninstallAddonFromLocation,
+
+ stageLangpacksForAppUpdate(nextVersion, nextPlatformVersion) {
+ return AppUpdate.stageLangpackUpdates(nextVersion, nextPlatformVersion);
+ },
+
+ // Keep track of in-progress operations that support cancel()
+ _inProgress: [],
+
+ doing(aCancellable) {
+ this._inProgress.push(aCancellable);
+ },
+
+ done(aCancellable) {
+ let i = this._inProgress.indexOf(aCancellable);
+ if (i != -1) {
+ this._inProgress.splice(i, 1);
+ return true;
+ }
+ return false;
+ },
+
+ cancelAll() {
+ // Cancelling one may alter _inProgress, so don't use a simple iterator
+ while (this._inProgress.length) {
+ let c = this._inProgress.shift();
+ try {
+ c.cancel();
+ } catch (e) {
+ logger.warn("Cancel failed", e);
+ }
+ }
+ },
+
+ /**
+ * @param {string} id
+ * The expected ID of the add-on.
+ * @param {nsIFile} file
+ * The XPI file to install the add-on from.
+ * @param {XPIStateLocation} location
+ * The install location to install the add-on to.
+ * @param {string?} [oldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @returns {AddonInternal}
+ * The installed Addon object, upon success.
+ */
+ async installDistributionAddon(id, file, location, oldAppVersion) {
+ let addon = await loadManifestFromFile(file, location);
+ addon.installTelemetryInfo = { source: "distribution" };
+
+ if (addon.id != id) {
+ throw new Error(
+ `File file ${file.path} contains an add-on with an incorrect ID`
+ );
+ }
+
+ let state = location.get(id);
+
+ if (state) {
+ try {
+ let existingAddon = await loadManifestFromFile(state.file, location);
+
+ if (Services.vc.compare(addon.version, existingAddon.version) <= 0) {
+ return null;
+ }
+ } catch (e) {
+ // Bad add-on in the profile so just proceed and install over the top
+ logger.warn(
+ "Profile contains an add-on with a bad or missing install " +
+ `manifest at ${state.path}, overwriting`,
+ e
+ );
+ }
+ } else if (
+ addon.type === "locale" &&
+ oldAppVersion &&
+ Services.vc.compare(oldAppVersion, "67") < 0
+ ) {
+ /* Distribution language packs didn't get installed due to the signing
+ issues so we need to force them to be reinstalled. */
+ Services.prefs.clearUserPref(
+ lazy.XPIInternal.PREF_BRANCH_INSTALLED_ADDON + id
+ );
+ } else if (
+ Services.prefs.getBoolPref(
+ lazy.XPIInternal.PREF_BRANCH_INSTALLED_ADDON + id,
+ false
+ )
+ ) {
+ return null;
+ }
+
+ // Install the add-on
+ addon.sourceBundle = location.installer.installAddon({
+ id,
+ source: file,
+ action: "copy",
+ });
+
+ lazy.XPIInternal.XPIStates.addAddon(addon);
+ logger.debug(`Installed distribution add-on ${id}`);
+
+ Services.prefs.setBoolPref(
+ lazy.XPIInternal.PREF_BRANCH_INSTALLED_ADDON + id,
+ true
+ );
+
+ return addon;
+ },
+
+ /**
+ * Completes the install of an add-on which was staged during the last
+ * session.
+ *
+ * @param {string} id
+ * The expected ID of the add-on.
+ * @param {object} metadata
+ * The parsed metadata for the staged install.
+ * @param {XPIStateLocation} location
+ * The install location to install the add-on to.
+ * @returns {AddonInternal}
+ * The installed Addon object, upon success.
+ */
+ async installStagedAddon(id, metadata, location) {
+ let source = getFile(`${id}.xpi`, location.installer.getStagingDir());
+
+ // Check that the directory's name is a valid ID.
+ if (!gIDTest.test(id) || !source.exists() || !source.isFile()) {
+ throw new Error(`Ignoring invalid staging directory entry: ${id}`);
+ }
+
+ let addon = await loadManifestFromFile(source, location);
+
+ if (
+ lazy.XPIDatabase.mustSign(addon.type) &&
+ addon.signedState <= AddonManager.SIGNEDSTATE_MISSING
+ ) {
+ throw new Error(
+ `Refusing to install staged add-on ${id} with signed state ${addon.signedState}`
+ );
+ }
+
+ // Import saved metadata before checking for compatibility.
+ addon.importMetadata(metadata);
+
+ // Ensure a staged addon is compatible with the current running version of
+ // Firefox. If a prior version of the addon is installed, it will remain.
+ if (!addon.isCompatible) {
+ throw new Error(
+ `Add-on ${addon.id} is not compatible with application version.`
+ );
+ }
+
+ logger.debug(`Processing install of ${id} in ${location.name}`);
+ let existingAddon = lazy.XPIInternal.XPIStates.findAddon(id);
+ // This part of the startup file changes is called from
+ // processPendingFileChanges, no addons are started yet.
+ // Here we handle copying the xpi into its proper place, later
+ // processFileChanges will call update.
+ try {
+ addon.sourceBundle = location.installer.installAddon({
+ id,
+ source,
+ });
+ lazy.XPIInternal.XPIStates.addAddon(addon);
+ } catch (e) {
+ if (existingAddon) {
+ // Re-install the old add-on
+ lazy.XPIInternal.get(existingAddon).install();
+ }
+ throw e;
+ }
+
+ return addon;
+ },
+
+ async updateSystemAddons() {
+ let systemAddonLocation = lazy.XPIInternal.XPIStates.getLocation(
+ lazy.XPIInternal.KEY_APP_SYSTEM_ADDONS
+ );
+ if (!systemAddonLocation) {
+ return;
+ }
+
+ let installer = systemAddonLocation.installer;
+
+ // Don't do anything in safe mode
+ if (Services.appinfo.inSafeMode) {
+ return;
+ }
+
+ // Download the list of system add-ons
+ let url = Services.prefs.getStringPref(PREF_SYSTEM_ADDON_UPDATE_URL, null);
+ if (!url) {
+ await installer.cleanDirectories();
+ return;
+ }
+
+ url = await lazy.UpdateUtils.formatUpdateURL(url);
+
+ logger.info(`Starting system add-on update check from ${url}.`);
+ let res = await lazy.ProductAddonChecker.getProductAddonList(
+ url,
+ true
+ ).catch(e => logger.error(`System addon update list error ${e}`));
+
+ // If there was no list then do nothing.
+ if (!res || !res.addons) {
+ logger.info("No system add-ons list was returned.");
+ await installer.cleanDirectories();
+ return;
+ }
+
+ let addonList = new Map(
+ res.addons.map(spec => [spec.id, { spec, path: null, addon: null }])
+ );
+
+ let setMatches = (wanted, existing) => {
+ if (wanted.size != existing.size) {
+ return false;
+ }
+
+ for (let [id, addon] of existing) {
+ let wantedInfo = wanted.get(id);
+
+ if (!wantedInfo) {
+ return false;
+ }
+ if (wantedInfo.spec.version != addon.version) {
+ return false;
+ }
+ }
+
+ return true;
+ };
+
+ // If this matches the current set in the profile location then do nothing.
+ let updatedAddons = addonMap(
+ await lazy.XPIDatabase.getAddonsInLocation(
+ lazy.XPIInternal.KEY_APP_SYSTEM_ADDONS
+ )
+ );
+ if (setMatches(addonList, updatedAddons)) {
+ logger.info("Retaining existing updated system add-ons.");
+ await installer.cleanDirectories();
+ return;
+ }
+
+ // If this matches the current set in the default location then reset the
+ // updated set.
+ let defaultAddons = addonMap(
+ await lazy.XPIDatabase.getAddonsInLocation(
+ lazy.XPIInternal.KEY_APP_SYSTEM_DEFAULTS
+ )
+ );
+ if (setMatches(addonList, defaultAddons)) {
+ logger.info("Resetting system add-ons.");
+ await installer.resetAddonSet();
+ await installer.cleanDirectories();
+ return;
+ }
+
+ // Download all the add-ons
+ async function downloadAddon(item) {
+ try {
+ let sourceAddon = updatedAddons.get(item.spec.id);
+ if (sourceAddon && sourceAddon.version == item.spec.version) {
+ // Copying the file to a temporary location has some benefits. If the
+ // file is locked and cannot be read then we'll fall back to
+ // downloading a fresh copy. We later mark the install object with
+ // ownsTempFile so that we will cleanup later (see installAddonSet).
+ try {
+ let tmpDir = Services.dirsvc.get("TmpD", Ci.nsIFile).path;
+ let uniquePath = await IOUtils.createUniqueFile(tmpDir, "tmpaddon");
+ await IOUtils.copy(sourceAddon._sourceBundle.path, uniquePath);
+ // Make sure to update file modification times so this is detected
+ // as a new add-on.
+ await IOUtils.setModificationTime(uniquePath);
+ item.path = uniquePath;
+ } catch (e) {
+ logger.warn(
+ `Failed make temporary copy of ${sourceAddon._sourceBundle.path}.`,
+ e
+ );
+ }
+ }
+ if (!item.path) {
+ item.path = await lazy.ProductAddonChecker.downloadAddon(item.spec);
+ }
+ item.addon = await loadManifestFromFile(
+ nsIFile(item.path),
+ systemAddonLocation
+ );
+ } catch (e) {
+ logger.error(`Failed to download system add-on ${item.spec.id}`, e);
+ }
+ }
+ await Promise.all(Array.from(addonList.values()).map(downloadAddon));
+
+ // The download promises all resolve regardless, now check if they all
+ // succeeded
+ let validateAddon = item => {
+ if (item.spec.id != item.addon.id) {
+ logger.warn(
+ `Downloaded system add-on expected to be ${item.spec.id} but was ${item.addon.id}.`
+ );
+ return false;
+ }
+
+ if (item.spec.version != item.addon.version) {
+ logger.warn(
+ `Expected system add-on ${item.spec.id} to be version ${item.spec.version} but was ${item.addon.version}.`
+ );
+ return false;
+ }
+
+ if (!installer.isValidAddon(item.addon)) {
+ return false;
+ }
+
+ return true;
+ };
+
+ if (
+ !Array.from(addonList.values()).every(
+ item => item.path && item.addon && validateAddon(item)
+ )
+ ) {
+ throw new Error(
+ "Rejecting updated system add-on set that either could not " +
+ "be downloaded or contained unusable add-ons."
+ );
+ }
+
+ // Install into the install location
+ logger.info("Installing new system add-on set");
+ await installer.installAddonSet(
+ Array.from(addonList.values()).map(a => a.addon)
+ );
+ },
+
+ /**
+ * Called to test whether installing XPI add-ons is enabled.
+ *
+ * @returns {boolean}
+ * True if installing is enabled.
+ */
+ isInstallEnabled() {
+ // Default to enabled if the preference does not exist
+ return Services.prefs.getBoolPref(PREF_XPI_ENABLED, true);
+ },
+
+ /**
+ * Called to test whether installing XPI add-ons by direct URL requests is
+ * whitelisted.
+ *
+ * @returns {boolean}
+ * True if installing by direct requests is whitelisted
+ */
+ isDirectRequestWhitelisted() {
+ // Default to whitelisted if the preference does not exist.
+ return Services.prefs.getBoolPref(PREF_XPI_DIRECT_WHITELISTED, true);
+ },
+
+ /**
+ * Called to test whether installing XPI add-ons from file referrers is
+ * whitelisted.
+ *
+ * @returns {boolean}
+ * True if installing from file referrers is whitelisted
+ */
+ isFileRequestWhitelisted() {
+ // Default to whitelisted if the preference does not exist.
+ return Services.prefs.getBoolPref(PREF_XPI_FILE_WHITELISTED, true);
+ },
+
+ /**
+ * Called to test whether installing XPI add-ons from a URI is allowed.
+ *
+ * @param {nsIPrincipal} aInstallingPrincipal
+ * The nsIPrincipal that initiated the install
+ * @returns {boolean}
+ * True if installing is allowed
+ */
+ isInstallAllowed(aInstallingPrincipal) {
+ if (!this.isInstallEnabled()) {
+ return false;
+ }
+
+ let uri = aInstallingPrincipal.URI;
+
+ // Direct requests without a referrer are either whitelisted or blocked.
+ if (!uri) {
+ return this.isDirectRequestWhitelisted();
+ }
+
+ // Local referrers can be whitelisted.
+ if (
+ this.isFileRequestWhitelisted() &&
+ (uri.schemeIs("chrome") || uri.schemeIs("file"))
+ ) {
+ return true;
+ }
+
+ lazy.XPIDatabase.importPermissions();
+
+ let permission = Services.perms.testPermissionFromPrincipal(
+ aInstallingPrincipal,
+ lazy.XPIInternal.XPI_PERMISSION
+ );
+ if (permission == Ci.nsIPermissionManager.DENY_ACTION) {
+ return false;
+ }
+
+ let requireWhitelist = Services.prefs.getBoolPref(
+ PREF_XPI_WHITELIST_REQUIRED,
+ true
+ );
+ if (
+ requireWhitelist &&
+ permission != Ci.nsIPermissionManager.ALLOW_ACTION
+ ) {
+ return false;
+ }
+
+ let requireSecureOrigin = Services.prefs.getBoolPref(
+ PREF_INSTALL_REQUIRESECUREORIGIN,
+ true
+ );
+ let safeSchemes = ["https", "chrome", "file"];
+ if (requireSecureOrigin && !safeSchemes.includes(uri.scheme)) {
+ return false;
+ }
+
+ return true;
+ },
+
+ /**
+ * Called to get an AddonInstall to download and install an add-on from a URL.
+ *
+ * @param {nsIURI} aUrl
+ * The URL to be installed
+ * @param {object} [aOptions]
+ * Additional options for this install.
+ * @param {string?} [aOptions.hash]
+ * A hash for the install
+ * @param {string} [aOptions.name]
+ * A name for the install
+ * @param {Object} [aOptions.icons]
+ * Icon URLs for the install
+ * @param {string} [aOptions.version]
+ * A version for the install
+ * @param {XULElement} [aOptions.browser]
+ * The browser performing the install
+ * @param {Object} [aOptions.telemetryInfo]
+ * An optional object which provides details about the installation source
+ * included in the addon manager telemetry events.
+ * @param {boolean} [aOptions.sendCookies = false]
+ * Whether cookies should be sent when downloading the add-on.
+ * @param {string} [aOptions.useSystemLocation = false]
+ * If true installs to the system profile location.
+ * @returns {AddonInstall}
+ */
+ async getInstallForURL(aUrl, aOptions) {
+ let locationName = aOptions.useSystemLocation
+ ? lazy.XPIInternal.KEY_APP_SYSTEM_PROFILE
+ : lazy.XPIInternal.KEY_APP_PROFILE;
+ let location = lazy.XPIInternal.XPIStates.getLocation(locationName);
+ if (!location) {
+ throw Components.Exception(
+ "Invalid location name",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let url = Services.io.newURI(aUrl);
+
+ if (url instanceof Ci.nsIFileURL) {
+ let install = new LocalAddonInstall(location, url, aOptions);
+ await install.init();
+ return install.wrapper;
+ }
+
+ let install = new DownloadAddonInstall(location, url, aOptions);
+ return install.wrapper;
+ },
+
+ /**
+ * Called to get an AddonInstall to install an add-on from a local file.
+ *
+ * @param {nsIFile} aFile
+ * The file to be installed
+ * @param {Object?} [aInstallTelemetryInfo]
+ * An optional object which provides details about the installation source
+ * included in the addon manager telemetry events.
+ * @param {boolean} [aUseSystemLocation = false]
+ * If true install to the system profile location.
+ * @returns {AddonInstall?}
+ */
+ async getInstallForFile(
+ aFile,
+ aInstallTelemetryInfo,
+ aUseSystemLocation = false
+ ) {
+ let location = lazy.XPIInternal.XPIStates.getLocation(
+ aUseSystemLocation
+ ? lazy.XPIInternal.KEY_APP_SYSTEM_PROFILE
+ : lazy.XPIInternal.KEY_APP_PROFILE
+ );
+ let install = await createLocalInstall(
+ aFile,
+ location,
+ aInstallTelemetryInfo
+ );
+ return install ? install.wrapper : null;
+ },
+
+ /**
+ * Called to get the current AddonInstalls, optionally limiting to a list of
+ * types.
+ *
+ * @param {Array<string>?} aTypes
+ * An array of types or null to get all types
+ * @returns {AddonInstall[]}
+ */
+ getInstallsByTypes(aTypes) {
+ let results = [...this.installs];
+ if (aTypes) {
+ results = results.filter(install => {
+ return aTypes.includes(install.type);
+ });
+ }
+
+ return results.map(install => install.wrapper);
+ },
+
+ /**
+ * Temporarily installs add-on from a local XPI file or directory.
+ * As this is intended for development, the signature is not checked and
+ * the add-on does not persist on application restart.
+ *
+ * @param {nsIFile} aFile
+ * An nsIFile for the unpacked add-on directory or XPI file.
+ *
+ * @returns {Promise<Addon>}
+ * A Promise that resolves to an Addon object on success, or rejects
+ * if the add-on is not a valid restartless add-on or if the
+ * same ID is already installed.
+ */
+ async installTemporaryAddon(aFile) {
+ let installLocation = lazy.XPIInternal.TemporaryInstallLocation;
+
+ if (lazy.XPIInternal.isXPI(aFile.leafName)) {
+ flushJarCache(aFile);
+ }
+ let addon = await loadManifestFromFile(aFile, installLocation);
+ addon.rootURI = lazy.XPIInternal.getURIForResourceInFile(aFile, "").spec;
+
+ await this._activateAddon(addon, { temporarilyInstalled: true });
+
+ logger.debug(`Install of temporary addon in ${aFile.path} completed.`);
+ return addon.wrapper;
+ },
+
+ /**
+ * Installs an add-on from a built-in location
+ * (ie a resource: url referencing assets shipped with the application)
+ *
+ * @param {string} base
+ * A string containing the base URL. Must be a resource: URL.
+ * @returns {Promise<Addon>}
+ * A Promise that resolves to an Addon object when the addon is
+ * installed.
+ */
+ async installBuiltinAddon(base) {
+ // We have to get this before the install, as the install will overwrite
+ // the pref. We then keep the value for this run, so we can restore
+ // the selected theme once it becomes available.
+ if (lastSelectedTheme === null) {
+ lastSelectedTheme = Services.prefs.getCharPref(PREF_SELECTED_THEME, "");
+ }
+
+ let baseURL = Services.io.newURI(base);
+
+ // WebExtensions need to be able to iterate through the contents of
+ // an extension (for localization). It knows how to do this with
+ // jar: and file: URLs, so translate the provided base URL to
+ // something it can use.
+ if (baseURL.scheme !== "resource") {
+ throw new Error("Built-in addons must use resource: URLS");
+ }
+
+ let pkg = builtinPackage(baseURL);
+ let addon = await loadManifest(pkg, lazy.XPIInternal.BuiltInLocation);
+ addon.rootURI = base;
+
+ // If this is a theme, decide whether to enable it. Themes are
+ // disabled by default. However:
+ //
+ // We always want one theme to be active, falling back to the
+ // default theme when the active theme is disabled.
+ // During a theme migration, such as a change in the path to the addon, we
+ // will need to ensure a correct theme is enabled.
+ if (addon.type === "theme") {
+ if (
+ addon.id === lastSelectedTheme ||
+ (!lastSelectedTheme.endsWith("@mozilla.org") &&
+ addon.id === lazy.AddonSettings.DEFAULT_THEME_ID &&
+ !lazy.XPIDatabase.getAddonsByType("theme").some(
+ theme => !theme.disabled
+ ))
+ ) {
+ addon.userDisabled = false;
+ }
+ }
+ await this._activateAddon(addon);
+ return addon.wrapper;
+ },
+
+ /**
+ * Activate a newly installed addon.
+ * This function handles all the bookkeeping related to a new addon
+ * and invokes whatever bootstrap methods are necessary.
+ * Note that this function is only used for temporary and built-in
+ * installs, it is very similar to AddonInstall::startInstall().
+ * It would be great to merge this function with that one some day.
+ *
+ * @param {AddonInternal} addon The addon to activate
+ * @param {object} [extraParams] Any extra parameters to pass to the
+ * bootstrap install() method
+ *
+ * @returns {Promise<void>}
+ */
+ async _activateAddon(addon, extraParams = {}) {
+ if (addon.appDisabled) {
+ let message = `Add-on ${addon.id} is not compatible with application version.`;
+
+ let app = addon.matchingTargetApplication;
+ if (app) {
+ if (app.minVersion) {
+ message += ` add-on minVersion: ${app.minVersion}.`;
+ }
+ if (app.maxVersion) {
+ message += ` add-on maxVersion: ${app.maxVersion}.`;
+ }
+ }
+ throw new Error(message);
+ }
+
+ let oldAddon = await lazy.XPIDatabase.getVisibleAddonForID(addon.id);
+
+ let willActivate =
+ !oldAddon ||
+ oldAddon.location == addon.location ||
+ addon.location.hasPrecedence(oldAddon.location);
+
+ let install = () => {
+ addon.visible = willActivate;
+ // Themes are generally not enabled by default at install time,
+ // unless enabled by the front-end code. If they are meant to be
+ // enabled, they will already have been enabled by this point.
+ if (addon.type !== "theme" || addon.location.isTemporary) {
+ addon.userDisabled = false;
+ }
+ addon.active = addon.visible && !addon.disabled;
+
+ addon = lazy.XPIDatabase.addToDatabase(
+ addon,
+ addon._sourceBundle ? addon._sourceBundle.path : null
+ );
+
+ lazy.XPIInternal.XPIStates.addAddon(addon);
+ lazy.XPIInternal.XPIStates.save();
+ };
+
+ AddonManagerPrivate.callAddonListeners("onInstalling", addon.wrapper);
+
+ if (!willActivate) {
+ addon.installDate = Date.now();
+
+ install();
+ } else if (oldAddon) {
+ logger.warn(
+ `Addon with ID ${oldAddon.id} already installed, ` +
+ "older version will be disabled"
+ );
+
+ addon.installDate = oldAddon.installDate;
+
+ await lazy.XPIInternal.BootstrapScope.get(oldAddon).update(
+ addon,
+ true,
+ install
+ );
+ } else {
+ addon.installDate = Date.now();
+
+ install();
+ let bootstrap = lazy.XPIInternal.BootstrapScope.get(addon);
+ await bootstrap.install(undefined, true, extraParams);
+ }
+
+ AddonManagerPrivate.callInstallListeners(
+ "onExternalInstall",
+ null,
+ addon.wrapper,
+ oldAddon ? oldAddon.wrapper : null,
+ false
+ );
+ AddonManagerPrivate.callAddonListeners("onInstalled", addon.wrapper);
+
+ // Notify providers that a new theme has been enabled.
+ if (addon.type === "theme" && !addon.userDisabled) {
+ AddonManagerPrivate.notifyAddonChanged(addon.id, addon.type, false);
+ }
+ },
+
+ /**
+ * Uninstalls an add-on, immediately if possible or marks it as pending
+ * uninstall if not.
+ *
+ * @param {DBAddonInternal} aAddon
+ * The DBAddonInternal to uninstall
+ * @param {boolean} aForcePending
+ * Force this addon into the pending uninstall state (used
+ * e.g. while the add-on manager is open and offering an
+ * "undo" button)
+ * @throws if the addon cannot be uninstalled because it is in an install
+ * location that does not allow it
+ */
+ async uninstallAddon(aAddon, aForcePending) {
+ if (!aAddon.inDatabase) {
+ throw new Error(
+ `Cannot uninstall addon ${aAddon.id} because it is not installed`
+ );
+ }
+ let { location } = aAddon;
+
+ // If the addon is sideloaded into a location that does not allow
+ // sideloads, it is a legacy sideload. We allow those to be uninstalled.
+ let isLegacySideload =
+ aAddon.foreignInstall &&
+ !(location.scope & lazy.AddonSettings.SCOPES_SIDELOAD);
+
+ if (location.locked && !isLegacySideload) {
+ throw new Error(
+ `Cannot uninstall addon ${aAddon.id} ` +
+ `from locked install location ${location.name}`
+ );
+ }
+
+ if (aForcePending && aAddon.pendingUninstall) {
+ throw new Error("Add-on is already marked to be uninstalled");
+ }
+
+ if (aAddon._updateCheck) {
+ logger.debug(`Cancel in-progress update check for ${aAddon.id}`);
+ aAddon._updateCheck.cancel();
+ }
+
+ let wasActive = aAddon.active;
+ let wasPending = aAddon.pendingUninstall;
+
+ if (aForcePending) {
+ // We create an empty directory in the staging directory to indicate
+ // that an uninstall is necessary on next startup. Temporary add-ons are
+ // automatically uninstalled on shutdown anyway so there is no need to
+ // do this for them.
+ if (!aAddon.location.isTemporary && aAddon.location.installer) {
+ let stage = getFile(
+ aAddon.id,
+ aAddon.location.installer.getStagingDir()
+ );
+ if (!stage.exists()) {
+ stage.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ lazy.FileUtils.PERMS_DIRECTORY
+ );
+ }
+ }
+
+ lazy.XPIDatabase.setAddonProperties(aAddon, {
+ pendingUninstall: true,
+ });
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
+ let xpiState = aAddon.location.get(aAddon.id);
+ if (xpiState) {
+ xpiState.enabled = false;
+ lazy.XPIInternal.XPIStates.save();
+ } else {
+ logger.warn(
+ "Can't find XPI state while uninstalling ${id} from ${location}",
+ aAddon
+ );
+ }
+ }
+
+ // If the add-on is not visible then there is no need to notify listeners.
+ if (!aAddon.visible) {
+ return;
+ }
+
+ let wrapper = aAddon.wrapper;
+
+ // If the add-on wasn't already pending uninstall then notify listeners.
+ if (!wasPending) {
+ AddonManagerPrivate.callAddonListeners(
+ "onUninstalling",
+ wrapper,
+ !!aForcePending
+ );
+ }
+
+ let existingAddon = lazy.XPIInternal.XPIStates.findAddon(
+ aAddon.id,
+ loc => loc != aAddon.location
+ );
+
+ let bootstrap = lazy.XPIInternal.BootstrapScope.get(aAddon);
+ if (!aForcePending) {
+ let existing;
+ if (existingAddon) {
+ existing = await lazy.XPIDatabase.getAddonInLocation(
+ aAddon.id,
+ existingAddon.location.name
+ );
+ }
+
+ let uninstall = () => {
+ lazy.XPIInternal.XPIStates.disableAddon(aAddon.id);
+ if (aAddon.location.installer) {
+ aAddon.location.installer.uninstallAddon(aAddon.id);
+ }
+ lazy.XPIDatabase.removeAddonMetadata(aAddon);
+ aAddon.location.removeAddon(aAddon.id);
+ AddonManagerPrivate.callAddonListeners("onUninstalled", wrapper);
+
+ if (existing) {
+ // Migrate back to the existing addon, unless it was a builtin colorway theme,
+ // in that case we also make sure to remove the addon from the builtin location.
+ if (
+ existing.isBuiltinColorwayTheme &&
+ lazy.BuiltInThemesHelpers.isColorwayMigrationEnabled
+ ) {
+ existing.location.removeAddon(existing.id);
+ } else {
+ lazy.XPIDatabase.makeAddonVisible(existing);
+ AddonManagerPrivate.callAddonListeners(
+ "onInstalling",
+ existing.wrapper,
+ false
+ );
+
+ if (!existing.disabled) {
+ lazy.XPIDatabase.updateAddonActive(existing, true);
+ }
+ }
+ }
+ };
+
+ // Migrate back to the existing addon, unless it was a builtin colorway theme.
+ if (
+ existing &&
+ !(
+ existing.isBuiltinColorwayTheme &&
+ lazy.BuiltInThemesHelpers.isColorwayMigrationEnabled
+ )
+ ) {
+ await bootstrap.update(existing, !existing.disabled, uninstall);
+
+ AddonManagerPrivate.callAddonListeners("onInstalled", existing.wrapper);
+ } else {
+ aAddon.location.removeAddon(aAddon.id);
+ await bootstrap.uninstall();
+ uninstall();
+ }
+ } else if (aAddon.active) {
+ lazy.XPIInternal.XPIStates.disableAddon(aAddon.id);
+ bootstrap.shutdown(lazy.XPIInternal.BOOTSTRAP_REASONS.ADDON_UNINSTALL);
+ lazy.XPIDatabase.updateAddonActive(aAddon, false);
+ }
+
+ // Notify any other providers that a new theme has been enabled
+ // (when the active theme is uninstalled, the default theme is enabled).
+ if (aAddon.type === "theme" && wasActive) {
+ AddonManagerPrivate.notifyAddonChanged(null, aAddon.type);
+ }
+ },
+
+ /**
+ * Cancels the pending uninstall of an add-on.
+ *
+ * @param {DBAddonInternal} aAddon
+ * The DBAddonInternal to cancel uninstall for
+ */
+ cancelUninstallAddon(aAddon) {
+ if (!aAddon.inDatabase) {
+ throw new Error("Can only cancel uninstall for installed addons.");
+ }
+ if (!aAddon.pendingUninstall) {
+ throw new Error("Add-on is not marked to be uninstalled");
+ }
+
+ if (!aAddon.location.isTemporary && aAddon.location.installer) {
+ aAddon.location.installer.cleanStagingDir([aAddon.id]);
+ }
+
+ lazy.XPIDatabase.setAddonProperties(aAddon, {
+ pendingUninstall: false,
+ });
+
+ if (!aAddon.visible) {
+ return;
+ }
+
+ aAddon.location.get(aAddon.id).syncWithDB(aAddon);
+ lazy.XPIInternal.XPIStates.save();
+
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
+
+ if (!aAddon.disabled) {
+ lazy.XPIInternal.BootstrapScope.get(aAddon).startup(
+ lazy.XPIInternal.BOOTSTRAP_REASONS.ADDON_INSTALL
+ );
+ lazy.XPIDatabase.updateAddonActive(aAddon, true);
+ }
+
+ let wrapper = aAddon.wrapper;
+ AddonManagerPrivate.callAddonListeners("onOperationCancelled", wrapper);
+
+ // Notify any other providers that this theme is now enabled again.
+ if (aAddon.type === "theme" && aAddon.active) {
+ AddonManagerPrivate.notifyAddonChanged(aAddon.id, aAddon.type, false);
+ }
+ },
+
+ DirectoryInstaller,
+ SystemAddonInstaller,
+};
diff --git a/toolkit/mozapps/extensions/internal/XPIProvider.jsm b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
new file mode 100644
index 0000000000..94e5e26636
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -0,0 +1,3378 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * This file contains most of the logic required to load and run
+ * extensions at startup. Anything which is not required immediately at
+ * startup should go in XPIInstall.jsm or XPIDatabase.jsm if at all
+ * possible, in order to minimize the impact on startup performance.
+ */
+
+/**
+ * @typedef {number} integer
+ */
+
+/* eslint "valid-jsdoc": [2, {requireReturn: false, requireReturnDescription: false, prefer: {return: "returns"}}] */
+
+var EXPORTED_SYMBOLS = ["XPIProvider", "XPIInternal"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { AddonManager, AddonManagerPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs",
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+ Dictionary: "resource://gre/modules/Extension.sys.mjs",
+ Extension: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionData: "resource://gre/modules/Extension.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
+ Langpack: "resource://gre/modules/Extension.sys.mjs",
+ SitePermission: "resource://gre/modules/Extension.sys.mjs",
+ TelemetrySession: "resource://gre/modules/TelemetrySession.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ XPIDatabase: "resource://gre/modules/addons/XPIDatabase.jsm",
+ XPIDatabaseReconcile: "resource://gre/modules/addons/XPIDatabase.jsm",
+ XPIInstall: "resource://gre/modules/addons/XPIInstall.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ aomStartup: [
+ "@mozilla.org/addons/addon-manager-startup;1",
+ "amIAddonManagerStartup",
+ ],
+ resProto: [
+ "@mozilla.org/network/protocol;1?name=resource",
+ "nsISubstitutingProtocolHandler",
+ ],
+ spellCheck: ["@mozilla.org/spellchecker/engine;1", "mozISpellCheckingEngine"],
+ timerManager: [
+ "@mozilla.org/updates/timer-manager;1",
+ "nsIUpdateTimerManager",
+ ],
+});
+
+const nsIFile = Components.Constructor(
+ "@mozilla.org/file/local;1",
+ "nsIFile",
+ "initWithPath"
+);
+const FileInputStream = Components.Constructor(
+ "@mozilla.org/network/file-input-stream;1",
+ "nsIFileInputStream",
+ "init"
+);
+
+const PREF_DB_SCHEMA = "extensions.databaseSchema";
+const PREF_PENDING_OPERATIONS = "extensions.pendingOperations";
+const PREF_EM_ENABLED_SCOPES = "extensions.enabledScopes";
+const PREF_EM_STARTUP_SCAN_SCOPES = "extensions.startupScanScopes";
+// xpinstall.signatures.required only supported in dev builds
+const PREF_XPI_SIGNATURES_REQUIRED = "xpinstall.signatures.required";
+const PREF_LANGPACK_SIGNATURES = "extensions.langpacks.signatures.required";
+const PREF_INSTALL_DISTRO_ADDONS = "extensions.installDistroAddons";
+const PREF_BRANCH_INSTALLED_ADDON = "extensions.installedDistroAddon.";
+const PREF_SYSTEM_ADDON_SET = "extensions.systemAddonSet";
+
+const PREF_EM_LAST_APP_BUILD_ID = "extensions.lastAppBuildId";
+
+// Specify a list of valid built-in add-ons to load.
+const BUILT_IN_ADDONS_URI = "chrome://browser/content/built_in_addons.json";
+
+const DIR_EXTENSIONS = "extensions";
+const DIR_SYSTEM_ADDONS = "features";
+const DIR_APP_SYSTEM_PROFILE = "system-extensions";
+const DIR_STAGE = "staged";
+const DIR_TRASH = "trash";
+
+const FILE_XPI_STATES = "addonStartup.json.lz4";
+
+const KEY_PROFILEDIR = "ProfD";
+const KEY_ADDON_APP_DIR = "XREAddonAppDir";
+const KEY_APP_DISTRIBUTION = "XREAppDist";
+const KEY_APP_FEATURES = "XREAppFeat";
+
+const KEY_APP_PROFILE = "app-profile";
+const KEY_APP_SYSTEM_PROFILE = "app-system-profile";
+const KEY_APP_SYSTEM_ADDONS = "app-system-addons";
+const KEY_APP_SYSTEM_DEFAULTS = "app-system-defaults";
+const KEY_APP_BUILTINS = "app-builtin";
+const KEY_APP_GLOBAL = "app-global";
+const KEY_APP_SYSTEM_LOCAL = "app-system-local";
+const KEY_APP_SYSTEM_SHARE = "app-system-share";
+const KEY_APP_SYSTEM_USER = "app-system-user";
+const KEY_APP_TEMPORARY = "app-temporary";
+
+const TEMPORARY_ADDON_SUFFIX = "@temporary-addon";
+
+const STARTUP_MTIME_SCOPES = [
+ KEY_APP_GLOBAL,
+ KEY_APP_SYSTEM_LOCAL,
+ KEY_APP_SYSTEM_SHARE,
+ KEY_APP_SYSTEM_USER,
+];
+
+const NOTIFICATION_FLUSH_PERMISSIONS = "flush-pending-permissions";
+const XPI_PERMISSION = "install";
+
+const XPI_SIGNATURE_CHECK_PERIOD = 24 * 60 * 60;
+
+const DB_SCHEMA = 35;
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "enabledScopesPref",
+ PREF_EM_ENABLED_SCOPES,
+ AddonManager.SCOPE_ALL
+);
+
+Object.defineProperty(lazy, "enabledScopes", {
+ get() {
+ // The profile location is always enabled
+ return lazy.enabledScopesPref | AddonManager.SCOPE_PROFILE;
+ },
+});
+
+function encoded(strings, ...values) {
+ let result = [];
+
+ for (let [i, string] of strings.entries()) {
+ result.push(string);
+ if (i < values.length) {
+ result.push(encodeURIComponent(values[i]));
+ }
+ }
+
+ return result.join("");
+}
+
+const BOOTSTRAP_REASONS = {
+ APP_STARTUP: 1,
+ APP_SHUTDOWN: 2,
+ ADDON_ENABLE: 3,
+ ADDON_DISABLE: 4,
+ ADDON_INSTALL: 5,
+ ADDON_UNINSTALL: 6,
+ ADDON_UPGRADE: 7,
+ ADDON_DOWNGRADE: 8,
+};
+
+// All addonTypes supported by the XPIProvider. These values can be passed to
+// AddonManager.getAddonsByTypes in order to get XPIProvider.getAddonsByTypes
+// to return only supported add-ons. Without these, it is possible for
+// AddonManager.getAddonsByTypes to return addons from other providers, or even
+// add-on types that are no longer supported by XPIProvider.
+const ALL_XPI_TYPES = new Set([
+ "dictionary",
+ "extension",
+ "locale",
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ "sitepermission-deprecated",
+ "theme",
+]);
+
+/**
+ * Valid IDs fit this pattern.
+ */
+var gIDTest =
+ /^(\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}|[a-z0-9-\._]*\@[a-z0-9-\._]+)$/i;
+
+const { Log } = ChromeUtils.importESModule(
+ "resource://gre/modules/Log.sys.mjs"
+);
+const LOGGER_ID = "addons.xpi";
+
+// Create a new logger for use by all objects in this Addons XPI Provider module
+// (Requires AddonManager.jsm)
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+/**
+ * Spins the event loop until the given promise resolves, and then eiter returns
+ * its success value or throws its rejection value.
+ *
+ * @param {Promise} promise
+ * The promise to await.
+ * @returns {any}
+ * The promise's resolution value, if any.
+ */
+function awaitPromise(promise) {
+ let success = undefined;
+ let result = null;
+
+ promise.then(
+ val => {
+ success = true;
+ result = val;
+ },
+ val => {
+ success = false;
+ result = val;
+ }
+ );
+
+ Services.tm.spinEventLoopUntil(
+ "XPIProvider.jsm:awaitPromise",
+ () => success !== undefined
+ );
+
+ if (!success) {
+ throw result;
+ }
+ return result;
+}
+
+/**
+ * Returns a nsIFile instance for the given path, relative to the given
+ * base file, if provided.
+ *
+ * @param {string} path
+ * The (possibly relative) path of the file.
+ * @param {nsIFile} [base]
+ * An optional file to use as a base path if `path` is relative.
+ * @returns {nsIFile}
+ */
+function getFile(path, base = null) {
+ // First try for an absolute path, as we get in the case of proxy
+ // files. Ideally we would try a relative path first, but on Windows,
+ // paths which begin with a drive letter are valid as relative paths,
+ // and treated as such.
+ try {
+ return new nsIFile(path);
+ } catch (e) {
+ // Ignore invalid relative paths. The only other error we should see
+ // here is EOM, and either way, any errors that we care about should
+ // be re-thrown below.
+ }
+
+ // If the path isn't absolute, we must have a base path.
+ let file = base.clone();
+ file.appendRelativePath(path);
+ return file;
+}
+
+/**
+ * Returns true if the given file, based on its name, should be treated
+ * as an XPI. If the file does not have an appropriate extension, it is
+ * assumed to be an unpacked add-on.
+ *
+ * @param {string} filename
+ * The filename to check.
+ * @param {boolean} [strict = false]
+ * If true, this file is in a location maintained by the browser, and
+ * must have a strict, lower-case ".xpi" extension.
+ * @returns {boolean}
+ * True if the file is an XPI.
+ */
+function isXPI(filename, strict) {
+ if (strict) {
+ return filename.endsWith(".xpi");
+ }
+ let ext = filename.slice(-4).toLowerCase();
+ return ext === ".xpi" || ext === ".zip";
+}
+
+/**
+ * Returns the extension expected ID for a given file in an extension install
+ * directory.
+ *
+ * @param {nsIFile} file
+ * The extension XPI file or unpacked directory.
+ * @returns {AddonId?}
+ * The add-on ID, if valid, or null otherwise.
+ */
+function getExpectedID(file) {
+ let { leafName } = file;
+ let id = isXPI(leafName, true) ? leafName.slice(0, -4) : leafName;
+ if (gIDTest.test(id)) {
+ return id;
+ }
+ return null;
+}
+
+/**
+ * Evaluates whether an add-on is allowed to run in safe mode.
+ *
+ * @param {AddonInternal} aAddon
+ * The add-on to check
+ * @returns {boolean}
+ * True if the add-on should run in safe mode
+ */
+function canRunInSafeMode(aAddon) {
+ let location = aAddon.location || null;
+ if (!location) {
+ return false;
+ }
+
+ // Even though the updated system add-ons aren't generally run in safe mode we
+ // include them here so their uninstall functions get called when switching
+ // back to the default set.
+
+ // TODO product should make the call about temporary add-ons running
+ // in safe mode. assuming for now that they are.
+ return location.isTemporary || location.isSystem || location.isBuiltin;
+}
+
+/**
+ * Gets an nsIURI for a file within another file, either a directory or an XPI
+ * file. If aFile is a directory then this will return a file: URI, if it is an
+ * XPI file then it will return a jar: URI.
+ *
+ * @param {nsIFile} aFile
+ * The file containing the resources, must be either a directory or an
+ * XPI file
+ * @param {string} aPath
+ * The path to find the resource at, "/" separated. If aPath is empty
+ * then the uri to the root of the contained files will be returned
+ * @returns {nsIURI}
+ * An nsIURI pointing at the resource
+ */
+function getURIForResourceInFile(aFile, aPath) {
+ if (!isXPI(aFile.leafName)) {
+ let resource = aFile.clone();
+ if (aPath) {
+ aPath.split("/").forEach(part => resource.append(part));
+ }
+
+ return Services.io.newFileURI(resource);
+ }
+
+ return buildJarURI(aFile, aPath);
+}
+
+/**
+ * Creates a jar: URI for a file inside a ZIP file.
+ *
+ * @param {nsIFile} aJarfile
+ * The ZIP file as an nsIFile
+ * @param {string} aPath
+ * The path inside the ZIP file
+ * @returns {nsIURI}
+ * An nsIURI for the file
+ */
+function buildJarURI(aJarfile, aPath) {
+ let uri = Services.io.newFileURI(aJarfile);
+ uri = "jar:" + uri.spec + "!/" + aPath;
+ return Services.io.newURI(uri);
+}
+
+function maybeResolveURI(uri) {
+ if (uri.schemeIs("resource")) {
+ return Services.io.newURI(lazy.resProto.resolveURI(uri));
+ }
+ return uri;
+}
+
+/**
+ * Iterates over the entries in a given directory.
+ *
+ * Fails silently if the given directory does not exist.
+ *
+ * @param {nsIFile} aDir
+ * Directory to iterate.
+ */
+function* iterDirectory(aDir) {
+ let dirEnum;
+ try {
+ dirEnum = aDir.directoryEntries;
+ let file;
+ while ((file = dirEnum.nextFile)) {
+ yield file;
+ }
+ } catch (e) {
+ if (aDir.exists()) {
+ logger.warn(`Can't iterate directory ${aDir.path}`, e);
+ }
+ } finally {
+ if (dirEnum) {
+ dirEnum.close();
+ }
+ }
+}
+
+/**
+ * Migrate data about an addon to match the change made in bug 857456
+ * in which "webextension-foo" types were converted to "foo" and the
+ * "loader" property was added to distinguish different addon types.
+ *
+ * @param {Object} addon The addon info to migrate.
+ * @returns {boolean} True if the addon data was converted, false if not.
+ */
+function migrateAddonLoader(addon) {
+ if (addon.hasOwnProperty("loader")) {
+ return false;
+ }
+
+ switch (addon.type) {
+ case "extension":
+ case "dictionary":
+ case "locale":
+ case "theme":
+ addon.loader = "bootstrap";
+ break;
+
+ case "webextension":
+ addon.type = "extension";
+ addon.loader = null;
+ break;
+
+ case "webextension-dictionary":
+ addon.type = "dictionary";
+ addon.loader = null;
+ break;
+
+ case "webextension-langpack":
+ addon.type = "locale";
+ addon.loader = null;
+ break;
+
+ case "webextension-theme":
+ addon.type = "theme";
+ addon.loader = null;
+ break;
+
+ default:
+ logger.warn(`Not converting unknown addon type ${addon.type}`);
+ }
+ return true;
+}
+
+/**
+ * The on-disk state of an individual XPI, created from an Object
+ * as stored in the addonStartup.json file.
+ */
+const JSON_FIELDS = Object.freeze([
+ "dependencies",
+ "enabled",
+ "file",
+ "loader",
+ "lastModifiedTime",
+ "path",
+ "recommendationState",
+ "rootURI",
+ "runInSafeMode",
+ "signedState",
+ "signedDate",
+ "startupData",
+ "telemetryKey",
+ "type",
+ "version",
+]);
+
+class XPIState {
+ constructor(location, id, saved = {}) {
+ this.location = location;
+ this.id = id;
+
+ // Set default values.
+ this.type = "extension";
+
+ for (let prop of JSON_FIELDS) {
+ if (prop in saved) {
+ this[prop] = saved[prop];
+ }
+ }
+
+ // Builds prior to be 1512436 did not include the rootURI property.
+ // If we're updating from such a build, add that property now.
+ if (!("rootURI" in this) && this.file) {
+ this.rootURI = getURIForResourceInFile(this.file, "").spec;
+ }
+
+ if (!this.telemetryKey) {
+ this.telemetryKey = this.getTelemetryKey();
+ }
+
+ if (
+ saved.currentModifiedTime &&
+ saved.currentModifiedTime != this.lastModifiedTime
+ ) {
+ this.lastModifiedTime = saved.currentModifiedTime;
+ } else if (saved.currentModifiedTime === null) {
+ this.missing = true;
+ }
+ }
+
+ // Compatibility shim getters for legacy callers in XPIDatabase.jsm.
+ get mtime() {
+ return this.lastModifiedTime;
+ }
+ get active() {
+ return this.enabled;
+ }
+
+ /**
+ * @property {string} path
+ * The full on-disk path of the add-on.
+ */
+ get path() {
+ return this.file && this.file.path;
+ }
+ set path(path) {
+ this.file = path ? getFile(path, this.location.dir) : null;
+ }
+
+ /**
+ * @property {string} relativePath
+ * The path to the add-on relative to its parent location, or
+ * the full path if its parent location has no on-disk path.
+ */
+ get relativePath() {
+ if (this.location.dir && this.location.dir.contains(this.file)) {
+ let path = this.file.getRelativePath(this.location.dir);
+ if (AppConstants.platform == "win") {
+ path = path.replace(/\//g, "\\");
+ }
+ return path;
+ }
+ return this.path;
+ }
+
+ /**
+ * Returns a JSON-compatible representation of this add-on's state
+ * data, to be saved to addonStartup.json.
+ *
+ * @returns {Object}
+ */
+ toJSON() {
+ let json = {
+ dependencies: this.dependencies,
+ enabled: this.enabled,
+ lastModifiedTime: this.lastModifiedTime,
+ loader: this.loader,
+ path: this.relativePath,
+ recommendationState: this.recommendationState,
+ rootURI: this.rootURI,
+ runInSafeMode: this.runInSafeMode,
+ signedState: this.signedState,
+ signedDate: this.signedDate,
+ telemetryKey: this.telemetryKey,
+ version: this.version,
+ };
+ if (this.type != "extension") {
+ json.type = this.type;
+ }
+ if (this.startupData) {
+ json.startupData = this.startupData;
+ }
+ return json;
+ }
+
+ get isWebExtension() {
+ return this.loader == null;
+ }
+
+ get isPrivileged() {
+ return lazy.ExtensionData.getIsPrivileged({
+ signedState: this.signedState,
+ builtIn: this.location.isBuiltin,
+ temporarilyInstalled: this.location.isTemporary,
+ });
+ }
+
+ /**
+ * Update the last modified time for an add-on on disk.
+ *
+ * @param {nsIFile} aFile
+ * The location of the add-on.
+ * @returns {boolean}
+ * True if the time stamp has changed.
+ */
+ getModTime(aFile) {
+ let mtime = 0;
+ try {
+ // Clone the file object so we always get the actual mtime, rather
+ // than whatever value it may have cached.
+ mtime = aFile.clone().lastModifiedTime;
+ } catch (e) {
+ logger.warn("Can't get modified time of ${path}", aFile, e);
+ }
+
+ let changed = mtime != this.lastModifiedTime;
+ this.lastModifiedTime = mtime;
+ return changed;
+ }
+
+ /**
+ * Returns a string key by which to identify this add-on in telemetry
+ * and crash reports.
+ *
+ * @returns {string}
+ */
+ getTelemetryKey() {
+ return encoded`${this.id}:${this.version}`;
+ }
+
+ get resolvedRootURI() {
+ return maybeResolveURI(Services.io.newURI(this.rootURI));
+ }
+
+ /**
+ * Update the XPIState to match an XPIDatabase entry; if 'enabled' is changed to true,
+ * update the last-modified time. This should probably be made async, but for now we
+ * don't want to maintain parallel sync and async versions of the scan.
+ *
+ * Caller is responsible for doing XPIStates.save() if necessary.
+ *
+ * @param {DBAddonInternal} aDBAddon
+ * The DBAddonInternal for this add-on.
+ * @param {boolean} [aUpdated = false]
+ * The add-on was updated, so we must record new modified time.
+ */
+ syncWithDB(aDBAddon, aUpdated = false) {
+ logger.debug("Updating XPIState for " + JSON.stringify(aDBAddon));
+ // If the add-on changes from disabled to enabled, we should re-check the modified time.
+ // If this is a newly found add-on, it won't have an 'enabled' field but we
+ // did a full recursive scan in that case, so we don't need to do it again.
+ // We don't use aDBAddon.active here because it's not updated until after restart.
+ let mustGetMod = aDBAddon.visible && !aDBAddon.disabled && !this.enabled;
+
+ this.enabled = aDBAddon.visible && !aDBAddon.disabled;
+
+ this.version = aDBAddon.version;
+ this.type = aDBAddon.type;
+ this.loader = aDBAddon.loader;
+
+ if (aDBAddon.startupData) {
+ this.startupData = aDBAddon.startupData;
+ }
+
+ this.telemetryKey = this.getTelemetryKey();
+
+ this.dependencies = aDBAddon.dependencies;
+ this.runInSafeMode = canRunInSafeMode(aDBAddon);
+ this.signedState = aDBAddon.signedState;
+ this.signedDate = aDBAddon.signedDate;
+ this.file = aDBAddon._sourceBundle;
+ this.rootURI = aDBAddon.rootURI;
+ this.recommendationState = aDBAddon.recommendationState;
+
+ if ((aUpdated || mustGetMod) && this.file) {
+ this.getModTime(this.file);
+ if (this.lastModifiedTime != aDBAddon.updateDate) {
+ aDBAddon.updateDate = this.lastModifiedTime;
+ if (lazy.XPIDatabase.initialized) {
+ lazy.XPIDatabase.saveChanges();
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Manages the state data for add-ons in a given install location.
+ *
+ * @param {string} name
+ * The name of the install location (e.g., "app-profile").
+ * @param {string | nsIFile | null} path
+ * The on-disk path of the install location. May be null for some
+ * locations which do not map to a specific on-disk path.
+ * @param {integer} scope
+ * The scope of add-ons installed in this location.
+ * @param {object} [saved]
+ * The persisted JSON state data to restore.
+ */
+class XPIStateLocation extends Map {
+ constructor(name, path, scope, saved) {
+ super();
+
+ this.name = name;
+ this.scope = scope;
+ if (path instanceof Ci.nsIFile) {
+ this.dir = path;
+ this.path = path.path;
+ } else {
+ this.path = path;
+ this.dir = this.path && new nsIFile(this.path);
+ }
+ this.staged = {};
+ this.changed = false;
+
+ if (saved) {
+ this.restore(saved);
+ }
+
+ this._installer = undefined;
+ }
+
+ hasPrecedence(otherLocation) {
+ let locations = Array.from(XPIStates.locations());
+ return locations.indexOf(this) <= locations.indexOf(otherLocation);
+ }
+
+ get installer() {
+ if (this._installer === undefined) {
+ this._installer = this.makeInstaller();
+ }
+ return this._installer;
+ }
+
+ makeInstaller() {
+ return null;
+ }
+
+ restore(saved) {
+ if (!this.path && saved.path) {
+ this.path = saved.path;
+ this.dir = new nsIFile(this.path);
+ }
+ this.staged = saved.staged || {};
+ this.changed = saved.changed || false;
+
+ for (let [id, data] of Object.entries(saved.addons || {})) {
+ let xpiState = this._addState(id, data);
+
+ // Make a note that this state was restored from saved data. But
+ // only if this location hasn't moved since the last startup,
+ // since that causes problems for new system add-on bundles.
+ if (!this.path || this.path == saved.path) {
+ xpiState.wasRestored = true;
+ }
+ }
+ }
+
+ /**
+ * Returns a JSON-compatible representation of this location's state
+ * data, to be saved to addonStartup.json.
+ *
+ * @returns {Object}
+ */
+ toJSON() {
+ let json = {
+ addons: {},
+ staged: this.staged,
+ };
+
+ if (this.path) {
+ json.path = this.path;
+ }
+
+ if (STARTUP_MTIME_SCOPES.includes(this.name)) {
+ json.checkStartupModifications = true;
+ }
+
+ for (let [id, addon] of this.entries()) {
+ json.addons[id] = addon;
+ }
+ return json;
+ }
+
+ get hasStaged() {
+ for (let key in this.staged) {
+ return true;
+ }
+ return false;
+ }
+
+ _addState(addonId, saved) {
+ let xpiState = new XPIState(this, addonId, saved);
+ this.set(addonId, xpiState);
+ return xpiState;
+ }
+
+ /**
+ * Adds state data for the given DB add-on to the DB.
+ *
+ * @param {DBAddon} addon
+ * The DBAddon to add.
+ */
+ addAddon(addon) {
+ logger.debug(
+ "XPIStates adding add-on ${id} in ${location}: ${path}",
+ addon
+ );
+
+ XPIProvider.persistStartupData(addon);
+
+ let xpiState = this._addState(addon.id, { file: addon._sourceBundle });
+ xpiState.syncWithDB(addon, true);
+
+ XPIProvider.addTelemetry(addon.id, { location: this.name });
+ }
+
+ /**
+ * Remove the XPIState for an add-on and save the new state.
+ *
+ * @param {string} aId
+ * The ID of the add-on.
+ */
+ removeAddon(aId) {
+ if (this.has(aId)) {
+ this.delete(aId);
+ XPIStates.save();
+ }
+ }
+
+ /**
+ * Adds stub state data for the local file to the DB.
+ *
+ * @param {string} addonId
+ * The ID of the add-on represented by the given file.
+ * @param {nsIFile} file
+ * The local file or directory containing the add-on.
+ * @returns {XPIState}
+ */
+ addFile(addonId, file) {
+ let xpiState = this._addState(addonId, {
+ enabled: false,
+ file: file.clone(),
+ });
+ xpiState.getModTime(xpiState.file);
+ return xpiState;
+ }
+
+ /**
+ * Adds metadata for a staged install which should be performed after
+ * the next restart.
+ *
+ * @param {string} addonId
+ * The ID of the staged install. The leaf name of the XPI
+ * within the location's staging directory must correspond to
+ * this ID.
+ * @param {object} metadata
+ * The JSON metadata of the parsed install, to be used during
+ * the next startup.
+ */
+ stageAddon(addonId, metadata) {
+ this.staged[addonId] = metadata;
+ XPIStates.save();
+ }
+
+ /**
+ * Removes staged install metadata for the given add-on ID.
+ *
+ * @param {string} addonId
+ * The ID of the staged install.
+ */
+ unstageAddon(addonId) {
+ if (addonId in this.staged) {
+ delete this.staged[addonId];
+ XPIStates.save();
+ }
+ }
+
+ *getStagedAddons() {
+ for (let [id, metadata] of Object.entries(this.staged)) {
+ yield [id, metadata];
+ }
+ }
+
+ /**
+ * Returns true if the given addon was installed in this location by a text
+ * file pointing to its real path.
+ *
+ * @param {string} aId
+ * The ID of the addon
+ * @returns {boolean}
+ */
+ isLinkedAddon(aId) {
+ if (!this.dir) {
+ return true;
+ }
+ return this.has(aId) && !this.dir.contains(this.get(aId).file);
+ }
+
+ get isTemporary() {
+ return false;
+ }
+
+ get isSystem() {
+ return false;
+ }
+
+ get isBuiltin() {
+ return false;
+ }
+
+ get hidden() {
+ return this.isBuiltin;
+ }
+
+ // If this property is false, it does not implement readAddons()
+ // interface. This is used for the temporary and built-in locations
+ // that do not correspond to a physical location that can be scanned.
+ get enumerable() {
+ return true;
+ }
+}
+
+class TemporaryLocation extends XPIStateLocation {
+ /**
+ * @param {string} name
+ * The string identifier for the install location.
+ */
+ constructor(name) {
+ super(name, null, AddonManager.SCOPE_TEMPORARY);
+ this.locked = false;
+ }
+
+ makeInstaller() {
+ // Installs are a no-op. We only register that add-ons exist, and
+ // run them from their current location.
+ return {
+ installAddon() {},
+ uninstallAddon() {},
+ };
+ }
+
+ toJSON() {
+ return {};
+ }
+
+ get isTemporary() {
+ return true;
+ }
+
+ get enumerable() {
+ return false;
+ }
+}
+
+var TemporaryInstallLocation = new TemporaryLocation(KEY_APP_TEMPORARY);
+
+/**
+ * A "location" for addons installed from assets packged into the app.
+ */
+var BuiltInLocation = new (class _BuiltInLocation extends XPIStateLocation {
+ constructor() {
+ super(KEY_APP_BUILTINS, null, AddonManager.SCOPE_APPLICATION);
+ this.locked = false;
+ }
+
+ // The installer object is responsible for moving files around on disk
+ // when (un)installing an addon. Since this location handles only addons
+ // that are embedded within the browser, these are no-ops.
+ makeInstaller() {
+ return {
+ installAddon() {},
+ uninstallAddon() {},
+ };
+ }
+
+ get hidden() {
+ return false;
+ }
+
+ get isBuiltin() {
+ return true;
+ }
+
+ get enumerable() {
+ return false;
+ }
+
+ // Builtin addons are never linked, return false
+ // here for correct behavior elsewhere.
+ isLinkedAddon(/* aId */) {
+ return false;
+ }
+})();
+
+/**
+ * An object which identifies a directory install location for add-ons. The
+ * location consists of a directory which contains the add-ons installed in the
+ * location.
+ *
+ */
+class DirectoryLocation extends XPIStateLocation {
+ /**
+ * Each add-on installed in the location is either a directory containing the
+ * add-on's files or a text file containing an absolute path to the directory
+ * containing the add-ons files. The directory or text file must have the same
+ * name as the add-on's ID.
+ *
+ * @param {string} name
+ * The string identifier for the install location.
+ * @param {nsIFile} dir
+ * The directory for the install location.
+ * @param {integer} scope
+ * The scope of add-ons installed in this location.
+ * @param {boolean} [locked = true]
+ * If false, the location accepts new add-on installs.
+ * @param {boolean} [system = false]
+ * If true, the location is a system addon location.
+ */
+ constructor(name, dir, scope, locked = true, system = false) {
+ super(name, dir, scope);
+ this.locked = locked;
+ this._isSystem = system;
+ }
+
+ makeInstaller() {
+ if (this.locked) {
+ return null;
+ }
+ return new lazy.XPIInstall.DirectoryInstaller(this);
+ }
+
+ /**
+ * Reads a single-line file containing the path to a directory, and
+ * returns an nsIFile pointing to that directory, if successful.
+ *
+ * @param {nsIFile} aFile
+ * The file containing the directory path
+ * @returns {nsIFile?}
+ * An nsIFile object representing the linked directory, or null
+ * on error.
+ */
+ _readLinkFile(aFile) {
+ let linkedDirectory;
+ if (aFile.isSymlink()) {
+ linkedDirectory = aFile.clone();
+ try {
+ linkedDirectory.normalize();
+ } catch (e) {
+ logger.warn(
+ `Symbolic link ${aFile.path} points to a path ` +
+ `which does not exist`
+ );
+ return null;
+ }
+ } else {
+ let fis = new FileInputStream(aFile, -1, -1, false);
+ let line = {};
+ fis.QueryInterface(Ci.nsILineInputStream).readLine(line);
+ fis.close();
+
+ if (line.value) {
+ linkedDirectory = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ try {
+ linkedDirectory.initWithPath(line.value);
+ } catch (e) {
+ linkedDirectory.setRelativeDescriptor(aFile.parent, line.value);
+ }
+ }
+ }
+
+ if (linkedDirectory) {
+ if (!linkedDirectory.exists()) {
+ logger.warn(
+ `File pointer ${aFile.path} points to ${linkedDirectory.path} ` +
+ "which does not exist"
+ );
+ return null;
+ }
+
+ if (!linkedDirectory.isDirectory()) {
+ logger.warn(
+ `File pointer ${aFile.path} points to ${linkedDirectory.path} ` +
+ "which is not a directory"
+ );
+ return null;
+ }
+
+ return linkedDirectory;
+ }
+
+ logger.warn(`File pointer ${aFile.path} does not contain a path`);
+ return null;
+ }
+
+ /**
+ * Finds all the add-ons installed in this location.
+ *
+ * @returns {Map<AddonID, nsIFile>}
+ * A map of add-ons present in this location.
+ */
+ readAddons() {
+ let addons = new Map();
+
+ if (!this.dir) {
+ return addons;
+ }
+
+ // Use a snapshot of the directory contents to avoid possible issues with
+ // iterating over a directory while removing files from it (the YAFFS2
+ // embedded filesystem has this issue, see bug 772238).
+ for (let entry of Array.from(iterDirectory(this.dir))) {
+ let id = getExpectedID(entry);
+ if (!id) {
+ if (![DIR_STAGE, DIR_TRASH].includes(entry.leafName)) {
+ logger.debug(
+ "Ignoring file: name is not a valid add-on ID: ${}",
+ entry.path
+ );
+ }
+ continue;
+ }
+
+ if (id == entry.leafName && (entry.isFile() || entry.isSymlink())) {
+ let newEntry = this._readLinkFile(entry);
+ if (!newEntry) {
+ logger.debug(`Deleting stale pointer file ${entry.path}`);
+ try {
+ entry.remove(true);
+ } catch (e) {
+ logger.warn(`Failed to remove stale pointer file ${entry.path}`, e);
+ // Failing to remove the stale pointer file is ignorable
+ }
+ continue;
+ }
+
+ entry = newEntry;
+ }
+
+ addons.set(id, entry);
+ }
+ return addons;
+ }
+
+ get isSystem() {
+ return this._isSystem;
+ }
+}
+
+/**
+ * An object which identifies a built-in install location for add-ons, such
+ * as default system add-ons.
+ *
+ * This location should point either to a XPI, or a directory in a local build.
+ */
+class SystemAddonDefaults extends DirectoryLocation {
+ /**
+ * Read the manifest of allowed add-ons and build a mapping between ID and URI
+ * for each.
+ *
+ * @returns {Map<AddonID, nsIFile>}
+ * A map of add-ons present in this location.
+ */
+ readAddons() {
+ let addons = new Map();
+
+ let manifest = XPIProvider.builtInAddons;
+
+ if (!("system" in manifest)) {
+ logger.debug("No list of valid system add-ons found.");
+ return addons;
+ }
+
+ for (let id of manifest.system) {
+ let file = this.dir.clone();
+ file.append(`${id}.xpi`);
+
+ // Only attempt to load unpacked directory if unofficial build.
+ if (!AppConstants.MOZILLA_OFFICIAL && !file.exists()) {
+ file = this.dir.clone();
+ file.append(`${id}`);
+ }
+
+ addons.set(id, file);
+ }
+
+ return addons;
+ }
+
+ get isSystem() {
+ return true;
+ }
+
+ get isBuiltin() {
+ return true;
+ }
+}
+
+/**
+ * An object which identifies a directory install location for system add-ons
+ * updates.
+ */
+class SystemAddonLocation extends DirectoryLocation {
+ /**
+ * The location consists of a directory which contains the add-ons installed.
+ *
+ * @param {string} name
+ * The string identifier for the install location.
+ * @param {nsIFile} dir
+ * The directory for the install location.
+ * @param {integer} scope
+ * The scope of add-ons installed in this location.
+ * @param {boolean} resetSet
+ * True to throw away the current add-on set
+ */
+ constructor(name, dir, scope, resetSet) {
+ let addonSet = SystemAddonLocation._loadAddonSet();
+ let directory = null;
+
+ // The system add-on update directory is stored in a pref.
+ // Therefore, this is looked up before calling the
+ // constructor on the superclass.
+ if (addonSet.directory) {
+ directory = getFile(addonSet.directory, dir);
+ logger.info(`SystemAddonLocation scanning directory ${directory.path}`);
+ } else {
+ logger.info("SystemAddonLocation directory is missing");
+ }
+
+ super(name, directory, scope, false);
+
+ this._addonSet = addonSet;
+ this._baseDir = dir;
+
+ if (resetSet) {
+ this.installer.resetAddonSet();
+ }
+ }
+
+ makeInstaller() {
+ if (this.locked) {
+ return null;
+ }
+ return new lazy.XPIInstall.SystemAddonInstaller(this);
+ }
+
+ /**
+ * Reads the current set of system add-ons
+ *
+ * @returns {Object}
+ */
+ static _loadAddonSet() {
+ try {
+ let setStr = Services.prefs.getStringPref(PREF_SYSTEM_ADDON_SET, null);
+ if (setStr) {
+ let addonSet = JSON.parse(setStr);
+ if (typeof addonSet == "object" && addonSet.schema == 1) {
+ return addonSet;
+ }
+ }
+ } catch (e) {
+ logger.error("Malformed system add-on set, resetting.");
+ }
+
+ return { schema: 1, addons: {} };
+ }
+
+ readAddons() {
+ // Updated system add-ons are ignored in safe mode
+ if (Services.appinfo.inSafeMode) {
+ return new Map();
+ }
+
+ let addons = super.readAddons();
+
+ // Strip out any unexpected add-ons from the list
+ for (let id of addons.keys()) {
+ if (!(id in this._addonSet.addons)) {
+ addons.delete(id);
+ }
+ }
+
+ return addons;
+ }
+
+ /**
+ * Tests whether updated system add-ons are expected.
+ *
+ * @returns {boolean}
+ */
+ isActive() {
+ return this.dir != null;
+ }
+
+ get isSystem() {
+ return true;
+ }
+
+ get isBuiltin() {
+ return true;
+ }
+}
+
+/**
+ * An object that identifies a registry install location for add-ons. The location
+ * consists of a registry key which contains string values mapping ID to the
+ * path where an add-on is installed
+ *
+ */
+class WinRegLocation extends XPIStateLocation {
+ /**
+ * @param {string} name
+ * The string identifier for the install location.
+ * @param {integer} rootKey
+ * The root key (one of the ROOT_KEY_ values from nsIWindowsRegKey).
+ * @param {integer} scope
+ * The scope of add-ons installed in this location.
+ */
+ constructor(name, rootKey, scope) {
+ super(name, undefined, scope);
+
+ this.locked = true;
+ this._rootKey = rootKey;
+ }
+
+ /**
+ * Retrieves the path of this Application's data key in the registry.
+ */
+ get _appKeyPath() {
+ let appVendor = Services.appinfo.vendor;
+ let appName = Services.appinfo.name;
+
+ // XXX Thunderbird doesn't specify a vendor string
+ if (appVendor == "" && AppConstants.MOZ_APP_NAME == "thunderbird") {
+ appVendor = "Mozilla";
+ }
+
+ return `SOFTWARE\\${appVendor}\\${appName}`;
+ }
+
+ /**
+ * Read the registry and build a mapping between ID and path for each
+ * installed add-on.
+ *
+ * @returns {Map<AddonID, nsIFile>}
+ * A map of add-ons in this location.
+ */
+ readAddons() {
+ let addons = new Map();
+
+ let path = `${this._appKeyPath}\\Extensions`;
+ let key = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+
+ // Reading the registry may throw an exception, and that's ok. In error
+ // cases, we just leave ourselves in the empty state.
+ try {
+ key.open(this._rootKey, path, Ci.nsIWindowsRegKey.ACCESS_READ);
+ } catch (e) {
+ return addons;
+ }
+
+ try {
+ let count = key.valueCount;
+ for (let i = 0; i < count; ++i) {
+ let id = key.getValueName(i);
+ let file = new nsIFile(key.readStringValue(id));
+ if (!file.exists()) {
+ logger.warn(`Ignoring missing add-on in ${file.path}`);
+ continue;
+ }
+
+ addons.set(id, file);
+ }
+ } finally {
+ key.close();
+ }
+
+ return addons;
+ }
+}
+
+/**
+ * Keeps track of the state of XPI add-ons on the file system.
+ */
+var XPIStates = {
+ // Map(location-name -> XPIStateLocation)
+ db: new Map(),
+
+ _jsonFile: null,
+
+ /**
+ * @property {Map<string, XPIState>} sideLoadedAddons
+ * A map of new add-ons detected during install location
+ * directory scans. Keys are add-on IDs, values are XPIState
+ * objects corresponding to those add-ons.
+ */
+ sideLoadedAddons: new Map(),
+
+ get size() {
+ let count = 0;
+ for (let location of this.locations()) {
+ count += location.size;
+ }
+ return count;
+ },
+
+ /**
+ * Load extension state data from addonStartup.json.
+ *
+ * @returns {Object}
+ */
+ loadExtensionState() {
+ let state;
+ try {
+ state = lazy.aomStartup.readStartupData();
+ } catch (e) {
+ logger.warn("Error parsing extensions state: ${error}", { error: e });
+ }
+
+ // When upgrading from a build prior to bug 857456, convert startup
+ // metadata.
+ let done = false;
+ for (let location of Object.values(state || {})) {
+ for (let data of Object.values(location.addons || {})) {
+ if (!migrateAddonLoader(data)) {
+ done = true;
+ break;
+ }
+ }
+ if (done) {
+ break;
+ }
+ }
+
+ logger.debug("Loaded add-on state: ${}", state);
+ return state || {};
+ },
+
+ /**
+ * Walk through all install locations, highest priority first,
+ * comparing the on-disk state of extensions to what is stored in prefs.
+ *
+ * @param {boolean} [ignoreSideloads = true]
+ * If true, ignore changes in scopes where we don't accept
+ * side-loads.
+ *
+ * @returns {boolean}
+ * True if anything has changed.
+ */
+ scanForChanges(ignoreSideloads = true) {
+ let oldState = this.initialStateData || this.loadExtensionState();
+ // We're called twice, do not restore the second time as new data
+ // may have been inserted since the first call.
+ let shouldRestoreLocationData = !this.initialStateData;
+ this.initialStateData = oldState;
+
+ let changed = false;
+ let oldLocations = new Set(Object.keys(oldState));
+
+ let startupScanScopes;
+ if (
+ Services.appinfo.appBuildID ==
+ Services.prefs.getCharPref(PREF_EM_LAST_APP_BUILD_ID, "")
+ ) {
+ startupScanScopes = Services.prefs.getIntPref(
+ PREF_EM_STARTUP_SCAN_SCOPES,
+ 0
+ );
+ } else {
+ // If the build id has changed, we need to do a full scan on first startup.
+ Services.prefs.setCharPref(
+ PREF_EM_LAST_APP_BUILD_ID,
+ Services.appinfo.appBuildID
+ );
+ startupScanScopes = AddonManager.SCOPE_ALL;
+ }
+
+ for (let loc of XPIStates.locations()) {
+ oldLocations.delete(loc.name);
+
+ if (shouldRestoreLocationData && oldState[loc.name]) {
+ loc.restore(oldState[loc.name]);
+ }
+ changed = changed || loc.changed;
+
+ // Don't bother checking scopes where we don't accept side-loads.
+ if (ignoreSideloads && !(loc.scope & startupScanScopes)) {
+ continue;
+ }
+
+ if (!loc.enumerable) {
+ continue;
+ }
+
+ // Don't bother scanning scopes where we don't have addons installed if they
+ // do not allow sideloading new addons. Once we have an addon in one of those
+ // locations, we need to check the location for changes (updates/deletions).
+ if (!loc.size && !(loc.scope & lazy.AddonSettings.SCOPES_SIDELOAD)) {
+ continue;
+ }
+
+ let knownIds = new Set(loc.keys());
+ for (let [id, file] of loc.readAddons()) {
+ knownIds.delete(id);
+
+ let xpiState = loc.get(id);
+ if (!xpiState) {
+ // If the location is not supported for sideloading, skip new
+ // addons. We handle this here so changes for existing sideloads
+ // will function.
+ if (
+ !loc.isSystem &&
+ !(loc.scope & lazy.AddonSettings.SCOPES_SIDELOAD)
+ ) {
+ continue;
+ }
+ logger.debug("New add-on ${id} in ${loc}", { id, loc: loc.name });
+
+ changed = true;
+ xpiState = loc.addFile(id, file);
+ if (!loc.isSystem) {
+ this.sideLoadedAddons.set(id, xpiState);
+ }
+ } else {
+ let addonChanged =
+ xpiState.getModTime(file) || file.path != xpiState.path;
+ xpiState.file = file.clone();
+
+ if (addonChanged) {
+ changed = true;
+ logger.debug("Changed add-on ${id} in ${loc}", {
+ id,
+ loc: loc.name,
+ });
+ } else {
+ logger.debug("Existing add-on ${id} in ${loc}", {
+ id,
+ loc: loc.name,
+ });
+ }
+ }
+ XPIProvider.addTelemetry(id, { location: loc.name });
+ }
+
+ // Anything left behind in oldState was removed from the file system.
+ for (let id of knownIds) {
+ loc.delete(id);
+ changed = true;
+ }
+ }
+
+ // If there's anything left in oldState, an install location that held add-ons
+ // was removed from the browser configuration.
+ changed = changed || oldLocations.size > 0;
+
+ logger.debug("scanForChanges changed: ${rv}, state: ${state}", {
+ rv: changed,
+ state: this.db,
+ });
+ return changed;
+ },
+
+ locations() {
+ return this.db.values();
+ },
+
+ /**
+ * @param {string} name
+ * The location name.
+ * @param {XPIStateLocation} location
+ * The location object.
+ */
+ addLocation(name, location) {
+ if (this.db.has(name)) {
+ throw new Error(`Trying to add duplicate location: ${name}`);
+ }
+ this.db.set(name, location);
+ },
+
+ /**
+ * Get the Map of XPI states for a particular location.
+ *
+ * @param {string} name
+ * The name of the install location.
+ *
+ * @returns {XPIStateLocation?}
+ * (id -> XPIState) or null if there are no add-ons in the location.
+ */
+ getLocation(name) {
+ return this.db.get(name);
+ },
+
+ /**
+ * Get the XPI state for a specific add-on in a location.
+ * If the state is not in our cache, return null.
+ *
+ * @param {string} aLocation
+ * The name of the location where the add-on is installed.
+ * @param {string} aId
+ * The add-on ID
+ *
+ * @returns {XPIState?}
+ * The XPIState entry for the add-on, or null.
+ */
+ getAddon(aLocation, aId) {
+ let location = this.db.get(aLocation);
+ return location && location.get(aId);
+ },
+
+ /**
+ * Find the highest priority location of an add-on by ID and return the
+ * XPIState.
+ * @param {string} aId
+ * The add-on IDa
+ * @param {function} aFilter
+ * An optional filter to apply to install locations. If provided,
+ * addons in locations that do not match the filter are not considered.
+ *
+ * @returns {XPIState?}
+ */
+ findAddon(aId, aFilter = location => true) {
+ // Fortunately the Map iterator returns in order of insertion, which is
+ // also our highest -> lowest priority order.
+ for (let location of this.locations()) {
+ if (!aFilter(location)) {
+ continue;
+ }
+ if (location.has(aId)) {
+ return location.get(aId);
+ }
+ }
+ return undefined;
+ },
+
+ /**
+ * Iterates over the list of all enabled add-ons in any location.
+ */
+ *enabledAddons() {
+ for (let location of this.locations()) {
+ for (let entry of location.values()) {
+ if (entry.enabled) {
+ yield entry;
+ }
+ }
+ }
+ },
+
+ /**
+ * Add a new XPIState for an add-on and synchronize it with the DBAddonInternal.
+ *
+ * @param {DBAddonInternal} aAddon
+ * The add-on to add.
+ */
+ addAddon(aAddon) {
+ aAddon.location.addAddon(aAddon);
+ },
+
+ /**
+ * Save the current state of installed add-ons.
+ */
+ save() {
+ if (!this._jsonFile) {
+ this._jsonFile = new lazy.JSONFile({
+ path: PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ FILE_XPI_STATES
+ ),
+ finalizeAt: AddonManagerPrivate.finalShutdown,
+ compression: "lz4",
+ });
+ this._jsonFile.data = this;
+ }
+
+ this._jsonFile.saveSoon();
+ },
+
+ toJSON() {
+ let data = {};
+ for (let [key, loc] of this.db.entries()) {
+ if (!loc.isTemporary && (loc.size || loc.hasStaged)) {
+ data[key] = loc;
+ }
+ }
+ return data;
+ },
+
+ /**
+ * Remove the XPIState for an add-on and save the new state.
+ *
+ * @param {string} aLocation
+ * The name of the add-on location.
+ * @param {string} aId
+ * The ID of the add-on.
+ *
+ */
+ removeAddon(aLocation, aId) {
+ logger.debug(`Removing XPIState for ${aLocation}: ${aId}`);
+ let location = this.db.get(aLocation);
+ if (location) {
+ location.removeAddon(aId);
+ this.save();
+ }
+ },
+
+ /**
+ * Disable the XPIState for an add-on.
+ *
+ * @param {string} aId
+ * The ID of the add-on.
+ */
+ disableAddon(aId) {
+ logger.debug(`Disabling XPIState for ${aId}`);
+ let state = this.findAddon(aId);
+ if (state) {
+ state.enabled = false;
+ }
+ },
+};
+
+/**
+ * A helper class to manage the lifetime of and interaction with
+ * bootstrap scopes for an add-on.
+ *
+ * @param {Object} addon
+ * The add-on which owns this scope. Should be either an
+ * AddonInternal or XPIState object.
+ */
+class BootstrapScope {
+ constructor(addon) {
+ if (!addon.id || !addon.version || !addon.type) {
+ throw new Error("Addon must include an id, version, and type");
+ }
+
+ this.addon = addon;
+ this.instanceID = null;
+ this.scope = null;
+ this.started = false;
+ }
+
+ /**
+ * Returns a BootstrapScope object for the given add-on. If an active
+ * scope exists, it is returned. Otherwise a new one is created.
+ *
+ * @param {Object} addon
+ * The add-on which owns this scope, as accepted by the
+ * constructor.
+ * @returns {BootstrapScope}
+ */
+ static get(addon) {
+ let scope = XPIProvider.activeAddons.get(addon.id);
+ if (!scope) {
+ scope = new this(addon);
+ }
+ return scope;
+ }
+
+ get file() {
+ return this.addon.file || this.addon._sourceBundle;
+ }
+
+ get runInSafeMode() {
+ return "runInSafeMode" in this.addon
+ ? this.addon.runInSafeMode
+ : canRunInSafeMode(this.addon);
+ }
+
+ /**
+ * Returns state information for use by an AsyncShutdown blocker. If
+ * the wrapped bootstrap scope has a fetchState method, it is called,
+ * and its result returned. If not, returns null.
+ *
+ * @returns {Object|null}
+ */
+ fetchState() {
+ if (this.scope && this.scope.fetchState) {
+ return this.scope.fetchState();
+ }
+ return null;
+ }
+
+ /**
+ * Calls a bootstrap method for an add-on.
+ *
+ * @param {string} aMethod
+ * The name of the bootstrap method to call
+ * @param {integer} aReason
+ * The reason flag to pass to the bootstrap's startup method
+ * @param {Object} [aExtraParams = {}]
+ * An object of additional key/value pairs to pass to the method in
+ * the params argument
+ * @returns {any}
+ * The return value of the bootstrap method.
+ */
+ async callBootstrapMethod(aMethod, aReason, aExtraParams = {}) {
+ let { addon, runInSafeMode } = this;
+ if (
+ Services.appinfo.inSafeMode &&
+ !runInSafeMode &&
+ aMethod !== "uninstall"
+ ) {
+ return null;
+ }
+
+ try {
+ if (!this.scope) {
+ this.loadBootstrapScope(aReason);
+ }
+
+ if (aMethod == "startup" || aMethod == "shutdown") {
+ aExtraParams.instanceID = this.instanceID;
+ }
+
+ let method = undefined;
+ let { scope } = this;
+ try {
+ method = scope[aMethod];
+ } catch (e) {
+ // An exception will be caught if the expected method is not defined.
+ // That will be logged below.
+ }
+
+ if (aMethod == "startup") {
+ this.started = true;
+ } else if (aMethod == "shutdown") {
+ this.started = false;
+
+ // Extensions are automatically deinitialized in the correct order at shutdown.
+ if (aReason != BOOTSTRAP_REASONS.APP_SHUTDOWN) {
+ this._pendingDisable = true;
+ for (let addon of XPIProvider.getDependentAddons(this.addon)) {
+ if (addon.active) {
+ await lazy.XPIDatabase.updateAddonDisabledState(addon);
+ }
+ }
+ }
+ }
+
+ let params = {
+ id: addon.id,
+ version: addon.version,
+ resourceURI: addon.resolvedRootURI,
+ signedState: addon.signedState,
+ temporarilyInstalled: addon.location.isTemporary,
+ builtIn: addon.location.isBuiltin,
+ isSystem: addon.location.isSystem,
+ isPrivileged: addon.isPrivileged,
+ locationHidden: addon.location.hidden,
+ recommendationState: addon.recommendationState,
+ };
+
+ if (aMethod == "startup" && addon.startupData) {
+ params.startupData = addon.startupData;
+ }
+
+ Object.assign(params, aExtraParams);
+
+ let result;
+ if (!method) {
+ logger.warn(
+ `Add-on ${addon.id} is missing bootstrap method ${aMethod}`
+ );
+ } else {
+ logger.debug(
+ `Calling bootstrap method ${aMethod} on ${addon.id} version ${addon.version}`
+ );
+
+ this._beforeCallBootstrapMethod(aMethod, params, aReason);
+
+ try {
+ result = await method.call(scope, params, aReason);
+ } catch (e) {
+ logger.warn(
+ `Exception running bootstrap method ${aMethod} on ${addon.id}`,
+ e
+ );
+ }
+ }
+ return result;
+ } finally {
+ // Extensions are automatically initialized in the correct order at startup.
+ if (aMethod == "startup" && aReason != BOOTSTRAP_REASONS.APP_STARTUP) {
+ for (let addon of XPIProvider.getDependentAddons(this.addon)) {
+ lazy.XPIDatabase.updateAddonDisabledState(addon);
+ }
+ }
+ }
+ }
+
+ // No-op method to be overridden by tests.
+ _beforeCallBootstrapMethod() {}
+
+ /**
+ * Loads a bootstrapped add-on's bootstrap.js into a sandbox and the reason
+ * values as constants in the scope.
+ *
+ * @param {integer?} [aReason]
+ * The reason this bootstrap is being loaded, as passed to a
+ * bootstrap method.
+ */
+ loadBootstrapScope(aReason) {
+ this.instanceID = Symbol(this.addon.id);
+ this._pendingDisable = false;
+
+ XPIProvider.activeAddons.set(this.addon.id, this);
+
+ // Mark the add-on as active for the crash reporter before loading.
+ // But not at app startup, since we'll already have added all of our
+ // annotations before starting any loads.
+ if (aReason !== BOOTSTRAP_REASONS.APP_STARTUP) {
+ XPIProvider.addAddonsToCrashReporter();
+ }
+
+ logger.debug(`Loading bootstrap scope from ${this.addon.rootURI}`);
+
+ if (this.addon.isWebExtension) {
+ switch (this.addon.type) {
+ case "extension":
+ case "theme":
+ this.scope = lazy.Extension.getBootstrapScope();
+ break;
+
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ case "sitepermission-deprecated":
+ this.scope = lazy.SitePermission.getBootstrapScope();
+ break;
+
+ case "locale":
+ this.scope = lazy.Langpack.getBootstrapScope();
+ break;
+
+ case "dictionary":
+ this.scope = lazy.Dictionary.getBootstrapScope();
+ break;
+
+ default:
+ throw new Error(`Unknown webextension type ${this.addon.type}`);
+ }
+ } else {
+ let loader = AddonManagerPrivate.externalExtensionLoaders.get(
+ this.addon.loader
+ );
+ if (!loader) {
+ throw new Error(`Cannot find loader for ${this.addon.loader}`);
+ }
+
+ this.scope = loader.loadScope(this.addon);
+ }
+ }
+
+ /**
+ * Unloads a bootstrap scope by dropping all references to it and then
+ * updating the list of active add-ons with the crash reporter.
+ */
+ unloadBootstrapScope() {
+ XPIProvider.activeAddons.delete(this.addon.id);
+ XPIProvider.addAddonsToCrashReporter();
+
+ this.scope = null;
+ this.startupPromise = null;
+ this.instanceID = null;
+ }
+
+ /**
+ * Calls the bootstrap scope's startup method, with the given reason
+ * and extra parameters.
+ *
+ * @param {integer} reason
+ * The reason code for the startup call.
+ * @param {Object} [aExtraParams]
+ * Optional extra parameters to pass to the bootstrap method.
+ * @returns {Promise}
+ * Resolves when the startup method has run to completion, rejects
+ * if called late during shutdown.
+ */
+ async startup(reason, aExtraParams) {
+ if (this.shutdownPromise) {
+ await this.shutdownPromise;
+ }
+
+ if (
+ Services.startup.isInOrBeyondShutdownPhase(
+ Ci.nsIAppStartup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ )
+ ) {
+ let err = new Error(
+ `XPIProvider can't start bootstrap scope for ${this.addon.id} after shutdown was already granted`
+ );
+ logger.warn("BoostrapScope startup failure: ${error}", { error: err });
+ this.startupPromise = Promise.reject(err);
+ } else {
+ this.startupPromise = this.callBootstrapMethod(
+ "startup",
+ reason,
+ aExtraParams
+ );
+ }
+
+ return this.startupPromise;
+ }
+
+ /**
+ * Calls the bootstrap scope's shutdown method, with the given reason
+ * and extra parameters.
+ *
+ * @param {integer} reason
+ * The reason code for the shutdown call.
+ * @param {Object} [aExtraParams]
+ * Optional extra parameters to pass to the bootstrap method.
+ */
+ async shutdown(reason, aExtraParams) {
+ this.shutdownPromise = this._shutdown(reason, aExtraParams);
+ await this.shutdownPromise;
+ this.shutdownPromise = null;
+ }
+
+ async _shutdown(reason, aExtraParams) {
+ await this.startupPromise;
+ return this.callBootstrapMethod("shutdown", reason, aExtraParams);
+ }
+
+ /**
+ * If the add-on is already running, calls its "shutdown" method, and
+ * unloads its bootstrap scope.
+ *
+ * @param {integer} reason
+ * The reason code for the shutdown call.
+ * @param {Object} [aExtraParams]
+ * Optional extra parameters to pass to the bootstrap method.
+ */
+ async disable() {
+ if (this.started) {
+ await this.shutdown(BOOTSTRAP_REASONS.ADDON_DISABLE);
+ // If we disable and re-enable very quickly, it's possible that
+ // the next startup() method will be called immediately after this
+ // shutdown method finishes. This almost never happens outside of
+ // tests. In tests, alas...
+ if (!this.started) {
+ this.unloadBootstrapScope();
+ }
+ }
+ }
+
+ /**
+ * Calls the bootstrap scope's install method, and optionally its
+ * startup method.
+ *
+ * @param {integer} reason
+ * The reason code for the calls.
+ * @param {boolean} [startup = false]
+ * If true, and the add-on is active, calls its startup method
+ * after its install method.
+ * @param {Object} [extraArgs]
+ * Optional extra parameters to pass to the bootstrap method.
+ * @returns {Promise}
+ * Resolves when the startup method has run to completion, if
+ * startup is required.
+ */
+ install(reason = BOOTSTRAP_REASONS.ADDON_INSTALL, startup, extraArgs) {
+ return this._install(reason, false, startup, extraArgs);
+ }
+
+ async _install(reason, callUpdate, startup, extraArgs) {
+ if (callUpdate) {
+ await this.callBootstrapMethod("update", reason, extraArgs);
+ } else {
+ this.callBootstrapMethod("install", reason, extraArgs);
+ }
+
+ if (startup && this.addon.active) {
+ await this.startup(reason, extraArgs);
+ } else if (this.addon.disabled) {
+ this.unloadBootstrapScope();
+ }
+ }
+
+ /**
+ * Calls the bootstrap scope's uninstall method, and unloads its
+ * bootstrap scope. If the extension is already running, its shutdown
+ * method is called before its uninstall method.
+ *
+ * @param {integer} reason
+ * The reason code for the calls.
+ * @param {Object} [extraArgs]
+ * Optional extra parameters to pass to the bootstrap method.
+ * @returns {Promise}
+ * Resolves when the shutdown method has run to completion, if
+ * shutdown is required, and the uninstall method has been
+ * called.
+ */
+ uninstall(reason = BOOTSTRAP_REASONS.ADDON_UNINSTALL, extraArgs) {
+ return this._uninstall(reason, false, extraArgs);
+ }
+
+ async _uninstall(reason, callUpdate, extraArgs) {
+ if (this.started) {
+ await this.shutdown(reason, extraArgs);
+ }
+ if (!callUpdate) {
+ this.callBootstrapMethod("uninstall", reason, extraArgs);
+ }
+ this.unloadBootstrapScope();
+
+ if (this.file) {
+ lazy.XPIInstall.flushJarCache(this.file);
+ }
+ }
+
+ /**
+ * Calls the appropriate sequence of shutdown, uninstall, update,
+ * startup, and install methods for updating the current scope's
+ * add-on to the given new add-on, depending on the current state of
+ * the scope.
+ *
+ * @param {XPIState} newAddon
+ * The new add-on which is being installed, as expected by the
+ * constructor.
+ * @param {boolean} [startup = false]
+ * If true, and the new add-on is enabled, calls its startup
+ * method as its final operation.
+ * @param {function} [updateCallback]
+ * An optional callback function to call between uninstalling
+ * the old add-on and installing the new one. This callback
+ * should update any database state which is necessary for the
+ * startup of the new add-on.
+ * @returns {Promise}
+ * Resolves when all required bootstrap callbacks have
+ * completed.
+ */
+ async update(newAddon, startup = false, updateCallback) {
+ let reason = lazy.XPIInstall.newVersionReason(
+ this.addon.version,
+ newAddon.version
+ );
+
+ let callUpdate = this.addon.isWebExtension && newAddon.isWebExtension;
+
+ // BootstrapScope gets either an XPIState instance or an AddonInternal
+ // instance, when we update, we need the latter to access permissions
+ // from the manifest.
+ let existingAddon = this.addon;
+
+ let extraArgs = {
+ oldVersion: existingAddon.version,
+ newVersion: newAddon.version,
+ };
+
+ // If we're updating an extension, we may need to read data to
+ // calculate permission changes.
+ if (callUpdate && existingAddon.type === "extension") {
+ if (this.addon instanceof XPIState) {
+ // The existing addon will be cached in the database.
+ existingAddon = await lazy.XPIDatabase.getAddonByID(this.addon.id);
+ }
+
+ if (newAddon instanceof XPIState) {
+ newAddon = await lazy.XPIInstall.loadManifestFromFile(
+ newAddon.file,
+ newAddon.location
+ );
+ }
+
+ Object.assign(extraArgs, {
+ userPermissions: newAddon.userPermissions,
+ optionalPermissions: newAddon.optionalPermissions,
+ oldPermissions: existingAddon.userPermissions,
+ oldOptionalPermissions: existingAddon.optionalPermissions,
+ });
+ }
+
+ await this._uninstall(reason, callUpdate, extraArgs);
+
+ if (updateCallback) {
+ await updateCallback();
+ }
+
+ this.addon = newAddon;
+ return this._install(reason, callUpdate, startup, extraArgs);
+ }
+}
+
+let resolveDBReady;
+let dbReadyPromise = new Promise(resolve => {
+ resolveDBReady = resolve;
+});
+let resolveProviderReady;
+let providerReadyPromise = new Promise(resolve => {
+ resolveProviderReady = resolve;
+});
+
+var XPIProvider = {
+ get name() {
+ return "XPIProvider";
+ },
+
+ BOOTSTRAP_REASONS: Object.freeze(BOOTSTRAP_REASONS),
+
+ // A Map of active addons to their bootstrapScope by ID
+ activeAddons: new Map(),
+ // Per-addon telemetry information
+ _telemetryDetails: {},
+ // Have we started shutting down bootstrap add-ons?
+ _closing: false,
+
+ // Promises awaited by the XPIProvider before resolving providerReadyPromise,
+ // (pushed into the array by XPIProvider maybeInstallBuiltinAddon and startup
+ // methods).
+ startupPromises: [],
+
+ // Array of the bootstrap startup promises for the enabled addons being
+ // initiated during the XPIProvider startup.
+ //
+ // NOTE: XPIProvider will wait for these promises (and the startupPromises one)
+ // to have settled before allowing the application to proceed with shutting down
+ // (see quitApplicationGranted blocker at the end of the XPIProvider.startup).
+ enabledAddonsStartupPromises: [],
+
+ databaseReady: Promise.all([dbReadyPromise, providerReadyPromise]),
+
+ // Check if the XPIDatabase has been loaded (without actually
+ // triggering unwanted imports or I/O)
+ get isDBLoaded() {
+ // Make sure we don't touch the XPIDatabase getter before it's
+ // actually loaded, and force an early load.
+ return (
+ (Object.getOwnPropertyDescriptor(lazy, "XPIDatabase").value &&
+ lazy.XPIDatabase.initialized) ||
+ false
+ );
+ },
+
+ /**
+ * Returns true if the add-on with the given ID is currently active,
+ * without forcing the add-ons database to load.
+ *
+ * @param {string} addonId
+ * The ID of the add-on to check.
+ * @returns {boolean}
+ */
+ addonIsActive(addonId) {
+ let state = XPIStates.findAddon(addonId);
+ return state && state.enabled;
+ },
+
+ /**
+ * Returns an array of the add-on values in `enabledAddons`,
+ * sorted so that all of an add-on's dependencies appear in the array
+ * before itself.
+ *
+ * @returns {Array<object>}
+ * A sorted array of add-on objects. Each value is a copy of the
+ * corresponding value in the `enabledAddons` object, with an
+ * additional `id` property, which corresponds to the key in that
+ * object, which is the same as the add-ons ID.
+ */
+ sortBootstrappedAddons() {
+ function compare(a, b) {
+ if (a === b) {
+ return 0;
+ }
+ return a < b ? -1 : 1;
+ }
+
+ // Sort the list so that ordering is deterministic.
+ let list = Array.from(XPIStates.enabledAddons());
+ list.sort((a, b) => compare(a.id, b.id));
+
+ let addons = {};
+ for (let entry of list) {
+ addons[entry.id] = entry;
+ }
+
+ let res = new Set();
+ let seen = new Set();
+
+ let add = addon => {
+ seen.add(addon.id);
+
+ for (let id of addon.dependencies || []) {
+ if (id in addons && !seen.has(id)) {
+ add(addons[id]);
+ }
+ }
+
+ res.add(addon.id);
+ };
+
+ Object.values(addons).forEach(add);
+
+ return Array.from(res, id => addons[id]);
+ },
+
+ /*
+ * Adds metadata to the telemetry payload for the given add-on.
+ */
+ addTelemetry(aId, aPayload) {
+ if (!this._telemetryDetails[aId]) {
+ this._telemetryDetails[aId] = {};
+ }
+ Object.assign(this._telemetryDetails[aId], aPayload);
+ },
+
+ setupInstallLocations(aAppChanged) {
+ function DirectoryLoc(aName, aScope, aKey, aPaths, aLocked, aIsSystem) {
+ try {
+ var dir = lazy.FileUtils.getDir(aKey, aPaths);
+ } catch (e) {
+ return null;
+ }
+ return new DirectoryLocation(aName, dir, aScope, aLocked, aIsSystem);
+ }
+
+ function SystemDefaultsLoc(name, scope, key, paths) {
+ try {
+ var dir = lazy.FileUtils.getDir(key, paths);
+ } catch (e) {
+ return null;
+ }
+ return new SystemAddonDefaults(name, dir, scope);
+ }
+
+ function SystemLoc(aName, aScope, aKey, aPaths) {
+ try {
+ var dir = lazy.FileUtils.getDir(aKey, aPaths);
+ } catch (e) {
+ return null;
+ }
+ return new SystemAddonLocation(aName, dir, aScope, aAppChanged !== false);
+ }
+
+ function RegistryLoc(aName, aScope, aKey) {
+ if ("nsIWindowsRegKey" in Ci) {
+ return new WinRegLocation(aName, Ci.nsIWindowsRegKey[aKey], aScope);
+ }
+ }
+
+ // These must be in order of priority, highest to lowest,
+ // for processFileChanges etc. to work
+ let locations = [
+ [() => TemporaryInstallLocation, TemporaryInstallLocation.name, null],
+
+ [
+ DirectoryLoc,
+ KEY_APP_PROFILE,
+ AddonManager.SCOPE_PROFILE,
+ KEY_PROFILEDIR,
+ [DIR_EXTENSIONS],
+ false,
+ ],
+
+ [
+ DirectoryLoc,
+ KEY_APP_SYSTEM_PROFILE,
+ AddonManager.SCOPE_APPLICATION,
+ KEY_PROFILEDIR,
+ [DIR_APP_SYSTEM_PROFILE],
+ false,
+ true,
+ ],
+
+ [
+ SystemLoc,
+ KEY_APP_SYSTEM_ADDONS,
+ AddonManager.SCOPE_PROFILE,
+ KEY_PROFILEDIR,
+ [DIR_SYSTEM_ADDONS],
+ ],
+
+ [
+ SystemDefaultsLoc,
+ KEY_APP_SYSTEM_DEFAULTS,
+ AddonManager.SCOPE_PROFILE,
+ KEY_APP_FEATURES,
+ [],
+ ],
+
+ [() => BuiltInLocation, KEY_APP_BUILTINS, AddonManager.SCOPE_APPLICATION],
+
+ [
+ DirectoryLoc,
+ KEY_APP_SYSTEM_USER,
+ AddonManager.SCOPE_USER,
+ "XREUSysExt",
+ [Services.appinfo.ID],
+ true,
+ ],
+
+ [
+ RegistryLoc,
+ "winreg-app-user",
+ AddonManager.SCOPE_USER,
+ "ROOT_KEY_CURRENT_USER",
+ ],
+
+ [
+ DirectoryLoc,
+ KEY_APP_GLOBAL,
+ AddonManager.SCOPE_APPLICATION,
+ KEY_ADDON_APP_DIR,
+ [DIR_EXTENSIONS],
+ true,
+ ],
+
+ [
+ DirectoryLoc,
+ KEY_APP_SYSTEM_SHARE,
+ AddonManager.SCOPE_SYSTEM,
+ "XRESysSExtPD",
+ [Services.appinfo.ID],
+ true,
+ ],
+
+ [
+ DirectoryLoc,
+ KEY_APP_SYSTEM_LOCAL,
+ AddonManager.SCOPE_SYSTEM,
+ "XRESysLExtPD",
+ [Services.appinfo.ID],
+ true,
+ ],
+
+ [
+ RegistryLoc,
+ "winreg-app-global",
+ AddonManager.SCOPE_SYSTEM,
+ "ROOT_KEY_LOCAL_MACHINE",
+ ],
+ ];
+
+ for (let [constructor, name, scope, ...args] of locations) {
+ if (!scope || lazy.enabledScopes & scope) {
+ try {
+ let loc = constructor(name, scope, ...args);
+ if (loc) {
+ XPIStates.addLocation(name, loc);
+ }
+ } catch (e) {
+ logger.warn(
+ `Failed to add ${constructor.name} install location ${name}`,
+ e
+ );
+ }
+ }
+ }
+ },
+
+ /**
+ * Registers the built-in set of dictionaries with the spell check
+ * service.
+ */
+ registerBuiltinDictionaries() {
+ this.dictionaries = {};
+ for (let [lang, path] of Object.entries(
+ this.builtInAddons.dictionaries || {}
+ )) {
+ path = path.slice(0, -4) + ".aff";
+ let uri = Services.io.newURI(`resource://gre/${path}`);
+
+ this.dictionaries[lang] = uri;
+ lazy.spellCheck.addDictionary(lang, uri);
+ }
+ },
+
+ /**
+ * Unregisters the dictionaries in the given object, and re-registers
+ * any built-in dictionaries in their place, when they exist.
+ *
+ * @param {Object<nsIURI>} aDicts
+ * An object containing a property with a dictionary language
+ * code and a nsIURI value for each dictionary to be
+ * unregistered.
+ */
+ unregisterDictionaries(aDicts) {
+ let origDicts = lazy.spellCheck.dictionaries.slice();
+ let toRemove = [];
+
+ for (let [lang, uri] of Object.entries(aDicts)) {
+ if (
+ lazy.spellCheck.removeDictionary(lang, uri) &&
+ this.dictionaries.hasOwnProperty(lang)
+ ) {
+ lazy.spellCheck.addDictionary(lang, this.dictionaries[lang]);
+ } else {
+ toRemove.push(lang);
+ }
+ }
+
+ lazy.spellCheck.dictionaries = origDicts.filter(
+ lang => !toRemove.includes(lang)
+ );
+ },
+
+ /**
+ * Starts the XPI provider initializes the install locations and prefs.
+ *
+ * @param {boolean?} aAppChanged
+ * A tri-state value. Undefined means the current profile was created
+ * for this session, true means the profile already existed but was
+ * last used with an application with a different version number,
+ * false means that the profile was last used by this version of the
+ * application.
+ * @param {string?} [aOldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @param {string?} [aOldPlatformVersion]
+ * The version of the platform last run with this profile or null
+ * if it is a new profile or the version is unknown
+ */
+ startup(aAppChanged, aOldAppVersion, aOldPlatformVersion) {
+ try {
+ AddonManagerPrivate.recordTimestamp("XPI_startup_begin");
+
+ logger.debug("startup");
+
+ this.builtInAddons = {};
+ try {
+ let url = Services.io.newURI(BUILT_IN_ADDONS_URI);
+ let data = Cu.readUTF8URI(url);
+ this.builtInAddons = JSON.parse(data);
+ } catch (e) {
+ if (AppConstants.DEBUG) {
+ logger.debug("List of built-in add-ons is missing or invalid.", e);
+ }
+ }
+
+ this.registerBuiltinDictionaries();
+
+ // Clear this at startup for xpcshell test restarts
+ this._telemetryDetails = {};
+ // Register our details structure with AddonManager
+ AddonManagerPrivate.setTelemetryDetails("XPI", this._telemetryDetails);
+
+ this.setupInstallLocations(aAppChanged);
+
+ if (!AppConstants.MOZ_REQUIRE_SIGNING || Cu.isInAutomation) {
+ Services.prefs.addObserver(PREF_XPI_SIGNATURES_REQUIRED, this);
+ }
+ Services.prefs.addObserver(PREF_LANGPACK_SIGNATURES, this);
+ Services.obs.addObserver(this, NOTIFICATION_FLUSH_PERMISSIONS);
+
+ this.checkForChanges(aAppChanged, aOldAppVersion, aOldPlatformVersion);
+
+ AddonManagerPrivate.markProviderSafe(this);
+
+ const lastTheme = Services.prefs.getCharPref(
+ "extensions.activeThemeID",
+ null
+ );
+
+ if (
+ lastTheme === "recommended-1" ||
+ lastTheme === "recommended-2" ||
+ lastTheme === "recommended-3" ||
+ lastTheme === "recommended-4" ||
+ lastTheme === "recommended-5"
+ ) {
+ // The user is using a theme that was once bundled with Firefox, but no longer
+ // is. Clear their theme so that they will be forced to reset to the default.
+ this.startupPromises.push(
+ AddonManagerPrivate.notifyAddonChanged(null, "theme")
+ );
+ }
+ this.maybeInstallBuiltinAddon(
+ "default-theme@mozilla.org",
+ "1.3",
+ "resource://default-theme/"
+ );
+
+ resolveProviderReady(Promise.all(this.startupPromises));
+
+ if (AppConstants.MOZ_CRASHREPORTER) {
+ // Annotate the crash report with relevant add-on information.
+ try {
+ Services.appinfo.annotateCrashReport(
+ "EMCheckCompatibility",
+ AddonManager.checkCompatibility
+ );
+ } catch (e) {}
+ this.addAddonsToCrashReporter();
+ }
+
+ try {
+ AddonManagerPrivate.recordTimestamp("XPI_bootstrap_addons_begin");
+
+ for (let addon of this.sortBootstrappedAddons()) {
+ // The startup update check above may have already started some
+ // extensions, make sure not to try to start them twice.
+ let activeAddon = this.activeAddons.get(addon.id);
+ if (activeAddon && activeAddon.started) {
+ continue;
+ }
+ try {
+ let reason = BOOTSTRAP_REASONS.APP_STARTUP;
+ // Eventually set INSTALLED reason when a bootstrap addon
+ // is dropped in profile folder and automatically installed
+ if (
+ AddonManager.getStartupChanges(
+ AddonManager.STARTUP_CHANGE_INSTALLED
+ ).includes(addon.id)
+ ) {
+ reason = BOOTSTRAP_REASONS.ADDON_INSTALL;
+ } else if (
+ AddonManager.getStartupChanges(
+ AddonManager.STARTUP_CHANGE_ENABLED
+ ).includes(addon.id)
+ ) {
+ reason = BOOTSTRAP_REASONS.ADDON_ENABLE;
+ }
+ this.enabledAddonsStartupPromises.push(
+ BootstrapScope.get(addon).startup(reason)
+ );
+ } catch (e) {
+ logger.error(
+ "Failed to load bootstrap addon " +
+ addon.id +
+ " from " +
+ addon.descriptor,
+ e
+ );
+ }
+ }
+ AddonManagerPrivate.recordTimestamp("XPI_bootstrap_addons_end");
+ } catch (e) {
+ logger.error("bootstrap startup failed", e);
+ AddonManagerPrivate.recordException(
+ "XPI-BOOTSTRAP",
+ "startup failed",
+ e
+ );
+ }
+
+ // Let these shutdown a little earlier when they still have access to most
+ // of XPCOM
+ lazy.AsyncShutdown.quitApplicationGranted.addBlocker(
+ "XPIProvider shutdown",
+ async () => {
+ // Do not enter shutdown before we actually finished starting as this
+ // can lead to hangs as seen in bug 1814104.
+ await Promise.allSettled([
+ ...this.startupPromises,
+ ...this.enabledAddonsStartupPromises,
+ ]);
+
+ XPIProvider._closing = true;
+
+ await XPIProvider.cleanupTemporaryAddons();
+ for (let addon of XPIProvider.sortBootstrappedAddons().reverse()) {
+ // If no scope has been loaded for this add-on then there is no need
+ // to shut it down (should only happen when a bootstrapped add-on is
+ // pending enable)
+ let activeAddon = XPIProvider.activeAddons.get(addon.id);
+ if (!activeAddon || !activeAddon.started) {
+ continue;
+ }
+
+ // If the add-on was pending disable then shut it down and remove it
+ // from the persisted data.
+ let reason = BOOTSTRAP_REASONS.APP_SHUTDOWN;
+ if (addon._pendingDisable) {
+ reason = BOOTSTRAP_REASONS.ADDON_DISABLE;
+ } else if (addon.location.name == KEY_APP_TEMPORARY) {
+ reason = BOOTSTRAP_REASONS.ADDON_UNINSTALL;
+ let existing = XPIStates.findAddon(
+ addon.id,
+ loc => !loc.isTemporary
+ );
+ if (existing) {
+ reason = lazy.XPIInstall.newVersionReason(
+ addon.version,
+ existing.version
+ );
+ }
+ }
+
+ let scope = BootstrapScope.get(addon);
+ let promise = scope.shutdown(reason);
+ lazy.AsyncShutdown.profileChangeTeardown.addBlocker(
+ `Extension shutdown: ${addon.id}`,
+ promise,
+ {
+ fetchState: scope.fetchState.bind(scope),
+ }
+ );
+ }
+ }
+ );
+
+ // Detect final-ui-startup for telemetry reporting
+ Services.obs.addObserver(function observer() {
+ AddonManagerPrivate.recordTimestamp("XPI_finalUIStartup");
+ Services.obs.removeObserver(observer, "final-ui-startup");
+ }, "final-ui-startup");
+
+ // If we haven't yet loaded the XPI database, schedule loading it
+ // to occur once other important startup work is finished. We want
+ // this to happen relatively quickly after startup so the telemetry
+ // environment has complete addon information.
+ //
+ // Unfortunately we have to use a variety of ways do detect when it
+ // is time to load. In a regular browser process we just wait for
+ // sessionstore-windows-restored. In a browser toolbox process
+ // we wait for the toolbox to show up, based on xul-window-visible
+ // and a visible toolbox window.
+ //
+ // TelemetryEnvironment's EnvironmentAddonBuilder awaits databaseReady
+ // before releasing a blocker on AddonManager.beforeShutdown, which in its
+ // turn is a blocker of a shutdown blocker at "profile-before-change".
+ // To avoid a deadlock, trigger the DB load at "profile-before-change" if
+ // the database hasn't started loading yet.
+ //
+ // Finally, we have a test-only event called test-load-xpi-database
+ // as a temporary workaround for bug 1372845. The latter can be
+ // cleaned up when that bug is resolved.
+ if (!this.isDBLoaded) {
+ const EVENTS = [
+ "sessionstore-windows-restored",
+ "xul-window-visible",
+ "profile-before-change",
+ "test-load-xpi-database",
+ ];
+ let observer = (subject, topic, data) => {
+ if (
+ topic == "xul-window-visible" &&
+ !Services.wm.getMostRecentWindow("devtools:toolbox")
+ ) {
+ return;
+ }
+
+ for (let event of EVENTS) {
+ Services.obs.removeObserver(observer, event);
+ }
+
+ lazy.XPIDatabase.asyncLoadDB();
+ };
+ for (let event of EVENTS) {
+ Services.obs.addObserver(observer, event);
+ }
+ }
+
+ AddonManagerPrivate.recordTimestamp("XPI_startup_end");
+
+ lazy.timerManager.registerTimer(
+ "xpi-signature-verification",
+ () => {
+ lazy.XPIDatabase.verifySignatures();
+ },
+ XPI_SIGNATURE_CHECK_PERIOD
+ );
+ } catch (e) {
+ logger.error("startup failed", e);
+ AddonManagerPrivate.recordException("XPI", "startup failed", e);
+ }
+ },
+
+ /**
+ * Shuts down the database and releases all references.
+ * Return: Promise{integer} resolves / rejects with the result of
+ * flushing the XPI Database if it was loaded,
+ * 0 otherwise.
+ */
+ async shutdown() {
+ logger.debug("shutdown");
+
+ this.activeAddons.clear();
+ this.allAppGlobal = true;
+
+ // Stop anything we were doing asynchronously
+ lazy.XPIInstall.cancelAll();
+
+ for (let install of lazy.XPIInstall.installs) {
+ if (install.onShutdown()) {
+ install.onShutdown();
+ }
+ }
+
+ // If there are pending operations then we must update the list of active
+ // add-ons
+ if (Services.prefs.getBoolPref(PREF_PENDING_OPERATIONS, false)) {
+ lazy.XPIDatabase.updateActiveAddons();
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, false);
+ }
+
+ await lazy.XPIDatabase.shutdown();
+ },
+
+ cleanupTemporaryAddons() {
+ let promises = [];
+ let tempLocation = TemporaryInstallLocation;
+ for (let [id, addon] of tempLocation.entries()) {
+ tempLocation.delete(id);
+
+ let bootstrap = BootstrapScope.get(addon);
+ let existing = XPIStates.findAddon(id, loc => !loc.isTemporary);
+
+ let cleanup = () => {
+ tempLocation.installer.uninstallAddon(id);
+ tempLocation.removeAddon(id);
+ };
+
+ let promise;
+ if (existing) {
+ promise = bootstrap.update(existing, false, () => {
+ cleanup();
+ lazy.XPIDatabase.makeAddonLocationVisible(id, existing.location);
+ });
+ } else {
+ promise = bootstrap.uninstall().then(cleanup);
+ }
+ lazy.AsyncShutdown.profileChangeTeardown.addBlocker(
+ `Temporary extension shutdown: ${addon.id}`,
+ promise
+ );
+ promises.push(promise);
+ }
+ return Promise.all(promises);
+ },
+
+ /**
+ * Adds a list of currently active add-ons to the next crash report.
+ */
+ addAddonsToCrashReporter() {
+ void (Services.appinfo instanceof Ci.nsICrashReporter);
+ if (!Services.appinfo.annotateCrashReport || Services.appinfo.inSafeMode) {
+ return;
+ }
+
+ let data = Array.from(XPIStates.enabledAddons(), a => a.telemetryKey).join(
+ ","
+ );
+
+ try {
+ Services.appinfo.annotateCrashReport("Add-ons", data);
+ } catch (e) {}
+
+ lazy.TelemetrySession.setAddOns(data);
+ },
+
+ /**
+ * Check the staging directories of install locations for any add-ons to be
+ * installed or add-ons to be uninstalled.
+ *
+ * @param {Object} aManifests
+ * A dictionary to add detected install manifests to for the purpose
+ * of passing through updated compatibility information
+ * @returns {boolean}
+ * True if an add-on was installed or uninstalled
+ */
+ processPendingFileChanges(aManifests) {
+ let changed = false;
+ for (let loc of XPIStates.locations()) {
+ aManifests[loc.name] = {};
+ // We can't install or uninstall anything in locked locations
+ if (loc.locked) {
+ continue;
+ }
+
+ // Collect any install errors for specific removal from the staged directory
+ // during cleanStagingDir. Successful installs remove the files.
+ let stagedFailureNames = [];
+ let promises = [];
+ for (let [id, metadata] of loc.getStagedAddons()) {
+ loc.unstageAddon(id);
+
+ aManifests[loc.name][id] = null;
+ promises.push(
+ lazy.XPIInstall.installStagedAddon(id, metadata, loc).then(
+ addon => {
+ aManifests[loc.name][id] = addon;
+ },
+ error => {
+ delete aManifests[loc.name][id];
+ stagedFailureNames.push(`${id}.xpi`);
+
+ logger.error(
+ `Failed to install staged add-on ${id} in ${loc.name}`,
+ error
+ );
+ }
+ )
+ );
+ }
+
+ if (promises.length) {
+ changed = true;
+ awaitPromise(Promise.all(promises));
+ }
+
+ try {
+ if (changed || stagedFailureNames.length) {
+ loc.installer.cleanStagingDir(stagedFailureNames);
+ }
+ } catch (e) {
+ // Non-critical, just saves some perf on startup if we clean this up.
+ logger.debug("Error cleaning staging dir", e);
+ }
+ }
+ return changed;
+ },
+
+ /**
+ * Installs any add-ons located in the extensions directory of the
+ * application's distribution specific directory into the profile unless a
+ * newer version already exists or the user has previously uninstalled the
+ * distributed add-on.
+ *
+ * @param {Object} aManifests
+ * A dictionary to add new install manifests to to save having to
+ * reload them later
+ * @param {string} [aAppChanged]
+ * See checkForChanges
+ * @param {string?} [aOldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @returns {boolean}
+ * True if any new add-ons were installed
+ */
+ installDistributionAddons(aManifests, aAppChanged, aOldAppVersion) {
+ let distroDirs = [];
+ try {
+ distroDirs.push(
+ lazy.FileUtils.getDir(KEY_APP_DISTRIBUTION, [DIR_EXTENSIONS])
+ );
+ } catch (e) {
+ return false;
+ }
+
+ let availableLocales = [];
+ for (let file of iterDirectory(distroDirs[0])) {
+ if (file.isDirectory() && file.leafName.startsWith("locale-")) {
+ availableLocales.push(file.leafName.replace("locale-", ""));
+ }
+ }
+
+ let locales = Services.locale.negotiateLanguages(
+ Services.locale.requestedLocales,
+ availableLocales,
+ undefined,
+ Services.locale.langNegStrategyMatching
+ );
+
+ // Also install addons from subdirectories that correspond to the requested
+ // locales. This allows for installing language packs and dictionaries.
+ for (let locale of locales) {
+ let langPackDir = distroDirs[0].clone();
+ langPackDir.append(`locale-${locale}`);
+ distroDirs.push(langPackDir);
+ }
+
+ let changed = false;
+ for (let distroDir of distroDirs) {
+ logger.warn(`Checking ${distroDir.path} for addons`);
+ for (let file of iterDirectory(distroDir)) {
+ if (!isXPI(file.leafName, true)) {
+ // Only warn for files, not directories
+ if (!file.isDirectory()) {
+ logger.warn(`Ignoring distribution: not an XPI: ${file.path}`);
+ }
+ continue;
+ }
+
+ let id = getExpectedID(file);
+ if (!id) {
+ logger.warn(
+ `Ignoring distribution: name is not a valid add-on ID: ${file.path}`
+ );
+ continue;
+ }
+
+ /* If this is not an upgrade and we've already handled this extension
+ * just continue */
+ if (
+ !aAppChanged &&
+ Services.prefs.prefHasUserValue(PREF_BRANCH_INSTALLED_ADDON + id)
+ ) {
+ continue;
+ }
+
+ try {
+ let loc = XPIStates.getLocation(KEY_APP_PROFILE);
+ let addon = awaitPromise(
+ lazy.XPIInstall.installDistributionAddon(
+ id,
+ file,
+ loc,
+ aOldAppVersion
+ )
+ );
+
+ if (addon) {
+ // aManifests may contain a copy of a newly installed add-on's manifest
+ // and we'll have overwritten that so instead cache our install manifest
+ // which will later be put into the database in processFileChanges
+ if (!(loc.name in aManifests)) {
+ aManifests[loc.name] = {};
+ }
+ aManifests[loc.name][id] = addon;
+ changed = true;
+ }
+ } catch (e) {
+ logger.error(`Failed to install distribution add-on ${file.path}`, e);
+ }
+ }
+ }
+
+ return changed;
+ },
+
+ /**
+ * Like `installBuiltinAddon`, but only installs the addon at `aBase`
+ * if an existing built-in addon with the ID `aID` and version doesn't
+ * already exist.
+ *
+ * @param {string} aID
+ * The ID of the add-on being registered.
+ * @param {string} aVersion
+ * The version of the add-on being registered.
+ * @param {string} aBase
+ * A string containing the base URL. Must be a resource: URL.
+ * @returns {Promise<Addon>} a Promise that resolves when the addon is installed.
+ */
+ async maybeInstallBuiltinAddon(aID, aVersion, aBase) {
+ let installed;
+ if (lazy.enabledScopes & BuiltInLocation.scope) {
+ let existing = BuiltInLocation.get(aID);
+ if (!existing || existing.version != aVersion) {
+ installed = this.installBuiltinAddon(aBase);
+ this.startupPromises.push(installed);
+ }
+ }
+ return installed;
+ },
+
+ getDependentAddons(aAddon) {
+ return Array.from(lazy.XPIDatabase.getAddons()).filter(addon =>
+ addon.dependencies.includes(aAddon.id)
+ );
+ },
+
+ /**
+ * Checks for any changes that have occurred since the last time the
+ * application was launched.
+ *
+ * @param {boolean?} [aAppChanged]
+ * A tri-state value. Undefined means the current profile was created
+ * for this session, true means the profile already existed but was
+ * last used with an application with a different version number,
+ * false means that the profile was last used by this version of the
+ * application.
+ * @param {string?} [aOldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @param {string?} [aOldPlatformVersion]
+ * The version of the platform last run with this profile or null
+ * if it is a new profile or the version is unknown
+ */
+ checkForChanges(aAppChanged, aOldAppVersion, aOldPlatformVersion) {
+ logger.debug("checkForChanges");
+
+ // Keep track of whether and why we need to open and update the database at
+ // startup time.
+ let updateReasons = [];
+ if (aAppChanged) {
+ updateReasons.push("appChanged");
+ }
+
+ let installChanged = XPIStates.scanForChanges(aAppChanged === false);
+ if (installChanged) {
+ updateReasons.push("directoryState");
+ }
+
+ // First install any new add-ons into the locations, if there are any
+ // changes then we must update the database with the information in the
+ // install locations
+ let manifests = {};
+ let updated = this.processPendingFileChanges(manifests);
+ if (updated) {
+ updateReasons.push("pendingFileChanges");
+ }
+
+ // This will be true if the previous session made changes that affect the
+ // active state of add-ons but didn't commit them properly (normally due
+ // to the application crashing)
+ let hasPendingChanges = Services.prefs.getBoolPref(
+ PREF_PENDING_OPERATIONS,
+ false
+ );
+ if (hasPendingChanges) {
+ updateReasons.push("hasPendingChanges");
+ }
+
+ // If the application has changed then check for new distribution add-ons
+ if (Services.prefs.getBoolPref(PREF_INSTALL_DISTRO_ADDONS, true)) {
+ updated = this.installDistributionAddons(
+ manifests,
+ aAppChanged,
+ aOldAppVersion
+ );
+ if (updated) {
+ updateReasons.push("installDistributionAddons");
+ }
+ }
+
+ // If the schema appears to have changed then we should update the database
+ if (DB_SCHEMA != Services.prefs.getIntPref(PREF_DB_SCHEMA, 0)) {
+ // If we don't have any add-ons, just update the pref, since we don't need to
+ // write the database
+ if (!XPIStates.size) {
+ logger.debug(
+ "Empty XPI database, setting schema version preference to " +
+ DB_SCHEMA
+ );
+ Services.prefs.setIntPref(PREF_DB_SCHEMA, DB_SCHEMA);
+ } else {
+ updateReasons.push("schemaChanged");
+ }
+ }
+
+ // Catch and log any errors during the main startup
+ try {
+ let extensionListChanged = false;
+ // If the database needs to be updated then open it and then update it
+ // from the filesystem
+ if (updateReasons.length) {
+ AddonManagerPrivate.recordSimpleMeasure(
+ "XPIDB_startup_load_reasons",
+ updateReasons
+ );
+ lazy.XPIDatabase.syncLoadDB(false);
+ try {
+ extensionListChanged = lazy.XPIDatabaseReconcile.processFileChanges(
+ manifests,
+ aAppChanged,
+ aOldAppVersion,
+ aOldPlatformVersion,
+ updateReasons.includes("schemaChanged")
+ );
+ } catch (e) {
+ logger.error("Failed to process extension changes at startup", e);
+ }
+ }
+
+ // If the application crashed before completing any pending operations then
+ // we should perform them now.
+ if (extensionListChanged || hasPendingChanges) {
+ lazy.XPIDatabase.updateActiveAddons();
+ return;
+ }
+
+ logger.debug("No changes found");
+ } catch (e) {
+ logger.error("Error during startup file checks", e);
+ }
+ },
+
+ /**
+ * Gets an array of add-ons which were placed in a known install location
+ * prior to startup of the current session, were detected by a directory scan
+ * of those locations, and are currently disabled.
+ *
+ * @returns {Promise<Array<Addon>>}
+ */
+ async getNewSideloads() {
+ if (XPIStates.scanForChanges(false)) {
+ // We detected changes. Update the database to account for them.
+ await lazy.XPIDatabase.asyncLoadDB(false);
+ lazy.XPIDatabaseReconcile.processFileChanges({}, false);
+ lazy.XPIDatabase.updateActiveAddons();
+ }
+
+ let addons = await Promise.all(
+ Array.from(XPIStates.sideLoadedAddons.keys(), id => this.getAddonByID(id))
+ );
+
+ return addons.filter(
+ addon =>
+ addon &&
+ addon.seen === false &&
+ addon.permissions & AddonManager.PERM_CAN_ENABLE
+ );
+ },
+
+ /**
+ * Called to test whether this provider supports installing a particular
+ * mimetype.
+ *
+ * @param {string} aMimetype
+ * The mimetype to check for
+ * @returns {boolean}
+ * True if the mimetype is application/x-xpinstall
+ */
+ supportsMimetype(aMimetype) {
+ return aMimetype == "application/x-xpinstall";
+ },
+
+ // Identify temporary install IDs.
+ isTemporaryInstallID(id) {
+ return id.endsWith(TEMPORARY_ADDON_SUFFIX);
+ },
+
+ /**
+ * Sets startupData for the given addon. The provided data will be stored
+ * in addonsStartup.json so it is available early during browser startup.
+ * Note that this file is read synchronously at startup, so startupData
+ * should be used with care.
+ *
+ * @param {string} aID
+ * The id of the addon to save startup data for.
+ * @param {any} aData
+ * The data to store. Must be JSON serializable.
+ */
+ setStartupData(aID, aData) {
+ let state = XPIStates.findAddon(aID);
+ state.startupData = aData;
+ XPIStates.save();
+ },
+
+ /**
+ * Persists some startupData into an addon if it is available in the current
+ * XPIState for the addon id.
+ *
+ * @param {AddonInternal} addon An addon to receive the startup data, typically an update that is occuring.
+ * @param {XPIState} state optional
+ */
+ persistStartupData(addon, state) {
+ if (!addon.startupData) {
+ state = state || XPIStates.findAddon(addon.id);
+ if (state?.enabled) {
+ // Save persistent listener data if available. It will be
+ // removed later if necessary.
+ let persistentListeners = state.startupData?.persistentListeners;
+ addon.startupData = { persistentListeners };
+ }
+ }
+ },
+
+ getAddonIDByInstanceID(aInstanceID) {
+ if (!aInstanceID || typeof aInstanceID != "symbol") {
+ throw Components.Exception(
+ "aInstanceID must be a Symbol()",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ for (let [id, val] of this.activeAddons) {
+ if (aInstanceID == val.instanceID) {
+ return id;
+ }
+ }
+
+ return null;
+ },
+
+ async getAddonsByTypes(aTypes) {
+ if (aTypes && !aTypes.some(type => ALL_XPI_TYPES.has(type))) {
+ return [];
+ }
+ return lazy.XPIDatabase.getAddonsByTypes(aTypes);
+ },
+
+ /**
+ * Called to get active Addons of a particular type
+ *
+ * @param {Array<string>?} aTypes
+ * An array of types to fetch. Can be null to get all types.
+ * @returns {Promise<Array<Addon>>}
+ */
+ async getActiveAddons(aTypes) {
+ // If we already have the database loaded, returning full info is fast.
+ if (this.isDBLoaded) {
+ let addons = await this.getAddonsByTypes(aTypes);
+ return {
+ addons: addons.filter(addon => addon.isActive),
+ fullData: true,
+ };
+ }
+
+ let result = [];
+ for (let addon of XPIStates.enabledAddons()) {
+ if (aTypes && !aTypes.includes(addon.type)) {
+ continue;
+ }
+ let { scope, isSystem } = addon.location;
+ result.push({
+ id: addon.id,
+ version: addon.version,
+ type: addon.type,
+ updateDate: addon.lastModifiedTime,
+ scope,
+ isSystem,
+ isWebExtension: addon.isWebExtension,
+ });
+ }
+
+ return { addons: result, fullData: false };
+ },
+
+ /*
+ * Notified when a preference we're interested in has changed.
+ *
+ * @see nsIObserver
+ */
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case NOTIFICATION_FLUSH_PERMISSIONS:
+ if (!aData || aData == XPI_PERMISSION) {
+ lazy.XPIDatabase.importPermissions();
+ }
+ break;
+
+ case "nsPref:changed":
+ switch (aData) {
+ case PREF_XPI_SIGNATURES_REQUIRED:
+ case PREF_LANGPACK_SIGNATURES:
+ lazy.XPIDatabase.updateAddonAppDisabledStates();
+ break;
+ }
+ }
+ },
+
+ uninstallSystemProfileAddon(aID) {
+ let location = XPIStates.getLocation(KEY_APP_SYSTEM_PROFILE);
+ return lazy.XPIInstall.uninstallAddonFromLocation(aID, location);
+ },
+};
+
+for (let meth of [
+ "getInstallForFile",
+ "getInstallForURL",
+ "getInstallsByTypes",
+ "installTemporaryAddon",
+ "installBuiltinAddon",
+ "isInstallAllowed",
+ "isInstallEnabled",
+ "updateSystemAddons",
+ "stageLangpacksForAppUpdate",
+]) {
+ XPIProvider[meth] = function () {
+ return lazy.XPIInstall[meth](...arguments);
+ };
+}
+
+for (let meth of [
+ "addonChanged",
+ "getAddonByID",
+ "getAddonBySyncGUID",
+ "updateAddonRepositoryData",
+ "updateAddonAppDisabledStates",
+]) {
+ XPIProvider[meth] = function () {
+ return lazy.XPIDatabase[meth](...arguments);
+ };
+}
+
+var XPIInternal = {
+ BOOTSTRAP_REASONS,
+ BootstrapScope,
+ BuiltInLocation,
+ DB_SCHEMA,
+ DIR_STAGE,
+ DIR_TRASH,
+ KEY_APP_PROFILE,
+ KEY_APP_SYSTEM_PROFILE,
+ KEY_APP_SYSTEM_ADDONS,
+ KEY_APP_SYSTEM_DEFAULTS,
+ PREF_BRANCH_INSTALLED_ADDON,
+ PREF_SYSTEM_ADDON_SET,
+ SystemAddonLocation,
+ TEMPORARY_ADDON_SUFFIX,
+ TemporaryInstallLocation,
+ XPIStates,
+ XPI_PERMISSION,
+ awaitPromise,
+ canRunInSafeMode,
+ getURIForResourceInFile,
+ isXPI,
+ iterDirectory,
+ maybeResolveURI,
+ migrateAddonLoader,
+ resolveDBReady,
+
+ // Used by tests to shut down AddonManager.
+ overrideAsyncShutdown(mockAsyncShutdown) {
+ lazy.AsyncShutdown = mockAsyncShutdown;
+ },
+};
+
+AddonManagerPrivate.registerProvider(XPIProvider, Array.from(ALL_XPI_TYPES));
diff --git a/toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs b/toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs
new file mode 100644
index 0000000000..d242948dfd
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const CryptoHash = Components.Constructor(
+ "@mozilla.org/security/hash;1",
+ "nsICryptoHash",
+ "initWithString"
+);
+
+/**
+ * Returns the string representation (hex) of the SHA256 hash of `input`.
+ *
+ * @param {string} input
+ * The value to hash.
+ * @returns {string}
+ * The hex representation of a SHA256 hash.
+ */
+export function computeSha256HashAsString(input) {
+ const data = new Uint8Array(new TextEncoder().encode(input));
+ const crypto = CryptoHash("sha256");
+ crypto.update(data, data.length);
+ return getHashStringForCrypto(crypto);
+}
+
+/**
+ * Returns the string representation (hex) of a given CryptoHashInstance.
+ *
+ * @param {CryptoHash} aCrypto
+ * @returns {string}
+ * The hex representation of a SHA256 hash.
+ */
+export function getHashStringForCrypto(aCrypto) {
+ // return the two-digit hexadecimal code for a byte
+ let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2);
+
+ // convert the binary hash data to a hex string.
+ let binary = aCrypto.finish(/* base64 */ false);
+ let hash = Array.from(binary, c => toHexString(c.charCodeAt(0)));
+ return hash.join("").toLowerCase();
+}
diff --git a/toolkit/mozapps/extensions/internal/moz.build b/toolkit/mozapps/extensions/internal/moz.build
new file mode 100644
index 0000000000..c4ed533412
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/moz.build
@@ -0,0 +1,28 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES.addons += [
+ "AddonRepository.sys.mjs",
+ "AddonSettings.sys.mjs",
+ "AddonUpdateChecker.sys.mjs",
+ "crypto-utils.sys.mjs",
+ "ProductAddonChecker.sys.mjs",
+ "siteperms-addon-utils.sys.mjs",
+ "XPIDatabase.jsm",
+ "XPIInstall.jsm",
+ "XPIProvider.jsm",
+]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] != "android":
+ EXTRA_JS_MODULES.addons += [
+ "GMPProvider.sys.mjs",
+ ## TODO consider extending it to mobile builds too (See Bug 1790084).
+ "SitePermsAddonProvider.sys.mjs",
+ ]
+
+TESTING_JS_MODULES += [
+ "AddonTestUtils.sys.mjs",
+]
diff --git a/toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs b/toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs
new file mode 100644
index 0000000000..86853d7168
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+export const GATED_PERMISSIONS = ["midi", "midi-sysex"];
+export const SITEPERMS_ADDON_PROVIDER_PREF =
+ "dom.sitepermsaddon-provider.enabled";
+export const SITEPERMS_ADDON_TYPE = "sitepermission";
+export const SITEPERMS_ADDON_BLOCKEDLIST_PREF =
+ "dom.sitepermsaddon-provider.separatedBlocklistedDomains";
+
+const lazy = {};
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "blocklistedOriginsSet",
+ SITEPERMS_ADDON_BLOCKEDLIST_PREF,
+ // Default value
+ "",
+ // onUpdate
+ null,
+ // transform
+ prefValue => new Set(prefValue.split(","))
+);
+
+/**
+ * @param {string} type
+ * @returns {boolean}
+ */
+export function isGatedPermissionType(type) {
+ return GATED_PERMISSIONS.includes(type);
+}
+
+/**
+ * @param {string} siteOrigin
+ * @returns {boolean}
+ */
+export function isKnownPublicSuffix(siteOrigin) {
+ const { host } = new URL(siteOrigin);
+
+ let isPublic = false;
+ // getKnownPublicSuffixFromHost throws when passed an IP, in such case, assume
+ // this is not a public etld.
+ try {
+ isPublic = Services.eTLD.getKnownPublicSuffixFromHost(host) == host;
+ } catch (e) {}
+
+ return isPublic;
+}
+
+/**
+ * ⚠️ This should be only used for testing purpose ⚠️
+ *
+ * @param {Array<String>} permissionTypes
+ * @throws if not called from xpcshell test
+ */
+export function addGatedPermissionTypesForXpcShellTests(permissionTypes) {
+ if (!Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
+ throw new Error("This should only be called from XPCShell tests");
+ }
+
+ GATED_PERMISSIONS.push(...permissionTypes);
+}
+
+/**
+ * @param {nsIPrincipal} principal
+ * @returns {Boolean}
+ */
+export function isPrincipalInSitePermissionsBlocklist(principal) {
+ return lazy.blocklistedOriginsSet.has(principal.baseDomain);
+}
diff --git a/toolkit/mozapps/extensions/jar.mn b/toolkit/mozapps/extensions/jar.mn
new file mode 100644
index 0000000000..935416225d
--- /dev/null
+++ b/toolkit/mozapps/extensions/jar.mn
@@ -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/.
+
+toolkit.jar:
+% content mozapps %content/mozapps/
+#ifndef MOZ_FENNEC
+ content/mozapps/extensions/OpenH264-license.txt (content/OpenH264-license.txt)
+ content/mozapps/extensions/aboutaddons.html (content/aboutaddons.html)
+ content/mozapps/extensions/aboutaddons.js (content/aboutaddons.js)
+ content/mozapps/extensions/aboutaddonsCommon.js (content/aboutaddonsCommon.js)
+ content/mozapps/extensions/aboutaddons.css (content/aboutaddons.css)
+ content/mozapps/extensions/abuse-reports.js (content/abuse-reports.js)
+ content/mozapps/extensions/abuse-report-frame.html (content/abuse-report-frame.html)
+ content/mozapps/extensions/abuse-report-panel.css (content/abuse-report-panel.css)
+ content/mozapps/extensions/abuse-report-panel.js (content/abuse-report-panel.js)
+ content/mozapps/extensions/drag-drop-addon-installer.js (content/drag-drop-addon-installer.js)
+ content/mozapps/extensions/rating-star.css (content/rating-star.css)
+ content/mozapps/extensions/shortcuts.css (content/shortcuts.css)
+ content/mozapps/extensions/shortcuts.js (content/shortcuts.js)
+ content/mozapps/extensions/view-controller.js (content/view-controller.js)
+
+% resource default-theme %content/mozapps/extensions/default-theme/
+ content/mozapps/extensions/default-theme (default-theme/*.svg)
+ content/mozapps/extensions/default-theme/manifest.json (default-theme/manifest.json)
+#endif
diff --git a/toolkit/mozapps/extensions/metrics.yaml b/toolkit/mozapps/extensions/metrics.yaml
new file mode 100644
index 0000000000..fbda25ec13
--- /dev/null
+++ b/toolkit/mozapps/extensions/metrics.yaml
@@ -0,0 +1,257 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# Adding a new metric? We have docs for that!
+# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html
+
+---
+$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0
+$tags:
+ - 'Toolkit :: Add-ons Manager'
+
+blocklist:
+ last_modified_rs_addons_mblf:
+ type: datetime
+ description: >
+ Keep track of the last time the "addons-bloomfilters" remotesetting
+ blocklist has been successfully updated.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1572711
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1607744
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1649960
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1689274
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1730037
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1763529
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1811159
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820155
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1607744#c11
+ data_sensitivity:
+ - technical
+ metadata:
+ tags:
+ - 'Toolkit :: Blocklist Implementation'
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ - rwu@mozilla.com
+ expires: 122
+ mlbf_source:
+ type: string
+ description: >
+ The source of the RemoteSettings attachment that
+ holds the bloom filter. Possible values are "dump_match",
+ "cache_match", "remote_match","dump_fallback", "cache_fallback",
+ "unknown". "dump_match", "cache_match" and "remote_match"
+ are expected known-good values, and means that the loaded
+ bloomfilter matches the blocklist record in
+ the RemoteSettings collection. The prefix denotes
+ the immediate source of the loaded data: "dump"
+ means packaged with the application, "remote"
+ means a freshly downloaded bloomfilter, "cache"
+ means a previously downloaded bloomfilter. "dump_fallback"
+ and "cache_fallback" means that the last known bloomfilter
+ was used, despite it not matching the latest record
+ in the RemoteSettings collection. In this case
+ the outdated bloomfilter is used as a fallback
+ (e.g. because the latest version cannot be downloaded).
+ "unknown" means that the bloomfilter cannot
+ be loaded at all. This can happen if the blocklist
+ is disabled via preferences or enterprise policies.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1662857
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1730037
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1763529
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1811159
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820155
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820155#c?
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820155#?
+ data_sensitivity:
+ - technical
+ metadata:
+ tags:
+ - 'Toolkit :: Blocklist Implementation'
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ - rwu@mozilla.com
+ expires: 122
+ telemetry_mirror: BLOCKLIST_MLBF_SOURCE
+ mlbf_generation_time:
+ type: datetime
+ description: >
+ Keep track of the generation time of the addon
+ blocklist's bloom filter. This marks the point
+ in time until which signed add-ons are recognized
+ by the selected bloom filter from the addons-bloomfilters
+ collection.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1633466
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1649960
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1689274
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1730037
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1763529
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1811159
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820155
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1633466#c3
+ data_sensitivity:
+ - technical
+ metadata:
+ tags:
+ - 'Toolkit :: Blocklist Implementation'
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ - rwu@mozilla.com
+ expires: 122
+ mlbf_stash_time_oldest:
+ type: datetime
+ description: >
+ Keep track of the timestamp of the oldest stash
+ of the addons blocklist. Only meaningful when
+ mlbf_enabled is true.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1633466
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1649960
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1689274
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1730037
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1763529
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1811159
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820155
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1633466#a1487451_575816
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ - rwu@mozilla.com
+ expires: 122
+ mlbf_stash_time_newest:
+ type: datetime
+ description: >
+ Keep track of the timestamp of the most recent
+ stash of the addons blocklist. Only meaningful
+ when mlbf_enabled is true.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1633466
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1649960
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1689274
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1730037
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1763529
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1811159
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820155
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1633466#a1487451_575816
+ data_sensitivity:
+ - technical
+ metadata:
+ tags:
+ - 'Toolkit :: Blocklist Implementation'
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ - rwu@mozilla.com
+ expires: 122
+ addon_block_change:
+ type: event
+ description: >
+ An add-on is blocked, or an installed add-on is unblocked.
+ When an add-on install/update is blocked, its installation
+ is aborted and the add-on is no longer listed in the activeAddons
+ field of TelemetryEnvironment.
+ extra_keys:
+ value:
+ type: string
+ description: >
+ The value is the ID of the add-on.
+ object:
+ type: string
+ description: >
+ The object represents the reason for triggering
+ the blocklistState check: "addon_install" is when
+ an add-on is installed. "addon_update" is when an
+ add-on is updated after an update check.
+ "addon_update_check" is when an add-on is blocked
+ during the update check. "addon_db_modified" is when
+ an add-on's blocklistState was altered
+ between application restarts. "blocklist_update"
+ is when an add-on's blocklistState changed due to a
+ blocklist update. This may be
+ due to the blocklist being disabled by preferences
+ or enterprise policies, but it is more commonly
+ the result of updating entries in the blocklist.
+ objects: ["addon_install", "addon_update",
+ "addon_update_check", "addon_db_modified",
+ "blocklist_update"]
+ blocklist_state:
+ type: string
+ description: >
+ The blocklistState of the add-on. 0 is unblocked,
+ 2 is blocked. 1 is soft blocked (only if blocklist
+ v3 was disabled in favor of v2).
+ addon_version:
+ type: string
+ description: >
+ Version of the add-on. Used together with an
+ add-on's ID (value) to identify add-ons to block.
+ signed_date:
+ type: string
+ description: >
+ Timestamp of the add-on (when it was signed via AMO).
+ the add-on was installed or updated.
+ At least zero when the blocklist is updated, -1 otherwise.
+ hours_since:
+ type: string
+ description: >
+ The number of hours that have passed since this version of
+ This field is missing (0) for "addon_update_check".
+ mlbf_last_time:
+ type: string
+ description: >
+ The generation time of the most recent
+ entry in the blocklist. Time generated by
+ AMO when the blocklist entry was created.
+ May be 0 when the blocklist is disabled.
+ mlbf_generation:
+ type: string
+ description: >
+ The generation time to identify the bloomfilter
+ that was used for this blocklist decision.
+ The bloomfilter is updated less frequently
+ than the so-called stashes in the RemoteSettings
+ collection that holds the blocklist data.
+ The stashes take precedence over the bloomfilter
+ in blocklist decisions.
+ Time generated by AMO when the blocklist
+ entry was created.
+ May be 0 when the blocklist is disabled.
+ mlbf_source:
+ type: string
+ description: >
+ The source of the RemoteSettings attachment
+ that holds the bloom filter. This field is
+ documented in more detail in the definition
+ of the blocklist.mlbf_source. Possible values
+ are "dump_match", "cache_match", "remote_match",
+ "dump_fallback", "cache_fallback", "unknown".
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1662857
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1730037
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1763529
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1811159
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820155
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1662857#c11
+ data_sensitivity:
+ - technical
+ metadata:
+ tags:
+ - 'Toolkit :: Blocklist Implementation'
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ - rwu@mozilla.com
+ expires: 122
diff --git a/toolkit/mozapps/extensions/moz.build b/toolkit/mozapps/extensions/moz.build
new file mode 100644
index 0000000000..5b884b4936
--- /dev/null
+++ b/toolkit/mozapps/extensions/moz.build
@@ -0,0 +1,101 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+SPHINX_TREES["addon-manager"] = "docs"
+
+with Files("docs/**"):
+ SCHEDULES.exclusive = ["docs"]
+
+if CONFIG["MOZ_BUILD_APP"] == "browser":
+ DEFINES["MOZ_BUILD_APP_IS_BROWSER"] = True
+
+if CONFIG["MOZ_BUILD_APP"] == "mobile/android":
+ DEFINES["MOZ_FENNEC"] = True
+
+DIRS += [
+ "internal",
+]
+TEST_DIRS += ["test"]
+
+XPIDL_SOURCES += [
+ "amIAddonManagerStartup.idl",
+ "amIWebInstallPrompt.idl",
+]
+
+XPIDL_MODULE = "extensions"
+
+built_in_addons = "built_in_addons.json"
+GENERATED_FILES += [built_in_addons]
+manifest = GENERATED_FILES[built_in_addons]
+manifest.script = "gen_built_in_addons.py"
+
+if CONFIG["MOZ_BUILD_APP"] == "browser":
+ manifest.flags = ["--features=browser/features"]
+
+ FINAL_TARGET_FILES.browser.chrome.browser.content.browser += [
+ "!%s" % built_in_addons,
+ ]
+elif CONFIG["MOZ_BUILD_APP"] == "mobile/android":
+ manifest.flags = ["--features=features"]
+
+ FINAL_TARGET_FILES.chrome.chrome.content += [
+ "!%s" % built_in_addons,
+ ]
+elif CONFIG["MOZ_BUILD_APP"] == "comm/mail":
+ manifest.flags = ["--features=features"]
+
+ FINAL_TARGET_FILES.chrome.browser.content += [
+ "!%s" % built_in_addons,
+ ]
+
+EXTRA_PP_COMPONENTS += [
+ "extensions.manifest",
+]
+
+EXTRA_JS_MODULES += [
+ "AbuseReporter.sys.mjs",
+ "AddonManager.sys.mjs",
+ "amContentHandler.sys.mjs",
+ "amInstallTrigger.sys.mjs",
+ "amManager.sys.mjs",
+ "amWebAPI.sys.mjs",
+ "Blocklist.sys.mjs",
+ "LightweightThemeManager.sys.mjs",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+EXPORTS.mozilla += [
+ "AddonContentPolicy.h",
+ "AddonManagerStartup.h",
+ "AddonManagerWebAPI.h",
+]
+
+UNIFIED_SOURCES += [
+ "AddonContentPolicy.cpp",
+ "AddonManagerStartup.cpp",
+ "AddonManagerWebAPI.cpp",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+LOCAL_INCLUDES += [
+ "/chrome",
+ "/dom/base",
+]
+
+FINAL_LIBRARY = "xul"
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "Add-ons Manager")
+
+with Files("Blocklist.sys.mjs"):
+ BUG_COMPONENT = ("Toolkit", "Blocklist Implementation")
+
+with Files("content/blocklist**"):
+ BUG_COMPONENT = ("Toolkit", "Blocklist Implementation")
diff --git a/toolkit/mozapps/extensions/test/browser/.eslintrc.js b/toolkit/mozapps/extensions/test/browser/.eslintrc.js
new file mode 100644
index 0000000000..f2b9e072f9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/.eslintrc.js
@@ -0,0 +1,14 @@
+"use strict";
+
+module.exports = {
+ env: {
+ webextensions: true,
+ },
+
+ rules: {
+ "no-unused-vars": [
+ "error",
+ { args: "none", varsIgnorePattern: "^end_test$" },
+ ],
+ },
+};
diff --git a/toolkit/mozapps/extensions/test/browser/addon_prefs.xhtml b/toolkit/mozapps/extensions/test/browser/addon_prefs.xhtml
new file mode 100644
index 0000000000..e8cde29666
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addon_prefs.xhtml
@@ -0,0 +1,6 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ id="addon-test-pref-window">
+ <label value="Oh hai!"/>
+</window>
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/manifest.mf b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/manifest.mf
new file mode 100644
index 0000000000..725ac8016f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/manifest.mf
@@ -0,0 +1,8 @@
+Manifest-Version: 1.0
+
+Name: manifest.json
+Digest-Algorithms: MD5 SHA1 SHA256
+MD5-Digest: mCLu38qfGN3trj7qKQQeEA==
+SHA1-Digest: A1BaJErQY6KqnYDijP0lglrehk0=
+SHA256-Digest: p2vjGP7DRqrK81NfT4LqnF7a5p8+lEuout5WLBhk9AA=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/mozilla.rsa b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/mozilla.rsa
new file mode 100644
index 0000000000..046a0285c7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/mozilla.rsa
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/mozilla.sf b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/mozilla.sf
new file mode 100644
index 0000000000..ad4e81b574
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/mozilla.sf
@@ -0,0 +1,5 @@
+Signature-Version: 1.0
+MD5-Digest-Manifest: LrrwWBKNYWeVd205Hq+JwQ==
+SHA1-Digest-Manifest: MeqqQN+uuf0MVesMXxbBtYN+5tU=
+SHA256-Digest-Manifest: iWCxfAJX593Cn4l8R63jaQETO5HX3XOhcnpQ7nMiPlg=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/manifest.json b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/manifest.json
new file mode 100644
index 0000000000..91012f24ed
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/manifest.json
@@ -0,0 +1,12 @@
+{
+ "manifest_version": 2,
+
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "dragdrop-1@tests.mozilla.org"
+ }
+ },
+
+ "name": "Drag Drop test 1",
+ "version": "1.0"
+}
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/manifest.mf b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/manifest.mf
new file mode 100644
index 0000000000..1da3c41b23
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/manifest.mf
@@ -0,0 +1,8 @@
+Manifest-Version: 1.0
+
+Name: manifest.json
+Digest-Algorithms: MD5 SHA1 SHA256
+MD5-Digest: 3dL7JFDBPC63pSFI5x+Z7Q==
+SHA1-Digest: l1cKPyWJIYdZyvumH9VfJ6fpqVA=
+SHA256-Digest: QHTjPqTMXxt5tl8zOaAzpQ8FZLqZx8LRF9LmzY+RCDQ=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/mozilla.rsa b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/mozilla.rsa
new file mode 100644
index 0000000000..170a361620
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/mozilla.rsa
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/mozilla.sf b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/mozilla.sf
new file mode 100644
index 0000000000..5301e431f7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/mozilla.sf
@@ -0,0 +1,5 @@
+Signature-Version: 1.0
+MD5-Digest-Manifest: c30hzcI1ISlt46ODjVVJ2w==
+SHA1-Digest-Manifest: 2yMpQHuLM0J61T7vt11NHoYI1tU=
+SHA256-Digest-Manifest: qtsYxiv1zGWBp7JWxLWrIztIdxIt+i3CToReEx5fkyw=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/manifest.json b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/manifest.json
new file mode 100644
index 0000000000..958aa03649
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/manifest.json
@@ -0,0 +1,12 @@
+{
+ "manifest_version": 2,
+
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "dragdrop-2@tests.mozilla.org"
+ }
+ },
+
+ "name": "Drag Drop test 2",
+ "version": "1.1"
+}
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/manifest.mf b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/manifest.mf
new file mode 100644
index 0000000000..e508bcd22f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/manifest.mf
@@ -0,0 +1,8 @@
+Manifest-Version: 1.0
+
+Name: manifest.json
+Digest-Algorithms: MD5 SHA1 SHA256
+MD5-Digest: Wzo/k6fhArpFb4UB2hIKlg==
+SHA1-Digest: D/WDy9api0X7OgRM6Gkvfbyzogo=
+SHA256-Digest: IWBdbytHgPLtCMKKhiZ3jenxKmKiRAhh3ce8iP5AVWU=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/mozilla.rsa b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/mozilla.rsa
new file mode 100644
index 0000000000..a026680e91
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/mozilla.rsa
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/mozilla.sf b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/mozilla.sf
new file mode 100644
index 0000000000..16a1461f37
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/mozilla.sf
@@ -0,0 +1,5 @@
+Signature-Version: 1.0
+MD5-Digest-Manifest: ovtNeIie34gMM5l18zP2MA==
+SHA1-Digest-Manifest: c5owdrvcOINxKp/HprYkWXXI/js=
+SHA256-Digest-Manifest: uLPmoONlxFYxWeSTOEPJ9hN2yMDDZMJL1PoNIWcqKG4=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/manifest.json b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/manifest.json
new file mode 100644
index 0000000000..b204e1bca7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/manifest.json
@@ -0,0 +1,13 @@
+{
+ "manifest_version": 2,
+
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "dragdrop-incompat@tests.mozilla.org",
+ "strict_max_version": "45.0"
+ }
+ },
+
+ "name": "Incomatible Drag Drop test",
+ "version": "1.1"
+}
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/manifest.mf b/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/manifest.mf
new file mode 100644
index 0000000000..eea5cbd501
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/manifest.mf
@@ -0,0 +1,8 @@
+Manifest-Version: 1.0
+
+Name: manifest.json
+Digest-Algorithms: MD5 SHA1 SHA256
+MD5-Digest: b4Q2C4GsIJfRLsXc7T2ldQ==
+SHA1-Digest: UG5rHxpzKmdlGrquXaguiAGDu8E=
+SHA256-Digest: WZrN9SdGBux9t3lV7TVIvyUG/L1px4er2dU3TsBpC4s=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/mozilla.rsa b/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/mozilla.rsa
new file mode 100644
index 0000000000..68621e19be
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/mozilla.rsa
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/mozilla.sf b/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/mozilla.sf
new file mode 100644
index 0000000000..fe6baa8dac
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/mozilla.sf
@@ -0,0 +1,5 @@
+Signature-Version: 1.0
+MD5-Digest-Manifest: zqRm8+jxS0iRUGWeArGkXg==
+SHA1-Digest-Manifest: pa/31Ll1PYx0dPBQ6C+fd1/wJO4=
+SHA256-Digest-Manifest: DJELIyswfwgeL0kaRqogXW2bzUKhn+Pickfv6WHBsW8=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/manifest.json b/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/manifest.json
new file mode 100644
index 0000000000..adc0ae09ee
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/manifest.json
@@ -0,0 +1,12 @@
+{
+ "manifest_version": 2,
+
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "sslinstall-1@tests.mozilla.org"
+ }
+ },
+
+ "name": "SSL Install Tests",
+ "version": "1.0"
+}
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_theme/manifest.json b/toolkit/mozapps/extensions/test/browser/addons/browser_theme/manifest.json
new file mode 100644
index 0000000000..7a399ddc17
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_theme/manifest.json
@@ -0,0 +1,22 @@
+{
+ "manifest_version": 2,
+
+ "name": "Theme test",
+ "version": "1.0",
+
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "theme@tests.mozilla.org"
+ }
+ },
+
+ "theme": {
+ "images": {
+ "theme_frame": "testImage.png"
+ },
+ "colors": {
+ "frame": "#000000",
+ "tab_background_text": "#ffffff"
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/manifest.mf b/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/manifest.mf
new file mode 100644
index 0000000000..a8c72c4794
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/manifest.mf
@@ -0,0 +1,12 @@
+Manifest-Version: 1.0
+
+Name: manifest.json
+Digest-Algorithms: MD5 SHA1
+MD5-Digest: Rnoaa6yWePDor5y5/SLFaw==
+SHA1-Digest: k51DtKj7bYrwkFJDdmYNDQeUBlA=
+
+Name: options.html
+Digest-Algorithms: MD5 SHA1
+MD5-Digest: vTjxWlRpioEhTZGKTNUqIw==
+SHA1-Digest: Y/mr6A34LsvekgRpdhyZRwPF1Vw=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/mozilla.rsa b/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/mozilla.rsa
new file mode 100644
index 0000000000..8b6320adda
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/mozilla.rsa
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/mozilla.sf b/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/mozilla.sf
new file mode 100644
index 0000000000..ba5fd22caa
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/mozilla.sf
@@ -0,0 +1,4 @@
+Signature-Version: 1.0
+MD5-Digest-Manifest: rdmx8VMNzkZ5tRf7tt8G1w==
+SHA1-Digest-Manifest: gjtTe8X9Tg46Hz2h4Tru3T02hmE=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/options_signed/manifest.json b/toolkit/mozapps/extensions/test/browser/addons/options_signed/manifest.json
new file mode 100644
index 0000000000..e808cd5ab6
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/options_signed/manifest.json
@@ -0,0 +1,11 @@
+{
+ "manifest_version": 2,
+
+ "name": "Test options_ui",
+ "description": "Test add-ons manager handling options_ui with no id in manifest.json",
+ "version": "1.2",
+
+ "options_ui": {
+ "page": "options.html"
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/browser/addons/options_signed/options.html b/toolkit/mozapps/extensions/test/browser/addons/options_signed/options.html
new file mode 100644
index 0000000000..ea804601b5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/options_signed/options.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <div id="options-test-panel" />
+ </body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/browser/browser.ini b/toolkit/mozapps/extensions/test/browser/browser.ini
new file mode 100644
index 0000000000..4a45e11424
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser.ini
@@ -0,0 +1,127 @@
+[DEFAULT]
+tags = addons
+support-files =
+ addons/browser_dragdrop1.xpi
+ addons/browser_dragdrop1.zip
+ addons/browser_dragdrop2.xpi
+ addons/browser_dragdrop2.zip
+ addons/browser_dragdrop_incompat.xpi
+ addons/browser_installssl.xpi
+ addons/browser_theme.xpi
+ addons/options_signed.xpi
+ addons/options_signed/*
+ addon_prefs.xhtml
+ discovery/api_response.json
+ discovery/api_response_empty.json
+ discovery/small-1x1.png
+ head.js
+ plugin_test.html
+ redirect.sjs
+ browser_updatessl.json
+ browser_updatessl.json^headers^
+ sandboxed.html
+ sandboxed.html^headers^
+ webapi_addon_listener.html
+ webapi_checkavailable.html
+ webapi_checkchromeframe.xhtml
+ webapi_checkframed.html
+ webapi_checknavigatedwindow.html
+ !/toolkit/mozapps/extensions/test/xpinstall/corrupt.xpi
+ !/toolkit/mozapps/extensions/test/xpinstall/incompatible.xpi
+ !/toolkit/mozapps/extensions/test/xpinstall/installtrigger.html
+ !/toolkit/mozapps/extensions/test/xpinstall/unsigned.xpi
+ !/toolkit/mozapps/extensions/test/xpinstall/amosigned.xpi
+
+generated-files =
+ addons/browser_dragdrop1.xpi
+ addons/browser_dragdrop1.zip
+ addons/browser_dragdrop2.xpi
+ addons/browser_dragdrop2.zip
+ addons/browser_dragdrop_incompat.xpi
+ addons/browser_installssl.xpi
+ addons/browser_theme.xpi
+ addons/options_signed.xpi
+
+skip-if =
+ fission && os == "linux" && asan # Bug 1713895 - new Fission platform triage
+ os == "win" && os_version == "6.1" # Bug 1717250
+
+prefs =
+ dom.webmidi.enabled=true
+ midi.testing=true
+
+[browser_about_debugging_link.js]
+[browser_addon_list_reordering.js]
+[browser_bug572561.js]
+[browser_checkAddonCompatibility.js]
+[browser_colorwaybuiltins_migration.js]
+skip-if = app-name != "firefox"
+[browser_dragdrop.js]
+skip-if = true # Bug 1626824
+[browser_file_xpi_no_process_switch.js]
+[browser_globalwarnings.js]
+[browser_gmpProvider.js]
+[browser_history_navigation.js]
+https_first_disabled = true
+[browser_html_abuse_report.js]
+support-files =
+ head_abuse_report.js
+[browser_html_abuse_report_dialog.js]
+support-files =
+ head_abuse_report.js
+[browser_html_detail_permissions.js]
+[browser_html_detail_view.js]
+[browser_html_discover_view.js]
+https_first_disabled = true
+support-files =
+ head_disco.js
+[browser_html_discover_view_clientid.js]
+[browser_html_discover_view_prefs.js]
+[browser_html_list_view.js]
+[browser_html_list_view_recommendations.js]
+[browser_html_message_bar.js]
+[browser_html_options_ui.js]
+[browser_html_options_ui_in_tab.js]
+[browser_html_pending_updates.js]
+[browser_html_recent_updates.js]
+[browser_html_recommendations.js]
+https_first_disabled = true
+[browser_html_scroll_restoration.js]
+[browser_html_sitepermission_addons.js]
+[browser_html_updates.js]
+https_first_disabled = true
+[browser_html_warning_messages.js]
+[browser_installssl.js]
+[browser_installtrigger_install.js]
+[browser_manage_shortcuts.js]
+[browser_manage_shortcuts_hidden.js]
+[browser_manage_shortcuts_remove.js]
+[browser_menu_button_accessibility.js]
+[browser_page_accessibility.js]
+[browser_page_options_install_addon.js]
+[browser_page_options_updates.js]
+[browser_permission_prompt.js]
+[browser_reinstall.js]
+[browser_shortcuts_duplicate_check.js]
+[browser_sidebar_categories.js]
+[browser_sidebar_hidden_categories.js]
+[browser_sidebar_restore_category.js]
+[browser_subframe_install.js]
+[browser_task_next_test.js]
+[browser_updateid.js]
+[browser_updatessl.js]
+[browser_verify_l10n_strings.js]
+[browser_webapi.js]
+[browser_webapi_abuse_report.js]
+support-files =
+ head_abuse_report.js
+[browser_webapi_access.js]
+https_first_disabled = true
+[browser_webapi_addon_listener.js]
+[browser_webapi_enable.js]
+[browser_webapi_install.js]
+[browser_webapi_install_disabled.js]
+[browser_webapi_theme.js]
+[browser_webapi_uninstall.js]
+[browser_webext_icon.js]
+[browser_webext_incognito.js]
diff --git a/toolkit/mozapps/extensions/test/browser/browser_about_debugging_link.js b/toolkit/mozapps/extensions/test/browser/browser_about_debugging_link.js
new file mode 100644
index 0000000000..c7351f054c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_about_debugging_link.js
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+// Allow rejections related to closing an about:debugging too soon after it has been
+// just opened in a new tab and loaded.
+PromiseTestUtils.allowMatchingRejectionsGlobally(/Connection closed/);
+
+function waitForDispatch(store, type) {
+ return new Promise(resolve => {
+ store.dispatch({
+ type: "@@service/waitUntil",
+ predicate: action => action.type === type,
+ run: (dispatch, getState, action) => {
+ resolve(action);
+ },
+ });
+ });
+}
+
+/**
+ * Wait for all client requests to settle, meaning here that no new request has been
+ * dispatched after the provided delay. (NOTE: same test helper used in about:debugging tests)
+ */
+async function waitForRequestsToSettle(store, delay = 500) {
+ let hasSettled = false;
+
+ // After each iteration of this while loop, we check is the timerPromise had the time
+ // to resolve or if we captured a REQUEST_*_SUCCESS action before.
+ while (!hasSettled) {
+ let timer;
+
+ // This timer will be executed only if no REQUEST_*_SUCCESS action is dispatched
+ // during the delay. We consider that when no request are received for some time, it
+ // means there are no ongoing requests anymore.
+ const timerPromise = new Promise(resolve => {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ timer = setTimeout(() => {
+ hasSettled = true;
+ resolve();
+ }, delay);
+ });
+
+ // Wait either for a REQUEST_*_SUCCESS to be dispatched, or for the timer to resolve.
+ await Promise.race([
+ waitForDispatch(store, "REQUEST_EXTENSIONS_SUCCESS"),
+ waitForDispatch(store, "REQUEST_TABS_SUCCESS"),
+ waitForDispatch(store, "REQUEST_WORKERS_SUCCESS"),
+ timerPromise,
+ ]);
+
+ // Clear the timer to avoid setting hasSettled to true accidently unless timerPromise
+ // was the first to resolve.
+ clearTimeout(timer);
+ }
+}
+
+function waitForRequestsSuccess(store) {
+ return Promise.all([
+ waitForDispatch(store, "REQUEST_EXTENSIONS_SUCCESS"),
+ waitForDispatch(store, "REQUEST_TABS_SUCCESS"),
+ waitForDispatch(store, "REQUEST_WORKERS_SUCCESS"),
+ ]);
+}
+
+add_task(async function testAboutDebugging() {
+ let win = await loadInitialView("extension");
+
+ let aboutAddonsTab = gBrowser.selectedTab;
+ let debugAddonsBtn = win.document.querySelector(
+ '#page-options [action="debug-addons"]'
+ );
+
+ // Verify the about:debugging is loaded.
+ info(`Check about:debugging loads`);
+ let loaded = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:debugging#/runtime/this-firefox",
+ true
+ );
+ debugAddonsBtn.click();
+ await loaded;
+ let aboutDebuggingTab = gBrowser.selectedTab;
+ const { AboutDebugging } = aboutDebuggingTab.linkedBrowser.contentWindow;
+ // Avoid test failures due to closing the about:debugging tab
+ // while it is still initializing.
+ info("Wait until about:debugging actions are finished");
+ await waitForRequestsSuccess(AboutDebugging.store);
+
+ info("Switch back to about:addons");
+ await BrowserTestUtils.switchTab(gBrowser, aboutAddonsTab);
+ is(gBrowser.selectedTab, aboutAddonsTab, "Back to about:addons");
+
+ info("Re-open about:debugging");
+ let switched = TestUtils.waitForCondition(
+ () => gBrowser.selectedTab == aboutDebuggingTab
+ );
+ debugAddonsBtn.click();
+ await switched;
+ await waitForRequestsToSettle(AboutDebugging.store);
+
+ info("Force about:debugging to a different hash URL");
+ aboutDebuggingTab.linkedBrowser.contentWindow.location.hash = "/setup";
+
+ info("Switch back to about:addons again");
+ await BrowserTestUtils.switchTab(gBrowser, aboutAddonsTab);
+ is(gBrowser.selectedTab, aboutAddonsTab, "Back to about:addons");
+
+ info("Re-open about:debugging a second time");
+ switched = TestUtils.waitForCondition(
+ () => gBrowser.selectedTab == aboutDebuggingTab
+ );
+ debugAddonsBtn.click();
+ await switched;
+
+ info("Wait until any new about:debugging request did settle");
+ // Avoid test failures due to closing the about:debugging tab
+ // while it is still initializing.
+ await waitForRequestsToSettle(AboutDebugging.store);
+
+ info("Remove the about:debugging tab");
+ BrowserTestUtils.removeTab(aboutDebuggingTab);
+
+ await closeView(win);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_addon_list_reordering.js b/toolkit/mozapps/extensions/test/browser/browser_addon_list_reordering.js
new file mode 100644
index 0000000000..3fd419032b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_addon_list_reordering.js
@@ -0,0 +1,197 @@
+/* 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"
+);
+
+AddonTestUtils.initMochitest(this);
+
+function assertInSection(card, sectionName, msg) {
+ let section = card.closest("section");
+ let heading = section.querySelector(".list-section-heading");
+ is(
+ card.ownerDocument.l10n.getAttributes(heading).id,
+ `extension-${sectionName}-heading`,
+ msg
+ );
+}
+
+function waitForAnimationFrame(win) {
+ return new Promise(resolve => win.requestAnimationFrame(resolve));
+}
+
+async function clickEnableToggle(card) {
+ let isDisabled = card.addon.userDisabled;
+ let addonEvent = isDisabled ? "onEnabled" : "onDisabled";
+ let addonStateChanged = AddonTestUtils.promiseAddonEvent(addonEvent);
+ let win = card.ownerGlobal;
+ let button = card.querySelector(".extension-enable-button");
+
+ // Centre the button since "start" could be behind the sticky header.
+ button.scrollIntoView({ block: "center" });
+ EventUtils.synthesizeMouseAtCenter(button, { type: "mousemove" }, win);
+ EventUtils.synthesizeMouseAtCenter(button, {}, win);
+
+ await addonStateChanged;
+ await waitForAnimationFrame(win);
+}
+
+function mouseOver(el) {
+ let win = el.ownerGlobal;
+ el.scrollIntoView({ block: "center" });
+ EventUtils.synthesizeMouseAtCenter(el, { type: "mousemove" }, win);
+ return waitForAnimationFrame(win);
+}
+
+function mouseOutOfList(win) {
+ return mouseOver(win.document.querySelector(".header-name"));
+}
+
+function pressKey(win, key) {
+ EventUtils.synthesizeKey(key, {}, win);
+ return waitForAnimationFrame(win);
+}
+
+function waitForTransitionEnd(...els) {
+ return Promise.all(
+ els.map(el =>
+ BrowserTestUtils.waitForEvent(el, "transitionend", false, e => {
+ let cardEl = el.firstElementChild;
+ return e.target == cardEl && e.propertyName == "transform";
+ })
+ )
+ );
+}
+
+add_setup(async function () {
+ // Ensure prefers-reduced-motion isn't set. Some linux environments will have
+ // this enabled by default.
+ await SpecialPowers.pushPrefEnv({
+ set: [["ui.prefersReducedMotion", 0]],
+ });
+});
+
+add_task(async function testReordering() {
+ let addonIds = [
+ "one@mochi.test",
+ "two@mochi.test",
+ "three@mochi.test",
+ "four@mochi.test",
+ "five@mochi.test",
+ ];
+ let extensions = addonIds.map(id =>
+ ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: id,
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ })
+ );
+
+ await Promise.all(extensions.map(ext => ext.startup()));
+
+ let win = await loadInitialView("extension", { withAnimations: true });
+
+ let cardOne = getAddonCard(win, "one@mochi.test");
+ ok(!cardOne.addon.userDisabled, "extension one is enabled");
+ assertInSection(cardOne, "enabled", "cardOne is initially in Enabled");
+
+ await clickEnableToggle(cardOne);
+
+ ok(cardOne.addon.userDisabled, "extension one is now disabled");
+ assertInSection(cardOne, "enabled", "cardOne is still in Enabled");
+
+ let cardThree = getAddonCard(win, "three@mochi.test");
+ ok(!cardThree.addon.userDisabled, "extension three is enabled");
+ assertInSection(cardThree, "enabled", "cardThree is initially in Enabled");
+
+ await clickEnableToggle(cardThree);
+
+ ok(cardThree.addon.userDisabled, "extension three is now disabled");
+ assertInSection(cardThree, "enabled", "cardThree is still in Enabled");
+
+ let transitionsEnded = waitForTransitionEnd(cardOne, cardThree);
+ await mouseOutOfList(win);
+ await transitionsEnded;
+
+ assertInSection(cardOne, "disabled", "cardOne has moved to disabled");
+ assertInSection(cardThree, "disabled", "cardThree has moved to disabled");
+
+ await clickEnableToggle(cardThree);
+ await clickEnableToggle(cardOne);
+
+ assertInSection(cardOne, "disabled", "cardOne is still in disabled");
+ assertInSection(cardThree, "disabled", "cardThree is still in disabled");
+
+ info("Opening a more options menu");
+ let panel = cardThree.querySelector("panel-list");
+ EventUtils.synthesizeMouseAtCenter(
+ cardThree.querySelector('[action="more-options"]'),
+ {},
+ win
+ );
+
+ await BrowserTestUtils.waitForEvent(panel, "shown");
+ await mouseOutOfList(win);
+
+ assertInSection(cardOne, "disabled", "cardOne stays in disabled, menu open");
+ assertInSection(cardThree, "disabled", "cardThree stays in disabled");
+
+ transitionsEnded = waitForTransitionEnd(cardOne, cardThree);
+ EventUtils.synthesizeMouseAtCenter(
+ win.document.querySelector(".header-name"),
+ {},
+ win
+ );
+ await transitionsEnded;
+
+ assertInSection(cardOne, "enabled", "cardOne is now in enabled");
+ assertInSection(cardThree, "enabled", "cardThree is now in enabled");
+
+ let cardOneToggle = cardOne.querySelector(".extension-enable-button");
+ cardOneToggle.scrollIntoView({ block: "center" });
+ cardOneToggle.focus();
+ await pressKey(win, " ");
+ await waitForAnimationFrame(win);
+
+ let cardThreeToggle = cardThree.querySelector(".extension-enable-button");
+ let addonList = win.document.querySelector("addon-list");
+ // Tab down to cardThreeToggle.
+ while (
+ addonList.contains(win.document.activeElement) &&
+ win.document.activeElement !== cardThreeToggle
+ ) {
+ await pressKey(win, "VK_TAB");
+ }
+ await pressKey(win, " ");
+
+ assertInSection(cardOne, "enabled", "cardOne is still in enabled");
+ assertInSection(cardThree, "enabled", "cardThree is still in enabled");
+
+ transitionsEnded = waitForTransitionEnd(cardOne, cardThree);
+ win.document.querySelector('[action="page-options"]').focus();
+ await transitionsEnded;
+ assertInSection(
+ cardOne,
+ "disabled",
+ "cardOne is now in the disabled section"
+ );
+ assertInSection(
+ cardThree,
+ "disabled",
+ "cardThree is now in the disabled section"
+ );
+
+ // Ensure an uninstalled extension is removed right away.
+ // Hover a card in the middle of the list.
+ await mouseOver(getAddonCard(win, "two@mochi.test"));
+ await cardOne.addon.uninstall(true);
+ ok(!cardOne.parentNode, "cardOne has been removed from the document");
+
+ await closeView(win);
+ await Promise.all(extensions.map(ext => ext.unload()));
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_bug572561.js b/toolkit/mozapps/extensions/test/browser/browser_bug572561.js
new file mode 100644
index 0000000000..6f8a56bfba
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_bug572561.js
@@ -0,0 +1,96 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that the locale category is shown if there are no locale packs
+// installed but some are pending install
+
+var gManagerWindow;
+var gCategoryUtilities;
+var gProvider;
+var gInstallProperties = [
+ {
+ name: "Locale Category Test",
+ type: "locale",
+ },
+];
+var gInstall;
+var gExpectedCancel = false;
+var gTestInstallListener = {
+ onInstallStarted(aInstall) {
+ check_hidden(false);
+ },
+
+ onInstallEnded(aInstall) {
+ check_hidden(false);
+ run_next_test();
+ },
+
+ onInstallCancelled(aInstall) {
+ ok(gExpectedCancel, "Should expect install cancel");
+ check_hidden(false);
+ run_next_test();
+ },
+
+ onInstallFailed(aInstall) {
+ ok(false, "Did not expect onInstallFailed");
+ run_next_test();
+ },
+};
+
+async function test() {
+ waitForExplicitFinish();
+
+ gProvider = new MockProvider();
+
+ let aWindow = await open_manager("addons://list/extension");
+ gManagerWindow = aWindow;
+ gCategoryUtilities = new CategoryUtilities(gManagerWindow);
+ run_next_test();
+}
+
+async function end_test() {
+ await close_manager(gManagerWindow);
+ finish();
+}
+
+function check_hidden(aExpectedHidden) {
+ var hidden = !gCategoryUtilities.isTypeVisible("locale");
+ is(hidden, aExpectedHidden, "Should have correct hidden state");
+}
+
+// Tests that a non-active install does not make the locale category show
+add_test(function () {
+ check_hidden(true);
+ gInstall = gProvider.createInstalls(gInstallProperties)[0];
+ gInstall.addTestListener(gTestInstallListener);
+ check_hidden(true);
+ run_next_test();
+});
+
+// Test that restarting the add-on manager with a non-active install
+// does not cause the locale category to show
+add_test(async function () {
+ let aWindow = await restart_manager(gManagerWindow, null);
+ gManagerWindow = aWindow;
+ gCategoryUtilities = new CategoryUtilities(gManagerWindow);
+ check_hidden(true);
+ run_next_test();
+});
+
+// Test that installing the install shows the locale category
+add_test(function () {
+ gInstall.install();
+});
+
+// Test that restarting the add-on manager does not cause the locale category
+// to become hidden
+add_test(async function () {
+ let aWindow = await restart_manager(gManagerWindow, null);
+ gManagerWindow = aWindow;
+ gCategoryUtilities = new CategoryUtilities(gManagerWindow);
+ check_hidden(false);
+
+ gExpectedCancel = true;
+ gInstall.cancel();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_checkAddonCompatibility.js b/toolkit/mozapps/extensions/test/browser/browser_checkAddonCompatibility.js
new file mode 100644
index 0000000000..9cea5b5045
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_checkAddonCompatibility.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test that all bundled add-ons are compatible.
+
+async function test() {
+ waitForExplicitFinish();
+
+ Services.prefs.setBoolPref(PREF_STRICT_COMPAT, true);
+ ok(
+ AddonManager.strictCompatibility,
+ "Strict compatibility should be enabled"
+ );
+
+ let aAddons = await AddonManager.getAllAddons();
+ aAddons.sort(function compareTypeName(a, b) {
+ return a.type.localeCompare(b.type) || a.name.localeCompare(b.name);
+ });
+
+ let allCompatible = true;
+ for (let a of aAddons) {
+ // Ignore plugins.
+ if (a.type == "plugin" || a.id == "workerbootstrap-test@mozilla.org") {
+ continue;
+ }
+
+ ok(
+ a.isCompatible,
+ a.type + " " + a.name + " " + a.version + " should be compatible"
+ );
+ allCompatible = allCompatible && a.isCompatible;
+ }
+
+ finish();
+}
diff --git a/toolkit/mozapps/extensions/test/browser/browser_colorwaybuiltins_migration.js b/toolkit/mozapps/extensions/test/browser/browser_colorwaybuiltins_migration.js
new file mode 100644
index 0000000000..4f91c60168
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_colorwaybuiltins_migration.js
@@ -0,0 +1,262 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* import-globals-from ../../../../../browser/base/content/test/webextensions/head.js */
+loadTestSubscript(
+ "../../../../../browser/base/content/test/webextensions/head.js"
+);
+
+const { BuiltInThemes } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemes.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const server = AddonTestUtils.createHttpServer();
+
+const SERVER_BASE_URL = `http://localhost:${server.identity.primaryPort}`;
+const EXPIRED_COLORWAY_THEME_ID1 = "2022red-colorway@mozilla.org";
+const EXPIRED_COLORWAY_THEME_ID2 = "2022orange-colorway@mozilla.org";
+const ICON_SVG = `
+ <svg width="63" height="62" viewBox="0 0 63 62" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <circle cx="31.5" cy="31" r="31" fill="url(#paint0_linear)"/>
+ <defs>
+ <linearGradient id="paint0_linear" x1="44.4829" y1="19" x2="10.4829" y2="53" gradientUnits="userSpaceOnUse">
+ <stop stop-color="hsl(147, 94%, 25%)"/>
+ <stop offset="1" stop-color="hsl(146, 38%, 49%)"/>
+ </linearGradient>
+ </defs>
+ </svg>
+`;
+
+AddonTestUtils.registerJSON(server, "/updates.json", {
+ addons: {
+ [EXPIRED_COLORWAY_THEME_ID1]: {
+ updates: [
+ {
+ version: "2.0.0",
+ update_link: `${SERVER_BASE_URL}/${EXPIRED_COLORWAY_THEME_ID1}.xpi`,
+ },
+ ],
+ },
+ [EXPIRED_COLORWAY_THEME_ID2]: {
+ updates: [
+ {
+ version: "3.0.0",
+ update_link: `${SERVER_BASE_URL}/${EXPIRED_COLORWAY_THEME_ID2}.xpi`,
+ },
+ ],
+ },
+ },
+});
+
+const createMockThemeManifest = (id, version) => ({
+ name: `Mock theme ${id} ${version}`,
+ author: "Mozilla",
+ version,
+ icons: { 32: "icon.svg" },
+ theme: {
+ colors: {
+ toolbar: "red",
+ },
+ },
+ browser_specific_settings: {
+ gecko: { id },
+ },
+});
+
+function createWebExtensionFile(id, version) {
+ return AddonTestUtils.createTempWebExtensionFile({
+ files: { "icon.svg": ICON_SVG },
+ manifest: createMockThemeManifest(id, version),
+ });
+}
+
+let expiredThemeUpdate1 = createWebExtensionFile(
+ EXPIRED_COLORWAY_THEME_ID1,
+ "2.0.0"
+);
+let expiredThemeUpdate2 = createWebExtensionFile(
+ EXPIRED_COLORWAY_THEME_ID2,
+ "3.0.0"
+);
+
+server.registerFile(`/${EXPIRED_COLORWAY_THEME_ID1}.xpi`, expiredThemeUpdate1);
+server.registerFile(`/${EXPIRED_COLORWAY_THEME_ID2}.xpi`, expiredThemeUpdate2);
+
+const goBack = async win => {
+ let loaded = waitForViewLoad(win);
+ let backButton = win.document.querySelector(".back-button");
+ ok(!backButton.disabled, "back button is enabled");
+ backButton.click();
+ await loaded;
+};
+
+const assertAddonCardFound = (win, { addonId, expectColorwayBuiltIn }) => {
+ const msg = expectColorwayBuiltIn
+ ? `Found addon card for colorway builtin ${addonId}`
+ : `Found addon card for migrated colorway ${addonId}`;
+
+ Assert.equal(
+ getAddonCard(win, addonId)?.addon.isBuiltinColorwayTheme,
+ expectColorwayBuiltIn,
+ msg
+ );
+};
+
+const assertDetailView = async (win, { addonId, expectThemeName }) => {
+ let loadedDetailView = waitForViewLoad(win);
+ await gBrowser.ownerGlobal.promiseDocumentFlushed(() => {});
+ const themeCard = getAddonCard(win, addonId);
+ themeCard.click();
+ await loadedDetailView;
+ Assert.equal(
+ themeCard.querySelector(".addon-name")?.textContent,
+ expectThemeName,
+ `Got the expected addon name in the addon details for ${addonId}`
+ );
+};
+
+async function test_update_expired_colorways_builtins() {
+ // Set expired theme as a retained colorway theme
+ const retainedThemePrefName = "browser.theme.retainedExpiredThemes";
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_UPDATEURL, `${SERVER_BASE_URL}/updates.json`],
+ ["extensions.checkUpdateSecurity", false],
+ ["browser.theme.colorway-migration", true],
+ [
+ retainedThemePrefName,
+ JSON.stringify([
+ EXPIRED_COLORWAY_THEME_ID1,
+ EXPIRED_COLORWAY_THEME_ID2,
+ ]),
+ ],
+ ],
+ });
+
+ await BuiltInThemes.ensureBuiltInThemes();
+ async function uninstallTestAddons() {
+ for (const addonId of [
+ EXPIRED_COLORWAY_THEME_ID1,
+ EXPIRED_COLORWAY_THEME_ID2,
+ ]) {
+ info(`Uninstalling test theme ${addonId}`);
+ let addon = await AddonManager.getAddonByID(addonId);
+ await addon?.uninstall();
+ }
+ }
+ registerCleanupFunction(uninstallTestAddons);
+
+ const expiredAddon1 = await AddonManager.getAddonByID(
+ EXPIRED_COLORWAY_THEME_ID1
+ );
+ const expiredAddon2 = await AddonManager.getAddonByID(
+ EXPIRED_COLORWAY_THEME_ID2
+ );
+ await expiredAddon2.disable();
+ await expiredAddon1.enable();
+
+ info("Open about:addons theme list view");
+ let win = await loadInitialView("theme");
+
+ assertAddonCardFound(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID1,
+ expectColorwayBuiltIn: true,
+ });
+ assertAddonCardFound(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID2,
+ expectColorwayBuiltIn: true,
+ });
+
+ info("Trigger addon update check");
+ const promiseInstallsEnded = Promise.all([
+ AddonTestUtils.promiseInstallEvent(
+ "onInstallEnded",
+ addon => addon.id === EXPIRED_COLORWAY_THEME_ID1
+ ),
+ AddonTestUtils.promiseInstallEvent(
+ "onInstallEnded",
+ addon => addon.id === EXPIRED_COLORWAY_THEME_ID1
+ ),
+ ]);
+ // Wait for active theme to also execute the update bootstrap method.
+ let promiseUpdatedAddon1 = waitForUpdate(expiredAddon1);
+ triggerPageOptionsAction(win, "check-for-updates");
+
+ info("Wait for addon update to be completed");
+ await Promise.all([promiseUpdatedAddon1, promiseInstallsEnded]);
+
+ info("Verify theme list view addon cards");
+ assertAddonCardFound(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID1,
+ expectColorwayBuiltIn: false,
+ });
+ assertAddonCardFound(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID2,
+ expectColorwayBuiltIn: false,
+ });
+
+ info(`Switch to detail view for theme ${EXPIRED_COLORWAY_THEME_ID1}`);
+ await assertDetailView(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID1,
+ expectThemeName: `Mock theme ${EXPIRED_COLORWAY_THEME_ID1} 2.0.0`,
+ });
+
+ info("Switch back to list view");
+ await goBack(win);
+ assertAddonCardFound(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID1,
+ expectColorwayBuiltIn: false,
+ });
+ assertAddonCardFound(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID2,
+ expectColorwayBuiltIn: false,
+ });
+
+ info(`Switch to detail view for theme ${EXPIRED_COLORWAY_THEME_ID2}`);
+ await assertDetailView(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID2,
+ expectThemeName: `Mock theme ${EXPIRED_COLORWAY_THEME_ID2} 3.0.0`,
+ });
+
+ info("Switch back to list view");
+ await goBack(win);
+ assertAddonCardFound(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID1,
+ expectColorwayBuiltIn: false,
+ });
+ assertAddonCardFound(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID2,
+ expectColorwayBuiltIn: false,
+ });
+
+ Assert.deepEqual(
+ JSON.parse(
+ Services.prefs.getStringPref("browser.theme.retainedExpiredThemes")
+ ),
+ [],
+ "Migrated colorways theme have been removed from the retainedExpiredThemes pref"
+ );
+
+ await closeView(win);
+ await uninstallTestAddons();
+
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_colorways_builtin_theme_migration() {
+ await test_update_expired_colorways_builtins();
+});
+
+add_task(
+ async function test_colorways_builtin_theme_migration_on_disabledAutoUpdates() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.update.autoUpdateDefault", false]],
+ });
+
+ await test_update_expired_colorways_builtins();
+
+ await SpecialPowers.popPrefEnv();
+ }
+);
diff --git a/toolkit/mozapps/extensions/test/browser/browser_dragdrop.js b/toolkit/mozapps/extensions/test/browser/browser_dragdrop.js
new file mode 100644
index 0000000000..ae8625a18a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_dragdrop.js
@@ -0,0 +1,270 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const ABOUT_ADDONS_URL = "chrome://mozapps/content/extensions/aboutaddons.html";
+
+const dragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
+ Ci.nsIDragService
+);
+
+// Test that the drag-drop-addon-installer component installs add-ons and is
+// included in about:addons. There is an issue with EventUtils.synthesizeDrop
+// where it throws an exception when you give it an subbrowser so we test
+// the component directly.
+
+async function checkInstallConfirmation(...names) {
+ let notificationCount = 0;
+ let observer = {
+ observe(aSubject, aTopic, aData) {
+ let installInfo = aSubject.wrappedJSObject;
+ isnot(
+ installInfo.browser,
+ null,
+ "Notification should have non-null browser"
+ );
+
+ is(
+ installInfo.installs.length,
+ 1,
+ "Got one AddonInstall instance as expected"
+ );
+
+ Assert.deepEqual(
+ installInfo.installs[0].installTelemetryInfo,
+ { source: "about:addons", method: "drag-and-drop" },
+ "Got the expected installTelemetryInfo"
+ );
+
+ notificationCount++;
+ },
+ };
+ Services.obs.addObserver(observer, "addon-install-started");
+
+ let results = [];
+
+ let promise = promisePopupNotificationShown("addon-webext-permissions");
+ for (let i = 0; i < names.length; i++) {
+ let panel = await promise;
+ let name = panel.getAttribute("name");
+ results.push(name);
+
+ info(`Saw install for ${name}`);
+ if (results.length < names.length) {
+ info(
+ `Waiting for installs for ${names.filter(n => !results.includes(n))}`
+ );
+
+ promise = promisePopupNotificationShown("addon-webext-permissions");
+ }
+ panel.secondaryButton.click();
+ }
+
+ Assert.deepEqual(results.sort(), names.sort(), "Got expected installs");
+
+ is(
+ notificationCount,
+ names.length,
+ `Saw ${names.length} addon-install-started notification`
+ );
+ Services.obs.removeObserver(observer, "addon-install-started");
+}
+
+function getDragOverTarget(win) {
+ return win.document.querySelector("categories-box");
+}
+
+function getDropTarget(win) {
+ return win.document.querySelector("drag-drop-addon-installer");
+}
+
+function withTestPage(fn) {
+ return BrowserTestUtils.withNewTab(
+ { url: ABOUT_ADDONS_URL, gBrowser },
+ async browser => {
+ let win = browser.contentWindow;
+ await win.customElements.whenDefined("drag-drop-addon-installer");
+ await fn(browser);
+ }
+ );
+}
+
+function initDragSession({ dragData, dropEffect }) {
+ let dropAction;
+ switch (dropEffect) {
+ case null:
+ case undefined:
+ case "move":
+ dropAction = _EU_Ci.nsIDragService.DRAGDROP_ACTION_MOVE;
+ break;
+ case "copy":
+ dropAction = _EU_Ci.nsIDragService.DRAGDROP_ACTION_COPY;
+ break;
+ case "link":
+ dropAction = _EU_Ci.nsIDragService.DRAGDROP_ACTION_LINK;
+ break;
+ default:
+ throw new Error(`${dropEffect} is an invalid drop effect value`);
+ }
+
+ const dataTransfer = new DataTransfer();
+ dataTransfer.dropEffect = dropEffect;
+
+ for (let i = 0; i < dragData.length; i++) {
+ const item = dragData[i];
+ for (let j = 0; j < item.length; j++) {
+ dataTransfer.mozSetDataAt(item[j].type, item[j].data, i);
+ }
+ }
+
+ dragService.startDragSessionForTests(dropAction);
+ const session = dragService.getCurrentSession();
+ session.dataTransfer = dataTransfer;
+
+ return session;
+}
+
+async function simulateDragAndDrop(win, dragData) {
+ const dropTarget = getDropTarget(win);
+ const dragOverTarget = getDragOverTarget(win);
+ const dropEffect = "move";
+
+ const session = initDragSession({ dragData, dropEffect });
+
+ info("Simulate drag over and wait for the drop target to be visible");
+
+ EventUtils.synthesizeDragOver(
+ dragOverTarget,
+ dragOverTarget,
+ dragData,
+ dropEffect,
+ win
+ );
+
+ // This make sure that the fake dataTransfer has still
+ // the expected drop effect after the synthesizeDragOver call.
+ session.dataTransfer.dropEffect = "move";
+
+ await BrowserTestUtils.waitForCondition(
+ () => !dropTarget.hidden,
+ "Wait for the drop target element to be visible"
+ );
+
+ info("Simulate drop dragData on drop target");
+
+ EventUtils.synthesizeDropAfterDragOver(
+ null,
+ session.dataTransfer,
+ dropTarget,
+ win,
+ { _domDispatchOnly: true }
+ );
+
+ dragService.endDragSession(true);
+}
+
+// Simulates dropping a URL onto the manager
+add_task(async function test_drop_url() {
+ for (let fileType of ["xpi", "zip"]) {
+ await withTestPage(async browser => {
+ const url = TESTROOT + `addons/browser_dragdrop1.${fileType}`;
+ const promise = checkInstallConfirmation("Drag Drop test 1");
+
+ await simulateDragAndDrop(browser.contentWindow, [
+ [{ type: "text/x-moz-url", data: url }],
+ ]);
+
+ await promise;
+ });
+ }
+});
+
+// Simulates dropping a file onto the manager
+add_task(async function test_drop_file() {
+ for (let fileType of ["xpi", "zip"]) {
+ await withTestPage(async browser => {
+ let fileurl = get_addon_file_url(`browser_dragdrop1.${fileType}`);
+ let promise = checkInstallConfirmation("Drag Drop test 1");
+
+ await simulateDragAndDrop(browser.contentWindow, [
+ [{ type: "application/x-moz-file", data: fileurl.file }],
+ ]);
+
+ await promise;
+ });
+ }
+});
+
+// Simulates dropping two urls onto the manager
+add_task(async function test_drop_multiple_urls() {
+ await withTestPage(async browser => {
+ let url1 = TESTROOT + "addons/browser_dragdrop1.xpi";
+ let url2 = TESTROOT2 + "addons/browser_dragdrop2.zip";
+ let promise = checkInstallConfirmation(
+ "Drag Drop test 1",
+ "Drag Drop test 2"
+ );
+
+ await simulateDragAndDrop(browser.contentWindow, [
+ [{ type: "text/x-moz-url", data: url1 }],
+ [{ type: "text/x-moz-url", data: url2 }],
+ ]);
+
+ await promise;
+ });
+}).skip(); // TODO(rpl): this fails because mozSetDataAt throws IndexSizeError.
+
+// Simulates dropping two files onto the manager
+add_task(async function test_drop_multiple_files() {
+ await withTestPage(async browser => {
+ let fileurl1 = get_addon_file_url("browser_dragdrop1.zip");
+ let fileurl2 = get_addon_file_url("browser_dragdrop2.xpi");
+ let promise = checkInstallConfirmation(
+ "Drag Drop test 1",
+ "Drag Drop test 2"
+ );
+
+ await simulateDragAndDrop(browser.contentWindow, [
+ [{ type: "application/x-moz-file", data: fileurl1.file }],
+ [{ type: "application/x-moz-file", data: fileurl2.file }],
+ ]);
+
+ await promise;
+ });
+}).skip(); // TODO(rpl): this fails because mozSetDataAt throws IndexSizeError.
+
+// Simulates dropping a file and a url onto the manager (weird, but should still work)
+add_task(async function test_drop_file_and_url() {
+ await withTestPage(async browser => {
+ let url = TESTROOT + "addons/browser_dragdrop1.xpi";
+ let fileurl = get_addon_file_url("browser_dragdrop2.zip");
+ let promise = checkInstallConfirmation(
+ "Drag Drop test 1",
+ "Drag Drop test 2"
+ );
+
+ await simulateDragAndDrop(browser.contentWindow, [
+ [{ type: "text/x-moz-url", data: url }],
+ [{ type: "application/x-moz-file", data: fileurl.file }],
+ ]);
+
+ await promise;
+ });
+}).skip(); // TODO(rpl): this fails because mozSetDataAt throws IndexSizeError.
+
+// Test that drag-and-drop of an incompatible addon generates
+// an error.
+add_task(async function test_drop_incompat_file() {
+ await withTestPage(async browser => {
+ let url = `${TESTROOT}/addons/browser_dragdrop_incompat.xpi`;
+
+ let panelPromise = promisePopupNotificationShown("addon-install-failed");
+ await simulateDragAndDrop(browser.contentWindow, [
+ [{ type: "text/x-moz-url", data: url }],
+ ]);
+
+ let panel = await panelPromise;
+ ok(panel, "Got addon-install-failed popup");
+ panel.button.click();
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_file_xpi_no_process_switch.js b/toolkit/mozapps/extensions/test/browser/browser_file_xpi_no_process_switch.js
new file mode 100644
index 0000000000..6793363698
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_file_xpi_no_process_switch.js
@@ -0,0 +1,122 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+
+const ADDON_INSTALL_ID = "addon-webext-permissions";
+
+let fileurl1 = get_addon_file_url("browser_dragdrop1.xpi");
+let fileurl2 = get_addon_file_url("browser_dragdrop2.xpi");
+
+function promiseInstallNotification(aBrowser) {
+ return new Promise(resolve => {
+ function popupshown(event) {
+ let notification = PopupNotifications.getNotification(
+ ADDON_INSTALL_ID,
+ aBrowser
+ );
+ if (!notification) {
+ return;
+ }
+
+ if (gBrowser.selectedBrowser !== aBrowser) {
+ return;
+ }
+
+ PopupNotifications.panel.removeEventListener("popupshown", popupshown);
+ ok(true, `Got ${ADDON_INSTALL_ID} popup for browser`);
+ event.target.firstChild.secondaryButton.click();
+ resolve();
+ }
+
+ PopupNotifications.panel.addEventListener("popupshown", popupshown);
+ });
+}
+
+function CheckBrowserInPid(browser, expectedPid, message) {
+ return SpecialPowers.spawn(browser, [{ expectedPid, message }], arg => {
+ is(Services.appinfo.processID, arg.expectedPid, arg.message);
+ });
+}
+
+async function testOpenedAndDraggedXPI(aBrowser) {
+ // Get the current pid for browser for comparison later.
+ let browserPid = await SpecialPowers.spawn(aBrowser, [], () => {
+ return Services.appinfo.processID;
+ });
+
+ // No process switch for XPI file:// URI in the urlbar.
+ let promiseNotification = promiseInstallNotification(aBrowser);
+ let urlbar = gURLBar;
+ urlbar.value = fileurl1.spec;
+ urlbar.focus();
+ EventUtils.synthesizeKey("KEY_Enter");
+ await promiseNotification;
+ await CheckBrowserInPid(
+ aBrowser,
+ browserPid,
+ "Check that browser has not switched process."
+ );
+
+ // No process switch for XPI file:// URI dragged to tab.
+ let tab = gBrowser.getTabForBrowser(aBrowser);
+ promiseNotification = promiseInstallNotification(aBrowser);
+ let effect = EventUtils.synthesizeDrop(
+ tab,
+ tab,
+ [[{ type: "text/uri-list", data: fileurl1.spec }]],
+ "move"
+ );
+ is(effect, "move", "Drag should be accepted");
+ await promiseNotification;
+ await CheckBrowserInPid(
+ aBrowser,
+ browserPid,
+ "Check that browser has not switched process."
+ );
+
+ // No process switch for two XPI file:// URIs dragged to tab.
+ promiseNotification = promiseInstallNotification(aBrowser);
+ let promiseNewTab = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabOpen"
+ );
+ effect = EventUtils.synthesizeDrop(
+ tab,
+ tab,
+ [
+ [{ type: "text/uri-list", data: fileurl1.spec }],
+ [{ type: "text/uri-list", data: fileurl2.spec }],
+ ],
+ "move"
+ );
+ is(effect, "move", "Drag should be accepted");
+ // When drag'n'dropping two XPIs, one is loaded in the current tab while the
+ // other one is loaded in a new tab.
+ let { target: newTab } = await promiseNewTab;
+ // This is the prompt for the first XPI in the current tab.
+ await promiseNotification;
+
+ let promiseSecondNotification = promiseInstallNotification(
+ newTab.linkedBrowser
+ );
+
+ // We switch to the second tab and wait for the prompt for the second XPI.
+ BrowserTestUtils.switchTab(gBrowser, newTab);
+ await promiseSecondNotification;
+
+ BrowserTestUtils.removeTab(newTab);
+
+ await CheckBrowserInPid(
+ aBrowser,
+ browserPid,
+ "Check that browser has not switched process."
+ );
+}
+
+// Test for bug 1175267.
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(
+ "http://example.com",
+ testOpenedAndDraggedXPI
+ );
+ await BrowserTestUtils.withNewTab("about:robots", testOpenedAndDraggedXPI);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_globalwarnings.js b/toolkit/mozapps/extensions/test/browser/browser_globalwarnings.js
new file mode 100644
index 0000000000..a1402ef35f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_globalwarnings.js
@@ -0,0 +1,168 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Bug 566194 - safe mode / security & compatibility check status are not exposed in new addon manager UI
+
+async function loadDetail(win, id) {
+ let loaded = waitForViewLoad(win);
+ // Check the detail view.
+ let card = win.document.querySelector(`addon-card[addon-id="${id}"]`);
+ EventUtils.synthesizeMouseAtCenter(card, {}, win);
+ await loaded;
+}
+
+function checkMessageShown(win, type, hasButton) {
+ let stack = win.document.querySelector("global-warnings");
+ is(stack.childElementCount, 1, "There is one message");
+ let messageBar = stack.firstElementChild;
+ ok(messageBar, "There is a message bar");
+ is(messageBar.localName, "message-bar", "The message bar is a message-bar");
+ is_element_visible(messageBar, "Message bar is visible");
+ is(messageBar.getAttribute("warning-type"), type);
+ if (hasButton) {
+ let button = messageBar.querySelector("button");
+ is_element_visible(button, "Button is visible");
+ is(button.getAttribute("action"), type, "Button action is set");
+ }
+}
+
+function checkNoMessages(win) {
+ let stack = win.document.querySelector("global-warnings");
+ if (stack.childElementCount) {
+ // The safe mode message is hidden in CSS on the plugin list.
+ for (let child of stack.children) {
+ is_element_hidden(child, "The message is hidden");
+ }
+ } else {
+ is(stack.childElementCount, 0, "There are no message bars");
+ }
+}
+
+function clickMessageAction(win) {
+ let stack = win.document.querySelector("global-warnings");
+ let button = stack.firstElementChild.querySelector("button");
+ EventUtils.synthesizeMouseAtCenter(button, {}, win);
+}
+
+add_task(async function checkCompatibility() {
+ info("Testing compatibility checking warning");
+
+ info("Setting checkCompatibility to false");
+ AddonManager.checkCompatibility = false;
+
+ let id = "test@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { browser_specific_settings: { gecko: { id } } },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+
+ // Check the extension list view.
+ checkMessageShown(win, "check-compatibility", true);
+
+ // Check the detail view.
+ await loadDetail(win, id);
+ checkMessageShown(win, "check-compatibility", true);
+
+ // Check other views.
+ let views = ["plugin", "theme"];
+ for (let view of views) {
+ await switchView(win, view);
+ checkMessageShown(win, "check-compatibility", true);
+ }
+
+ // Check the button works.
+ info("Clicking 'Enable' button");
+ clickMessageAction(win);
+ is(
+ AddonManager.checkCompatibility,
+ true,
+ "Check Compatibility pref should be cleared"
+ );
+ checkNoMessages(win);
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function checkSecurity() {
+ info("Testing update security checking warning");
+
+ var pref = "extensions.checkUpdateSecurity";
+ info("Setting " + pref + " pref to false");
+ await SpecialPowers.pushPrefEnv({
+ set: [[pref, false]],
+ });
+
+ let id = "test-security@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { browser_specific_settings: { gecko: { id } } },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+
+ // Check extension list view.
+ checkMessageShown(win, "update-security", true);
+
+ // Check detail view.
+ await loadDetail(win, id);
+ checkMessageShown(win, "update-security", true);
+
+ // Check other views.
+ let views = ["plugin", "theme"];
+ for (let view of views) {
+ await switchView(win, view);
+ checkMessageShown(win, "update-security", true);
+ }
+
+ // Check the button works.
+ info("Clicking 'Enable' button");
+ clickMessageAction(win);
+ is(
+ Services.prefs.prefHasUserValue(pref),
+ false,
+ "Check Update Security pref should be cleared"
+ );
+ checkNoMessages(win);
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function checkSafeMode() {
+ info("Testing safe mode warning");
+
+ let id = "test-safemode@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { browser_specific_settings: { gecko: { id } } },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+
+ // Check extension list view hidden.
+ checkNoMessages(win);
+
+ let globalWarnings = win.document.querySelector("global-warnings");
+ globalWarnings.inSafeMode = true;
+ globalWarnings.refresh();
+
+ // Check detail view.
+ await loadDetail(win, id);
+ checkMessageShown(win, "safe-mode");
+
+ // Check other views.
+ await switchView(win, "theme");
+ checkMessageShown(win, "safe-mode");
+ await switchView(win, "plugin");
+ checkNoMessages(win);
+
+ await closeView(win);
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_gmpProvider.js b/toolkit/mozapps/extensions/test/browser/browser_gmpProvider.js
new file mode 100644
index 0000000000..74810ec666
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_gmpProvider.js
@@ -0,0 +1,384 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { GMPInstallManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/GMPInstallManager.sys.mjs"
+);
+const { GMPPrefs, GMP_PLUGIN_IDS, WIDEVINE_ID } = ChromeUtils.importESModule(
+ "resource://gre/modules/GMPUtils.sys.mjs"
+);
+
+const TEST_DATE = new Date(2013, 0, 1, 12);
+
+var gMockAddons = [];
+
+for (let pluginId of GMP_PLUGIN_IDS) {
+ let mockAddon = Object.freeze({
+ id: pluginId,
+ isValid: true,
+ isInstalled: false,
+ isEME: pluginId == WIDEVINE_ID,
+ });
+ gMockAddons.push(mockAddon);
+}
+
+var gInstalledAddonId = "";
+var gInstallDeferred = null;
+var gPrefs = Services.prefs;
+var getKey = GMPPrefs.getPrefKey;
+
+const MockGMPInstallManagerPrototype = {
+ checkForAddons: () =>
+ Promise.resolve({
+ usedFallback: true,
+ addons: gMockAddons,
+ }),
+
+ installAddon: addon => {
+ gInstalledAddonId = addon.id;
+ gInstallDeferred.resolve();
+ return Promise.resolve();
+ },
+};
+
+function openDetailsView(win, id) {
+ let item = getAddonCard(win, id);
+ Assert.ok(item, "Should have got add-on element.");
+ is_element_visible(item, "Add-on element should be visible.");
+
+ let loaded = waitForViewLoad(win);
+ EventUtils.synthesizeMouseAtCenter(item, {}, item.ownerGlobal);
+ return loaded;
+}
+
+add_task(async function initializeState() {
+ gPrefs.setBoolPref(GMPPrefs.KEY_LOGGING_DUMP, true);
+ gPrefs.setIntPref(GMPPrefs.KEY_LOGGING_LEVEL, 0);
+
+ registerCleanupFunction(async function () {
+ for (let addon of gMockAddons) {
+ gPrefs.clearUserPref(getKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id));
+ gPrefs.clearUserPref(getKey(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, addon.id));
+ gPrefs.clearUserPref(getKey(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, addon.id));
+ gPrefs.clearUserPref(getKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id));
+ gPrefs.clearUserPref(getKey(GMPPrefs.KEY_PLUGIN_VISIBLE, addon.id));
+ gPrefs.clearUserPref(
+ getKey(GMPPrefs.KEY_PLUGIN_FORCE_SUPPORTED, addon.id)
+ );
+ }
+ gPrefs.clearUserPref(GMPPrefs.KEY_LOGGING_DUMP);
+ gPrefs.clearUserPref(GMPPrefs.KEY_LOGGING_LEVEL);
+ gPrefs.clearUserPref(GMPPrefs.KEY_UPDATE_LAST_CHECK);
+ gPrefs.clearUserPref(GMPPrefs.KEY_EME_ENABLED);
+ });
+
+ // Start out with plugins not being installed, disabled and automatic updates
+ // disabled.
+ gPrefs.setBoolPref(GMPPrefs.KEY_EME_ENABLED, true);
+ for (let addon of gMockAddons) {
+ gPrefs.setBoolPref(getKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id), false);
+ gPrefs.setIntPref(getKey(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, addon.id), 0);
+ gPrefs.setBoolPref(getKey(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, addon.id), false);
+ gPrefs.setCharPref(getKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id), "");
+ gPrefs.setBoolPref(getKey(GMPPrefs.KEY_PLUGIN_VISIBLE, addon.id), true);
+ gPrefs.setBoolPref(
+ getKey(GMPPrefs.KEY_PLUGIN_FORCE_SUPPORTED, addon.id),
+ true
+ );
+ }
+});
+
+add_task(async function testNotInstalledDisabled() {
+ let win = await loadInitialView("extension");
+
+ Assert.ok(isCategoryVisible(win, "plugin"), "Plugin tab visible.");
+ await switchView(win, "plugin");
+
+ for (let addon of gMockAddons) {
+ let addonCard = getAddonCard(win, addon.id);
+ Assert.ok(addonCard, "Got add-on element:" + addon.id);
+
+ is(
+ addonCard.ownerDocument.l10n.getAttributes(addonCard.addonNameEl).id,
+ "addon-name-disabled",
+ "The addon name should include a disabled postfix"
+ );
+
+ let cardMessage = addonCard.querySelector("message-bar.addon-card-message");
+ is_element_hidden(cardMessage, "Warning notification is hidden");
+ }
+
+ await closeView(win);
+});
+
+add_task(async function testNotInstalledDisabledDetails() {
+ let win = await loadInitialView("plugin");
+
+ for (let addon of gMockAddons) {
+ await openDetailsView(win, addon.id);
+ let addonCard = getAddonCard(win, addon.id);
+ ok(addonCard, "Got add-on element: " + addon.id);
+
+ is(
+ win.document.l10n.getAttributes(addonCard.addonNameEl).id,
+ "addon-name-disabled",
+ "The addon name should include a disabled postfix"
+ );
+
+ let updatesBtn = addonCard.querySelector("[action=update-check]");
+ is_element_visible(updatesBtn, "Check for Updates action is visible");
+ let cardMessage = addonCard.querySelector("message-bar.addon-card-message");
+ is_element_hidden(cardMessage, "Warning notification is hidden");
+
+ await switchView(win, "plugin");
+ }
+
+ await closeView(win);
+});
+
+add_task(async function testNotInstalled() {
+ let win = await loadInitialView("plugin");
+
+ for (let addon of gMockAddons) {
+ gPrefs.setBoolPref(getKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id), true);
+ let item = getAddonCard(win, addon.id);
+ Assert.ok(item, "Got add-on element:" + addon.id);
+
+ let warningMessageBar = await BrowserTestUtils.waitForCondition(() => {
+ return item.querySelector("message-bar.addon-card-message[type=warning]");
+ }, "Wait for the addon card message to be updated");
+
+ is_element_visible(warningMessageBar, "Warning notification is visible");
+
+ is(item.parentNode.getAttribute("section"), "0", "Should be enabled");
+ // Open the options menu (needed to check the disabled buttons).
+ const pluginOptions = item.querySelector("plugin-options");
+ pluginOptions.querySelector("panel-list").open = true;
+ const alwaysActivate = pluginOptions.querySelector(
+ "panel-item[action=always-activate]"
+ );
+ ok(
+ alwaysActivate.hasAttribute("checked"),
+ "Plugin state should be always-activate"
+ );
+ pluginOptions.querySelector("panel-list").open = false;
+ }
+
+ await closeView(win);
+});
+
+add_task(async function testNotInstalledDetails() {
+ let win = await loadInitialView("plugin");
+
+ for (let addon of gMockAddons) {
+ await openDetailsView(win, addon.id);
+
+ const addonCard = getAddonCard(win, addon.id);
+ let el = addonCard.querySelector("[action=update-check]");
+ is_element_visible(el, "Check for Updates action is visible");
+
+ let warningMessageBar = await BrowserTestUtils.waitForCondition(() => {
+ return addonCard.querySelector(
+ "message-bar.addon-card-message[type=warning]"
+ );
+ }, "Wait for the addon card message to be updated");
+ is_element_visible(warningMessageBar, "Warning notification is visible");
+
+ await switchView(win, "plugin");
+ }
+
+ await closeView(win);
+});
+
+add_task(async function testInstalled() {
+ let win = await loadInitialView("plugin");
+
+ for (let addon of gMockAddons) {
+ gPrefs.setIntPref(
+ getKey(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, addon.id),
+ TEST_DATE.getTime()
+ );
+ gPrefs.setBoolPref(getKey(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, addon.id), false);
+ gPrefs.setCharPref(
+ getKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id),
+ "1.2.3.4"
+ );
+
+ let item = getAddonCard(win, addon.id);
+ Assert.ok(item, "Got add-on element.");
+
+ is(item.parentNode.getAttribute("section"), "0", "Should be enabled");
+ // Open the options menu (needed to check the disabled buttons).
+ const pluginOptions = item.querySelector("plugin-options");
+ pluginOptions.querySelector("panel-list").open = true;
+ const alwaysActivate = pluginOptions.querySelector(
+ "panel-item[action=always-activate]"
+ );
+ ok(
+ alwaysActivate.hasAttribute("checked"),
+ "Plugin state should be always-activate"
+ );
+ pluginOptions.querySelector("panel-list").open = false;
+ }
+
+ await closeView(win);
+});
+
+add_task(async function testInstalledDetails() {
+ let win = await loadInitialView("plugin");
+
+ for (let addon of gMockAddons) {
+ await openDetailsView(win, addon.id);
+
+ let card = getAddonCard(win, addon.id);
+ ok(card, "Got add-on element:" + addon.id);
+
+ is_element_visible(
+ card.querySelector("[action=update-check]"),
+ "Find updates link is visible"
+ );
+
+ await switchView(win, "plugin");
+ }
+
+ await closeView(win);
+});
+
+add_task(async function testInstalledGlobalEmeDisabled() {
+ let win = await loadInitialView("plugin");
+ gPrefs.setBoolPref(GMPPrefs.KEY_EME_ENABLED, false);
+
+ for (let addon of gMockAddons) {
+ let item = getAddonCard(win, addon.id);
+ if (addon.isEME) {
+ is(item.parentNode.getAttribute("section"), "1", "Should be disabled");
+ } else {
+ Assert.ok(item, "Got add-on element.");
+ }
+ }
+
+ gPrefs.setBoolPref(GMPPrefs.KEY_EME_ENABLED, true);
+ await closeView(win);
+});
+
+add_task(async function testPreferencesButton() {
+ let prefValues = [
+ { enabled: false, version: "" },
+ { enabled: false, version: "1.2.3.4" },
+ { enabled: true, version: "" },
+ { enabled: true, version: "1.2.3.4" },
+ ];
+
+ for (let preferences of prefValues) {
+ info(
+ "Testing preferences button with pref settings: " +
+ JSON.stringify(preferences)
+ );
+ for (let addon of gMockAddons) {
+ let win = await loadInitialView("plugin");
+ gPrefs.setCharPref(
+ getKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id),
+ preferences.version
+ );
+ gPrefs.setBoolPref(
+ getKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id),
+ preferences.enabled
+ );
+
+ let item = getAddonCard(win, addon.id);
+
+ // Open the options menu (needed to check the more options action is enabled).
+ const pluginOptions = item.querySelector("plugin-options");
+ pluginOptions.querySelector("panel-list").open = true;
+ const moreOptions = pluginOptions.querySelector(
+ "panel-item[action=expand]"
+ );
+ ok(
+ !moreOptions.shadowRoot.querySelector("button").disabled,
+ "more options action should be enabled"
+ );
+ moreOptions.click();
+
+ await waitForViewLoad(win);
+
+ item = getAddonCard(win, addon.id);
+ ok(item, "The right view is loaded");
+
+ await closeView(win);
+ }
+ }
+});
+
+add_task(async function testUpdateButton() {
+ gPrefs.clearUserPref(GMPPrefs.KEY_UPDATE_LAST_CHECK);
+
+ // The GMPInstallManager constructor has an empty body,
+ // so replacing the prototype is safe.
+ let originalInstallManager = GMPInstallManager.prototype;
+ GMPInstallManager.prototype = MockGMPInstallManagerPrototype;
+
+ let win = await loadInitialView("plugin");
+
+ for (let addon of gMockAddons) {
+ let item = getAddonCard(win, addon.id);
+
+ gInstalledAddonId = "";
+ gInstallDeferred = PromiseUtils.defer();
+
+ let loaded = waitForViewLoad(win);
+ item.querySelector("[action=expand]").click();
+ await loaded;
+ let detail = getAddonCard(win, addon.id);
+ detail.querySelector("[action=update-check]").click();
+
+ await gInstallDeferred.promise;
+ Assert.equal(gInstalledAddonId, addon.id);
+
+ await switchView(win, "plugin");
+ }
+
+ GMPInstallManager.prototype = originalInstallManager;
+
+ await closeView(win);
+});
+
+add_task(async function testEmeSupport() {
+ for (let addon of gMockAddons) {
+ gPrefs.clearUserPref(getKey(GMPPrefs.KEY_PLUGIN_FORCE_SUPPORTED, addon.id));
+ }
+
+ let win = await loadInitialView("plugin");
+
+ for (let addon of gMockAddons) {
+ let item = getAddonCard(win, addon.id);
+ if (addon.id == WIDEVINE_ID) {
+ if (
+ AppConstants.isPlatformAndVersionAtLeast("win", "6") ||
+ AppConstants.platform == "macosx" ||
+ AppConstants.platform == "linux"
+ ) {
+ Assert.ok(item, "Widevine supported, found add-on element.");
+ } else {
+ Assert.ok(
+ !item,
+ "Widevine not supported, couldn't find add-on element."
+ );
+ }
+ } else {
+ Assert.ok(item, "Found add-on element.");
+ }
+ }
+
+ await closeView(win);
+
+ for (let addon of gMockAddons) {
+ gPrefs.setBoolPref(getKey(GMPPrefs.KEY_PLUGIN_VISIBLE, addon.id), true);
+ gPrefs.setBoolPref(
+ getKey(GMPPrefs.KEY_PLUGIN_FORCE_SUPPORTED, addon.id),
+ true
+ );
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_history_navigation.js b/toolkit/mozapps/extensions/test/browser/browser_history_navigation.js
new file mode 100644
index 0000000000..a5a6de724c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_history_navigation.js
@@ -0,0 +1,617 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* eslint max-nested-callbacks: ["warn", 12] */
+
+/**
+ * Tests that history navigation works for the add-ons manager.
+ */
+
+// Request a longer timeout, because this tests run twice
+// (once on XUL views and once on the HTML views).
+requestLongerTimeout(4);
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const DISCOAPI_URL = `http://example.com/${RELATIVE_DIR}/discovery/api_response_empty.json`;
+
+SpecialPowers.pushPrefEnv({
+ set: [["browser.navigation.requireUserInteraction", false]],
+});
+
+var gProvider = new MockProvider();
+gProvider.createAddons([
+ {
+ id: "test1@tests.mozilla.org",
+ name: "Test add-on 1",
+ description: "foo",
+ },
+ {
+ id: "test2@tests.mozilla.org",
+ name: "Test add-on 2",
+ description: "bar",
+ },
+ {
+ id: "test3@tests.mozilla.org",
+ name: "Test add-on 3",
+ type: "theme",
+ description: "bar",
+ },
+]);
+
+function go_back() {
+ gBrowser.goBack();
+}
+
+const goBackKeyModifier =
+ AppConstants.platform == "macosx" ? { metaKey: true } : { altKey: true };
+
+function go_back_key() {
+ EventUtils.synthesizeKey("KEY_ArrowLeft", goBackKeyModifier);
+}
+
+function go_forward_key() {
+ EventUtils.synthesizeKey("KEY_ArrowRight", goBackKeyModifier);
+}
+
+function go_forward() {
+ gBrowser.goForward();
+}
+
+function check_state(canGoBack, canGoForward) {
+ is(gBrowser.canGoBack, canGoBack, "canGoBack should be correct");
+ is(gBrowser.canGoForward, canGoForward, "canGoForward should be correct");
+}
+
+function is_in_list(aManager, view, canGoBack, canGoForward) {
+ var categoryUtils = new CategoryUtilities(aManager);
+
+ is(
+ categoryUtils.getSelectedViewId(),
+ view,
+ "Should be on the right category"
+ );
+
+ ok(
+ aManager.document.querySelector("addon-list"),
+ "Got a list-view in about:addons"
+ );
+
+ check_state(canGoBack, canGoForward);
+}
+
+function is_in_detail(aManager, view, canGoBack, canGoForward) {
+ var categoryUtils = new CategoryUtilities(aManager);
+
+ is(
+ categoryUtils.getSelectedViewId(),
+ view,
+ "Should be on the right category"
+ );
+
+ is(
+ aManager.document.querySelectorAll("addon-card").length,
+ 1,
+ "Got a detail-view in about:addons"
+ );
+
+ check_state(canGoBack, canGoForward);
+}
+
+function is_in_discovery(aManager, canGoBack, canGoForward) {
+ ok(
+ aManager.document.querySelector("discovery-pane"),
+ "Got a discovery panel in the HTML about:addons browser"
+ );
+
+ check_state(canGoBack, canGoForward);
+}
+
+async function expand_addon_element(aManagerWin, aId) {
+ var addon = getAddonCard(aManagerWin, aId);
+ addon.click();
+}
+
+function wait_for_page_load(browser) {
+ return BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+}
+
+// Tests simple forward and back navigation and that the right heading and
+// category is selected
+add_task(async function test_navigate_history() {
+ let aManager = await open_manager("addons://list/extension");
+ let categoryUtils = new CategoryUtilities(aManager);
+ info("Part 1");
+ is_in_list(aManager, "addons://list/extension", false, false);
+
+ EventUtils.synthesizeMouseAtCenter(categoryUtils.get("plugin"), {}, aManager);
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 2");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ go_back();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 3");
+ is_in_list(aManager, "addons://list/extension", false, true);
+
+ go_forward();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 4");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ go_back();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 5");
+ is_in_list(aManager, "addons://list/extension", false, true);
+
+ await expand_addon_element(aManager, "test1@tests.mozilla.org");
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 6");
+ is_in_detail(aManager, "addons://list/extension", true, false);
+
+ go_back();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 7");
+ is_in_list(aManager, "addons://list/extension", false, true);
+
+ await close_manager(aManager);
+});
+
+// Tests that browsing to the add-ons manager from a website and going back works
+add_task(async function test_navigate_between_webpage_and_aboutaddons() {
+ info("Part 1");
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "http://example.com/",
+ true,
+ true
+ );
+
+ info("Part 2");
+ ok(!gBrowser.canGoBack, "Should not be able to go back");
+ ok(!gBrowser.canGoForward, "Should not be able to go forward");
+
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:addons");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ let manager = await wait_for_manager_load(
+ gBrowser.selectedBrowser.contentWindow
+ );
+
+ info("Part 3");
+ is_in_list(manager, "addons://list/extension", true, false);
+
+ // XXX: This is less than ideal, as it's currently difficult to deal with
+ // the browser frame switching between remote/non-remote in e10s mode.
+ let promiseLoaded;
+ if (gMultiProcessBrowser) {
+ promiseLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ } else {
+ promiseLoaded = BrowserTestUtils.waitForEvent(
+ gBrowser.selectedBrowser,
+ "pageshow"
+ );
+ }
+
+ go_back(manager);
+ await promiseLoaded;
+
+ info("Part 4");
+ is(
+ gBrowser.currentURI.spec,
+ "http://example.com/",
+ "Should be showing the webpage"
+ );
+ ok(!gBrowser.canGoBack, "Should not be able to go back");
+ ok(gBrowser.canGoForward, "Should be able to go forward");
+
+ promiseLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ go_forward(manager);
+ await promiseLoaded;
+
+ manager = gBrowser.selectedBrowser.contentWindow;
+ info("Part 5");
+ await TestUtils.waitForCondition(
+ () => manager.document.querySelector("addon-list"),
+ "The add-on list should render."
+ );
+
+ is_in_list(manager, "addons://list/extension", true, false);
+
+ await close_manager(manager);
+});
+
+// Tests simple forward and back navigation and that the right heading and
+// category is selected -- Keyboard navigation [Bug 565359]
+add_task(async function test_keyboard_history_navigation() {
+ let aManager = await open_manager("addons://list/extension");
+ let categoryUtils = new CategoryUtilities(aManager);
+ info("Part 1");
+ is_in_list(aManager, "addons://list/extension", false, false);
+
+ EventUtils.synthesizeMouseAtCenter(categoryUtils.get("plugin"), {}, aManager);
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 2");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ // Backspace should not navigate back. We should still be on the same view.
+ is(
+ Services.prefs.getIntPref("browser.backspace_action"),
+ 2,
+ "Backspace should not navigate back"
+ );
+ EventUtils.synthesizeKey("KEY_Backspace");
+ aManager = await wait_for_view_load(aManager);
+ info("Part 2b");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ go_back_key();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 3");
+ is_in_list(aManager, "addons://list/extension", false, true);
+
+ go_forward_key();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 4");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ go_back_key();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 5");
+ is_in_list(aManager, "addons://list/extension", false, true);
+
+ await expand_addon_element(aManager, "test1@tests.mozilla.org");
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 6");
+ is_in_detail(aManager, "addons://list/extension", true, false);
+
+ go_back_key();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 7");
+ is_in_list(aManager, "addons://list/extension", false, true);
+
+ await close_manager(aManager);
+});
+
+// Tests that opening a custom first view only stores a single history entry
+add_task(async function test_single_history_entry() {
+ let aManager = await open_manager("addons://list/plugin");
+ let categoryUtils = new CategoryUtilities(aManager);
+ info("Part 1");
+ is_in_list(aManager, "addons://list/plugin", false, false);
+
+ EventUtils.synthesizeMouseAtCenter(
+ categoryUtils.get("extension"),
+ {},
+ aManager
+ );
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 2");
+ is_in_list(aManager, "addons://list/extension", true, false);
+
+ go_back();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 3");
+ is_in_list(aManager, "addons://list/plugin", false, true);
+
+ await close_manager(aManager);
+});
+
+// Tests that opening a view while the manager is already open adds a new
+// history entry
+add_task(async function test_new_history_entry_while_opened() {
+ let aManager = await open_manager("addons://list/extension");
+ info("Part 1");
+ is_in_list(aManager, "addons://list/extension", false, false);
+
+ aManager.loadView("addons://list/plugin");
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 2");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ go_back();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 3");
+ is_in_list(aManager, "addons://list/extension", false, true);
+
+ go_forward();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 4");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ await close_manager(aManager);
+});
+
+// Tests than navigating to a website and then going back returns to the
+// previous view
+add_task(async function test_navigate_back_from_website() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["security.allow_eval_with_system_principal", true]],
+ });
+
+ let aManager = await open_manager("addons://list/plugin");
+ info("Part 1");
+ is_in_list(aManager, "addons://list/plugin", false, false);
+
+ BrowserTestUtils.loadURIString(gBrowser, "http://example.com/");
+ await wait_for_page_load(gBrowser.selectedBrowser);
+
+ info("Part 2");
+
+ await new Promise(resolve =>
+ executeSoon(function () {
+ ok(gBrowser.canGoBack, "Should be able to go back");
+ ok(!gBrowser.canGoForward, "Should not be able to go forward");
+
+ go_back();
+
+ gBrowser.addEventListener("pageshow", async function listener(event) {
+ if (event.target.location != "about:addons") {
+ return;
+ }
+ gBrowser.removeEventListener("pageshow", listener);
+
+ aManager = await wait_for_view_load(
+ gBrowser.contentWindow.wrappedJSObject
+ );
+ info("Part 3");
+ is_in_list(aManager, "addons://list/plugin", false, true);
+
+ executeSoon(() => go_forward());
+ wait_for_page_load(gBrowser.selectedBrowser).then(() => {
+ info("Part 4");
+
+ executeSoon(function () {
+ ok(gBrowser.canGoBack, "Should be able to go back");
+ ok(!gBrowser.canGoForward, "Should not be able to go forward");
+
+ go_back();
+
+ gBrowser.addEventListener(
+ "pageshow",
+ async function listener(event) {
+ if (event.target.location != "about:addons") {
+ return;
+ }
+ gBrowser.removeEventListener("pageshow", listener);
+ aManager = await wait_for_view_load(
+ gBrowser.contentWindow.wrappedJSObject
+ );
+ info("Part 5");
+ is_in_list(aManager, "addons://list/plugin", false, true);
+
+ resolve();
+ }
+ );
+ });
+ });
+ });
+ })
+ );
+
+ await close_manager(aManager);
+ await SpecialPowers.popPrefEnv();
+});
+
+// Tests that refreshing a list view does not affect the history
+add_task(async function test_refresh_listview_donot_add_history_entries() {
+ let aManager = await open_manager("addons://list/extension");
+ let categoryUtils = new CategoryUtilities(aManager);
+ info("Part 1");
+ is_in_list(aManager, "addons://list/extension", false, false);
+
+ EventUtils.synthesizeMouseAtCenter(categoryUtils.get("plugin"), {}, aManager);
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 2");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ await new Promise(resolve => {
+ gBrowser.reload();
+ gBrowser.addEventListener("pageshow", async function listener(event) {
+ if (event.target.location != "about:addons") {
+ return;
+ }
+ gBrowser.removeEventListener("pageshow", listener);
+
+ aManager = await wait_for_view_load(
+ gBrowser.contentWindow.wrappedJSObject
+ );
+ info("Part 3");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ go_back();
+ aManager = await wait_for_view_load(aManager);
+ info("Part 4");
+ is_in_list(aManager, "addons://list/extension", false, true);
+ resolve();
+ });
+ });
+
+ await close_manager(aManager);
+});
+
+// Tests that refreshing a detail view does not affect the history
+add_task(async function test_refresh_detailview_donot_add_history_entries() {
+ let aManager = await open_manager(null);
+ info("Part 1");
+ is_in_list(aManager, "addons://list/extension", false, false);
+
+ await expand_addon_element(aManager, "test1@tests.mozilla.org");
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 2");
+ is_in_detail(aManager, "addons://list/extension", true, false);
+
+ await new Promise(resolve => {
+ gBrowser.reload();
+ gBrowser.addEventListener("pageshow", async function listener(event) {
+ if (event.target.location != "about:addons") {
+ return;
+ }
+ gBrowser.removeEventListener("pageshow", listener);
+
+ aManager = await wait_for_view_load(
+ gBrowser.contentWindow.wrappedJSObject
+ );
+ info("Part 3");
+ is_in_detail(aManager, "addons://list/extension", true, false);
+
+ go_back();
+ aManager = await wait_for_view_load(aManager);
+ info("Part 4");
+ is_in_list(aManager, "addons://list/extension", false, true);
+ resolve();
+ });
+ });
+
+ await close_manager(aManager);
+});
+
+// Tests that removing an extension from the detail view goes back and doesn't
+// allow you to go forward again.
+add_task(async function test_history_on_detailview_extension_removed() {
+ let aManager = await open_manager("addons://list/extension");
+
+ info("Part 1");
+ is_in_list(aManager, "addons://list/extension", false, false);
+
+ await expand_addon_element(aManager, "test1@tests.mozilla.org");
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 2");
+ is_in_detail(aManager, "addons://list/extension", true, false);
+
+ const addonCard = aManager.document.querySelector(
+ 'addon-card[addon-id="test1@tests.mozilla.org"]'
+ );
+ const promptService = mockPromptService();
+ promptService._response = 0;
+ addonCard.querySelector("[action=remove]").click();
+
+ await wait_for_view_load(aManager);
+ await TestUtils.waitForCondition(
+ () => aManager.document.querySelector("addon-list"),
+ "The add-on list should render."
+ );
+ is_in_list(aManager, "addons://list/extension", true, false);
+
+ const addon = await AddonManager.getAddonByID("test1@tests.mozilla.org");
+ addon.cancelUninstall();
+
+ await close_manager(aManager);
+});
+
+// Tests that opening the manager opens the last view
+add_task(async function test_open_last_view() {
+ let aManager = await open_manager("addons://list/plugin");
+ info("Part 1");
+ is_in_list(aManager, "addons://list/plugin", false, false);
+
+ await close_manager(aManager);
+ aManager = await open_manager(null);
+ info("Part 2");
+ is_in_list(aManager, "addons://list/plugin", false, false);
+
+ await close_manager(aManager);
+});
+
+// Tests that navigating the discovery page works when that was the first view
+add_task(async function test_discopane_first_history_entry() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.getAddons.discovery.api_url", DISCOAPI_URL]],
+ });
+
+ let aManager = await open_manager("addons://discover/");
+ let categoryUtils = new CategoryUtilities(aManager);
+ info("1");
+ is_in_discovery(aManager, false, false);
+
+ EventUtils.synthesizeMouseAtCenter(categoryUtils.get("plugin"), {}, aManager);
+
+ aManager = await wait_for_view_load(aManager);
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ go_back();
+ aManager = await wait_for_view_load(aManager);
+
+ is_in_discovery(aManager, false, true);
+
+ await close_manager(aManager);
+});
+
+// Tests that navigating the discovery page works when that was the second view
+add_task(async function test_discopane_second_history_entry() {
+ let aManager = await open_manager("addons://list/plugin");
+ let categoryUtils = new CategoryUtilities(aManager);
+ is_in_list(aManager, "addons://list/plugin", false, false);
+
+ EventUtils.synthesizeMouseAtCenter(
+ categoryUtils.get("discover"),
+ {},
+ aManager
+ );
+
+ aManager = await wait_for_view_load(aManager);
+ is_in_discovery(aManager, true, false);
+
+ EventUtils.synthesizeMouseAtCenter(categoryUtils.get("plugin"), {}, aManager);
+
+ aManager = await wait_for_view_load(aManager);
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ go_back();
+
+ aManager = await wait_for_view_load(aManager);
+ is_in_discovery(aManager, true, true);
+
+ go_back();
+
+ aManager = await wait_for_view_load(aManager);
+ is_in_list(aManager, "addons://list/plugin", false, true);
+
+ await close_manager(aManager);
+});
+
+add_task(async function test_initialSelectedView_on_aboutaddons_reload() {
+ let managerWindow = await open_manager("addons://list/extension");
+ isnot(
+ managerWindow.gViewController.currentViewId,
+ null,
+ "Got a non null currentViewId on first load"
+ );
+
+ managerWindow.location.reload();
+ await wait_for_manager_load(managerWindow);
+ await wait_for_view_load(managerWindow);
+
+ isnot(
+ managerWindow.gViewController.currentViewId,
+ null,
+ "Got a non null currentViewId on reload"
+ );
+
+ await close_manager(managerWindow);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_abuse_report.js b/toolkit/mozapps/extensions/test/browser/browser_html_abuse_report.js
new file mode 100644
index 0000000000..b325f87511
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_abuse_report.js
@@ -0,0 +1,1029 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* eslint max-len: ["error", 80] */
+
+loadTestSubscript("head_abuse_report.js");
+
+add_setup(async function () {
+ await AbuseReportTestUtils.setup();
+});
+
+/**
+ * Base tests on abuse report panel webcomponents.
+ */
+
+// This test case verified that the abuse report panels contains a radio
+// button for all the expected "abuse report reasons", they are grouped
+// together under the same form field named "reason".
+add_task(async function test_abusereport_issuelist() {
+ const extension = await installTestExtension();
+
+ const abuseReportEl = await AbuseReportTestUtils.openReport(extension.id);
+
+ const reasonsPanel = abuseReportEl._reasonsPanel;
+ const radioButtons = reasonsPanel.querySelectorAll("[type=radio]");
+ const selectedRadios = reasonsPanel.querySelectorAll("[type=radio]:checked");
+
+ is(selectedRadios.length, 1, "Expect only one radio button selected");
+ is(
+ selectedRadios[0],
+ radioButtons[0],
+ "Expect the first radio button to be selected"
+ );
+
+ is(
+ abuseReportEl.reason,
+ radioButtons[0].value,
+ `The reason property has the expected value: ${radioButtons[0].value}`
+ );
+
+ const reasons = Array.from(radioButtons).map(el => el.value);
+ Assert.deepEqual(
+ reasons.sort(),
+ AbuseReportTestUtils.getReasons(abuseReportEl).sort(),
+ `Got a radio button for the expected reasons`
+ );
+
+ for (const radio of radioButtons) {
+ const reasonInfo = AbuseReportTestUtils.getReasonInfo(
+ abuseReportEl,
+ radio.value
+ );
+ const expectExampleHidden =
+ reasonInfo && reasonInfo.isExampleHidden("extension");
+ is(
+ radio.parentNode.querySelector(".reason-example").hidden,
+ expectExampleHidden,
+ `Got expected visibility on the example for reason "${radio.value}"`
+ );
+ }
+
+ info("Change the selected reason to " + radioButtons[3].value);
+ radioButtons[3].checked = true;
+ is(
+ abuseReportEl.reason,
+ radioButtons[3].value,
+ "The reason property has the expected value"
+ );
+
+ await extension.unload();
+ await closeAboutAddons();
+});
+
+// This test case verifies that the abuse report panel:
+// - switches from its "reasons list" mode to its "submit report" mode when the
+// "next" button is clicked
+// - goes back to the "reasons list" mode when the "go back" button is clicked
+// - the abuse report panel is closed when the "close" icon is clicked
+add_task(async function test_abusereport_submitpanel() {
+ const extension = await installTestExtension();
+
+ const abuseReportEl = await AbuseReportTestUtils.openReport(extension.id);
+
+ ok(
+ !abuseReportEl._reasonsPanel.hidden,
+ "The list of abuse reasons is the currently visible"
+ );
+ ok(
+ abuseReportEl._submitPanel.hidden,
+ "The submit panel is the currently hidden"
+ );
+
+ let onceUpdated = AbuseReportTestUtils.promiseReportUpdated(
+ abuseReportEl,
+ "submit"
+ );
+ await AbuseReportTestUtils.clickPanelButton(abuseReportEl._btnNext);
+ await onceUpdated;
+
+ onceUpdated = AbuseReportTestUtils.promiseReportUpdated(
+ abuseReportEl,
+ "reasons"
+ );
+ await AbuseReportTestUtils.clickPanelButton(abuseReportEl._btnGoBack);
+ await onceUpdated;
+
+ const onceReportClosed =
+ AbuseReportTestUtils.promiseReportClosed(abuseReportEl);
+ await AbuseReportTestUtils.clickPanelButton(abuseReportEl._btnCancel);
+ await onceReportClosed;
+
+ await extension.unload();
+ await closeAboutAddons();
+});
+
+// This test case verifies that the abuse report panel sends the expected data
+// in the "abuse-report:submit" event detail.
+add_task(async function test_abusereport_submit() {
+ // Reset the timestamp of the last report between tests.
+ AbuseReporter._lastReportTimestamp = null;
+ const extension = await installTestExtension();
+
+ const abuseReportEl = await AbuseReportTestUtils.openReport(extension.id);
+
+ ok(
+ !abuseReportEl._reasonsPanel.hidden,
+ "The list of abuse reasons is the currently visible"
+ );
+
+ let onceUpdated = AbuseReportTestUtils.promiseReportUpdated(
+ abuseReportEl,
+ "submit"
+ );
+ await AbuseReportTestUtils.clickPanelButton(abuseReportEl._btnNext);
+ await onceUpdated;
+
+ is(abuseReportEl.message, "", "The abuse report message is initially empty");
+
+ info("Test typing a message in the abuse report submit panel textarea");
+ const typedMessage = "Description of the extension abuse report";
+
+ EventUtils.synthesizeComposition(
+ {
+ data: typedMessage,
+ type: "compositioncommit",
+ },
+ abuseReportEl.ownerGlobal
+ );
+
+ is(
+ abuseReportEl.message,
+ typedMessage,
+ "Got the expected typed message in the abuse report"
+ );
+
+ const expectedDetail = {
+ addonId: extension.id,
+ };
+
+ const expectedReason = abuseReportEl.reason;
+ const expectedMessage = abuseReportEl.message;
+
+ function handleSubmitRequest({ request, response }) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/json", false);
+ response.write("{}");
+ }
+
+ let reportSubmitted;
+ const onReportSubmitted = AbuseReportTestUtils.promiseReportSubmitHandled(
+ ({ data, request, response }) => {
+ reportSubmitted = JSON.parse(data);
+ handleSubmitRequest({ request, response });
+ }
+ );
+
+ const onceReportClosed =
+ AbuseReportTestUtils.promiseReportClosed(abuseReportEl);
+
+ const onMessageBarsCreated = AbuseReportTestUtils.promiseMessageBars(2);
+
+ const onceSubmitEvent = BrowserTestUtils.waitForEvent(
+ abuseReportEl,
+ "abuse-report:submit"
+ );
+ await AbuseReportTestUtils.clickPanelButton(abuseReportEl._btnSubmit);
+ const submitEvent = await onceSubmitEvent;
+
+ const actualDetail = {
+ addonId: submitEvent.detail.addonId,
+ };
+ Assert.deepEqual(
+ actualDetail,
+ expectedDetail,
+ "Got the expected detail in the abuse-report:submit event"
+ );
+
+ ok(
+ submitEvent.detail.report,
+ "Got a report object in the abuse-report:submit event detail"
+ );
+
+ // Verify that, when the "abuse-report:submit" has been sent,
+ // the abuse report panel has been hidden, the report has been
+ // submitted and the expected message bar is created in the
+ // HTML about:addons page.
+ info("Wait the report to be submitted to the api server");
+ await onReportSubmitted;
+ info("Wait the report panel to be closed");
+ await onceReportClosed;
+
+ is(
+ reportSubmitted.addon,
+ ADDON_ID,
+ "Got the expected addon in the submitted report"
+ );
+ is(
+ reportSubmitted.reason,
+ expectedReason,
+ "Got the expected reason in the submitted report"
+ );
+ is(
+ reportSubmitted.message,
+ expectedMessage,
+ "Got the expected message in the submitted report"
+ );
+ is(
+ reportSubmitted.report_entry_point,
+ REPORT_ENTRY_POINT,
+ "Got the expected report_entry_point in the submitted report"
+ );
+
+ info("Waiting the expected message bars to be created");
+ const barDetails = await onMessageBarsCreated;
+ is(barDetails.length, 2, "Expect two message bars to have been created");
+ is(
+ barDetails[0].definitionId,
+ "submitting",
+ "Got a submitting message bar as expected"
+ );
+ is(
+ barDetails[1].definitionId,
+ "submitted",
+ "Got a submitted message bar as expected"
+ );
+
+ await extension.unload();
+ await closeAboutAddons();
+});
+
+// This helper does verify that the abuse report panel contains the expected
+// suggestions when the selected reason requires it (and urls are being set
+// on the links elements included in the suggestions when expected).
+async function test_abusereport_suggestions(addonId) {
+ const addon = await AddonManager.getAddonByID(addonId);
+
+ const abuseReportEl = await AbuseReportTestUtils.openReport(addonId);
+
+ const {
+ _btnNext,
+ _btnGoBack,
+ _reasonsPanel,
+ _submitPanel,
+ _submitPanel: { _suggestions },
+ } = abuseReportEl;
+
+ for (const reason of AbuseReportTestUtils.getReasons(abuseReportEl)) {
+ const reasonInfo = AbuseReportTestUtils.getReasonInfo(
+ abuseReportEl,
+ reason
+ );
+
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based
+ // implementation is also removed.
+ const addonType =
+ addon.type === "sitepermission-deprecated"
+ ? "sitepermission"
+ : addon.type;
+
+ if (reasonInfo.isReasonHidden(addonType)) {
+ continue;
+ }
+
+ info(`Test suggestions for abuse reason "${reason}"`);
+
+ // Select a reason with suggestions.
+ let radioEl = abuseReportEl.querySelector(`#abuse-reason-${reason}`);
+ ok(radioEl, `Found radio button for "${reason}"`);
+ radioEl.checked = true;
+
+ // Make sure the element localization is completed before
+ // checking the content isn't empty.
+ await document.l10n.translateFragment(radioEl);
+
+ // Verify each radio button has a non-empty localized string.
+ const localizedRadioContent = Array.from(
+ radioEl.closest("label").querySelectorAll("[data-l10n-id]")
+ ).filter(el => !el.hidden);
+
+ for (let el of localizedRadioContent) {
+ isnot(
+ el.textContent,
+ "",
+ `Fluent string id '${el.getAttribute("data-l10n-id")}' missing`
+ );
+ }
+
+ // Switch to the submit form with the current reason radio selected.
+ let oncePanelUpdated = AbuseReportTestUtils.promiseReportUpdated(
+ abuseReportEl,
+ "submit"
+ );
+ await AbuseReportTestUtils.clickPanelButton(_btnNext);
+ await oncePanelUpdated;
+
+ const localizedSuggestionsContent = Array.from(
+ _suggestions.querySelectorAll("[data-l10n-id]")
+ ).filter(el => !el.hidden);
+
+ is(
+ !_suggestions.hidden,
+ !!reasonInfo.hasSuggestions,
+ `Suggestions block has the expected visibility for "${reason}"`
+ );
+ if (reasonInfo.hasSuggestions) {
+ ok(
+ !!localizedSuggestionsContent.length,
+ `Category suggestions should not be empty for "${reason}"`
+ );
+ } else {
+ ok(
+ localizedSuggestionsContent.length === 0,
+ `Category suggestions should be empty for "${reason}"`
+ );
+ }
+
+ const extSupportLink = _suggestions.querySelector(
+ ".extension-support-link"
+ );
+ if (extSupportLink) {
+ is(
+ extSupportLink.getAttribute("href"),
+ BASE_TEST_MANIFEST.homepage_url,
+ "Got the expected extension-support-url"
+ );
+ }
+
+ const learnMoreLinks = [];
+ for (const linkClass of _suggestions.LEARNMORE_LINKS) {
+ learnMoreLinks.push(..._suggestions.querySelectorAll(linkClass));
+ }
+
+ if (learnMoreLinks.length) {
+ ok(
+ learnMoreLinks.every(el => el.getAttribute("target") === "_blank"),
+ "All the learn more links have target _blank"
+ );
+ ok(
+ learnMoreLinks.every(el => el.hasAttribute("href")),
+ "All the learn more links have a url set"
+ );
+ }
+
+ oncePanelUpdated = AbuseReportTestUtils.promiseReportUpdated(
+ abuseReportEl,
+ "reasons"
+ );
+ await AbuseReportTestUtils.clickPanelButton(_btnGoBack);
+ await oncePanelUpdated;
+ ok(!_reasonsPanel.hidden, "Reasons panel should be visible");
+ ok(_submitPanel.hidden, "Submit panel should be hidden");
+ }
+
+ await closeAboutAddons();
+}
+
+add_task(async function test_abusereport_suggestions_extension() {
+ const EXT_ID = "test-extension-suggestions@mochi.test";
+ const extension = await installTestExtension(EXT_ID);
+ await test_abusereport_suggestions(EXT_ID);
+ await extension.unload();
+});
+
+add_task(async function test_abusereport_suggestions_theme() {
+ const THEME_ID = "theme@mochi.test";
+ const theme = await installTestExtension(THEME_ID, "theme");
+ await test_abusereport_suggestions(THEME_ID);
+ await theme.unload();
+});
+
+// TODO(Bug 1789718): adapt to SitePermAddonProvider implementation.
+add_task(async function test_abusereport_suggestions_sitepermission() {
+ const SITEPERM_ADDON_ID = "webmidi@mochi.test";
+ const sitePermAddon = await installTestExtension(
+ SITEPERM_ADDON_ID,
+ "sitepermission-deprecated"
+ );
+ await test_abusereport_suggestions(SITEPERM_ADDON_ID);
+ await sitePermAddon.unload();
+});
+
+// This test case verifies the message bars created on other
+// scenarios (e.g. report creation and submissions errors).
+//
+// TODO(Bug 1789718): adapt to SitePermAddonProvider implementation.
+add_task(async function test_abusereport_messagebars() {
+ const EXT_ID = "test-extension-report@mochi.test";
+ const EXT_ID2 = "test-extension-report-2@mochi.test";
+ const THEME_ID = "test-theme-report@mochi.test";
+ const SITEPERM_ADDON_ID = "webmidi-report@mochi.test";
+ const extension = await installTestExtension(EXT_ID);
+ const extension2 = await installTestExtension(EXT_ID2);
+ const theme = await installTestExtension(THEME_ID, "theme");
+ const sitePermAddon = await installTestExtension(
+ SITEPERM_ADDON_ID,
+ "sitepermission-deprecated"
+ );
+
+ async function assertMessageBars(
+ expectedMessageBarIds,
+ testSetup,
+ testMessageBarDetails
+ ) {
+ await openAboutAddons();
+ const expectedLength = expectedMessageBarIds.length;
+ const onMessageBarsCreated =
+ AbuseReportTestUtils.promiseMessageBars(expectedLength);
+ // Reset the timestamp of the last report between tests.
+ AbuseReporter._lastReportTimestamp = null;
+ await testSetup();
+ info(`Waiting for ${expectedLength} message-bars to be created`);
+ const barDetails = await onMessageBarsCreated;
+ Assert.deepEqual(
+ barDetails.map(d => d.definitionId),
+ expectedMessageBarIds,
+ "Got the expected message bars"
+ );
+ if (testMessageBarDetails) {
+ await testMessageBarDetails(barDetails);
+ }
+ await closeAboutAddons();
+ }
+
+ function setTestRequestHandler(responseStatus, responseData) {
+ AbuseReportTestUtils.promiseReportSubmitHandled(({ request, response }) => {
+ response.setStatusLine(request.httpVersion, responseStatus, "Error");
+ response.write(responseData);
+ });
+ }
+
+ await assertMessageBars(["ERROR_ADDON_NOTFOUND"], async () => {
+ info("Test message bars on addon not found");
+ AbuseReportTestUtils.triggerNewReport(
+ "non-existend-addon-id@mochi.test",
+ REPORT_ENTRY_POINT
+ );
+ });
+
+ await assertMessageBars(["submitting", "ERROR_RECENT_SUBMIT"], async () => {
+ info("Test message bars on recent submission");
+ const promiseRendered = AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerNewReport(EXT_ID, REPORT_ENTRY_POINT);
+ await promiseRendered;
+ AbuseReporter.updateLastReportTimestamp();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ });
+
+ await assertMessageBars(["submitting", "ERROR_ABORTED_SUBMIT"], async () => {
+ info("Test message bars on aborted submission");
+ AbuseReportTestUtils.triggerNewReport(EXT_ID, REPORT_ENTRY_POINT);
+ await AbuseReportTestUtils.promiseReportRendered();
+ const { _report } = AbuseReportTestUtils.getReportPanel();
+ _report.abort();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ });
+
+ await assertMessageBars(["submitting", "ERROR_SERVER"], async () => {
+ info("Test message bars on server error");
+ setTestRequestHandler(500);
+ AbuseReportTestUtils.triggerNewReport(EXT_ID, REPORT_ENTRY_POINT);
+ await AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ });
+
+ await assertMessageBars(["submitting", "ERROR_CLIENT"], async () => {
+ info("Test message bars on client error");
+ setTestRequestHandler(400);
+ AbuseReportTestUtils.triggerNewReport(EXT_ID, REPORT_ENTRY_POINT);
+ await AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ });
+
+ await assertMessageBars(["submitting", "ERROR_UNKNOWN"], async () => {
+ info("Test message bars on unexpected status code");
+ setTestRequestHandler(604);
+ AbuseReportTestUtils.triggerNewReport(EXT_ID, REPORT_ENTRY_POINT);
+ await AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ });
+
+ await assertMessageBars(["submitting", "ERROR_UNKNOWN"], async () => {
+ info("Test message bars on invalid json in the response data");
+ setTestRequestHandler(200, "");
+ AbuseReportTestUtils.triggerNewReport(EXT_ID, REPORT_ENTRY_POINT);
+ await AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ });
+
+ // Verify message bar on add-on without perm_can_uninstall.
+ await assertMessageBars(
+ ["submitting", "submitted-no-remove-action"],
+ async () => {
+ info("Test message bars on report submitted on an addon without remove");
+ setTestRequestHandler(200, "{}");
+ AbuseReportTestUtils.triggerNewReport(THEME_NO_UNINSTALL_ID, "menu");
+ await AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ }
+ );
+
+ // Verify the 3 expected entry points:
+ // menu, toolbar_context_menu and uninstall
+ // (See https://addons-server.readthedocs.io/en/latest/topics/api/abuse.html).
+ await assertMessageBars(["submitting", "submitted"], async () => {
+ info("Test message bars on report opened from addon options menu");
+ setTestRequestHandler(200, "{}");
+ AbuseReportTestUtils.triggerNewReport(EXT_ID, "menu");
+ await AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ });
+
+ for (const extId of [EXT_ID, THEME_ID]) {
+ await assertMessageBars(
+ ["submitting", "submitted"],
+ async () => {
+ info(`Test message bars on ${extId} reported from toolbar contextmenu`);
+ setTestRequestHandler(200, "{}");
+ AbuseReportTestUtils.triggerNewReport(extId, "toolbar_context_menu");
+ await AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ },
+ ([submittingDetails, submittedDetails]) => {
+ const buttonsL10nId = Array.from(
+ submittedDetails.messagebar.querySelectorAll("button")
+ ).map(el => el.getAttribute("data-l10n-id"));
+ if (extId === THEME_ID) {
+ ok(
+ buttonsL10nId.every(id => id.endsWith("-theme")),
+ "submitted bar actions should use the Fluent id for themes"
+ );
+ } else {
+ ok(
+ buttonsL10nId.every(id => id.endsWith("-extension")),
+ "submitted bar actions should use the Fluent id for extensions"
+ );
+ }
+ }
+ );
+ }
+
+ for (const extId of [EXT_ID2, THEME_ID, SITEPERM_ADDON_ID]) {
+ const testFn = async () => {
+ info(`Test message bars on ${extId} reported opened from addon removal`);
+ setTestRequestHandler(200, "{}");
+ AbuseReportTestUtils.triggerNewReport(extId, "uninstall");
+ await AbuseReportTestUtils.promiseReportRendered();
+ const addon = await AddonManager.getAddonByID(extId);
+ // Ensure that the test extension is pending uninstall as it would be
+ // when a user trigger this scenario on an actual addon uninstall.
+ await addon.uninstall(true);
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ };
+ const assertMessageBarDetails = async ([
+ submittingDetails,
+ submittedDetails,
+ ]) => AbuseReportTestUtils.assertFluentStrings(submittedDetails.messagebar);
+ await assertMessageBars(
+ ["submitting", "submitted-and-removed"],
+ testFn,
+ assertMessageBarDetails
+ );
+ }
+
+ // Verify message bar on sitepermission add-on type.
+ await assertMessageBars(
+ ["submitting", "submitted"],
+ async () => {
+ info(
+ "Test message bars for report submitted on an sitepermission addon type"
+ );
+ setTestRequestHandler(200, "{}");
+ AbuseReportTestUtils.triggerNewReport(SITEPERM_ADDON_ID, "menu");
+ await AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ },
+ ([submittingDetails, submittedDetails]) =>
+ AbuseReportTestUtils.assertFluentStrings(submittedDetails.messagebar)
+ );
+
+ await extension.unload();
+ await extension2.unload();
+ await theme.unload();
+ await sitePermAddon.unload();
+});
+
+add_task(async function test_abusereport_from_aboutaddons_menu() {
+ const EXT_ID = "test-report-from-aboutaddons-menu@mochi.test";
+ const extension = await installTestExtension(EXT_ID);
+
+ await openAboutAddons();
+
+ AbuseReportTestUtils.assertReportPanelHidden();
+
+ const addonCard = gManagerWindow.document.querySelector(
+ `addon-list addon-card[addon-id="${extension.id}"]`
+ );
+ ok(addonCard, "Got the addon-card for the test extension");
+
+ const reportButton = addonCard.querySelector("[action=report]");
+ ok(reportButton, "Got the report action for the test extension");
+
+ info("Click the report action and wait for the 'abuse-report:new' event");
+
+ let onceReportOpened = AbuseReportTestUtils.promiseReportOpened({
+ addonId: extension.id,
+ reportEntryPoint: "menu",
+ });
+ reportButton.click();
+ const panelEl = await onceReportOpened;
+
+ await AbuseReportTestUtils.closeReportPanel(panelEl);
+
+ await closeAboutAddons();
+ await extension.unload();
+});
+
+add_task(async function test_abusereport_from_aboutaddons_remove() {
+ const EXT_ID = "test-report-from-aboutaddons-remove@mochi.test";
+
+ // Test on a theme addon to cover the report checkbox included in the
+ // uninstall dialog also on a theme.
+ const extension = await installTestExtension(EXT_ID, "theme");
+
+ await openAboutAddons("theme");
+
+ AbuseReportTestUtils.assertReportPanelHidden();
+
+ const addonCard = gManagerWindow.document.querySelector(
+ `addon-list addon-card[addon-id="${extension.id}"]`
+ );
+ ok(addonCard, "Got the addon-card for the test theme extension");
+
+ const removeButton = addonCard.querySelector("[action=remove]");
+ ok(removeButton, "Got the remove action for the test theme extension");
+
+ // Prepare the mocked prompt service.
+ const promptService = mockPromptService();
+ promptService.confirmEx = createPromptConfirmEx({
+ remove: true,
+ report: true,
+ });
+
+ info("Click the report action and wait for the 'abuse-report:new' event");
+
+ const onceReportOpened = AbuseReportTestUtils.promiseReportOpened({
+ addonId: extension.id,
+ reportEntryPoint: "uninstall",
+ });
+ removeButton.click();
+ const panelEl = await onceReportOpened;
+
+ await AbuseReportTestUtils.closeReportPanel(panelEl);
+
+ await closeAboutAddons();
+ await extension.unload();
+});
+
+add_task(async function test_abusereport_from_browserAction_remove() {
+ const EXT_ID = "test-report-from-browseraction-remove@mochi.test";
+ const xpiFile = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ ...BASE_TEST_MANIFEST,
+ browser_action: {
+ default_area: "navbar",
+ },
+ browser_specific_settings: { gecko: { id: EXT_ID } },
+ },
+ });
+ const addon = await AddonManager.installTemporaryAddon(xpiFile);
+
+ const buttonId = `${makeWidgetId(EXT_ID)}-browser-action`;
+
+ async function promiseAnimationFrame() {
+ await new Promise(resolve => window.requestAnimationFrame(resolve));
+
+ let { tm } = Services;
+ return new Promise(resolve => tm.dispatchToMainThread(resolve));
+ }
+
+ async function reportFromContextMenuRemove() {
+ const menu = document.getElementById("toolbar-context-menu");
+ const node = document.getElementById(CSS.escape(buttonId));
+ const shown = BrowserTestUtils.waitForEvent(
+ menu,
+ "popupshown",
+ "Wair for contextmenu popup"
+ );
+
+ // Wait for an animation frame as we do for the other mochitest-browser
+ // tests related to the browserActions.
+ await promiseAnimationFrame();
+ EventUtils.synthesizeMouseAtCenter(node, { type: "contextmenu" });
+ await shown;
+
+ info(`Clicking on "Remove Extension" context menu item`);
+ let removeExtension = menu.querySelector(
+ ".customize-context-removeExtension"
+ );
+ removeExtension.click();
+
+ return menu;
+ }
+
+ // Prepare the mocked prompt service.
+ const promptService = mockPromptService();
+ promptService.confirmEx = createPromptConfirmEx({
+ remove: true,
+ report: true,
+ });
+
+ await BrowserTestUtils.withNewTab("about:blank", async function () {
+ info(`Open browserAction context menu in toolbar context menu`);
+ let promiseMenu = reportFromContextMenuRemove();
+
+ // Wait about:addons to be loaded.
+ let browser = gBrowser.selectedBrowser;
+ await BrowserTestUtils.browserLoaded(browser);
+
+ let onceReportOpened = AbuseReportTestUtils.promiseReportOpened({
+ addonId: EXT_ID,
+ reportEntryPoint: "uninstall",
+ managerWindow: browser.contentWindow,
+ });
+
+ is(
+ browser.currentURI.spec,
+ "about:addons",
+ "about:addons tab currently selected"
+ );
+
+ let menu = await promiseMenu;
+ menu.hidePopup();
+
+ let panelEl = await onceReportOpened;
+
+ await AbuseReportTestUtils.closeReportPanel(panelEl);
+
+ let onceExtStarted = AddonTestUtils.promiseWebExtensionStartup(EXT_ID);
+ addon.cancelUninstall();
+ await onceExtStarted;
+
+ // Reload the tab to verify Bug 1559124 didn't regressed.
+ browser.contentWindow.location.reload();
+ await BrowserTestUtils.browserLoaded(browser);
+ is(
+ browser.currentURI.spec,
+ "about:addons",
+ "about:addons tab currently selected"
+ );
+
+ onceReportOpened = AbuseReportTestUtils.promiseReportOpened({
+ addonId: EXT_ID,
+ reportEntryPoint: "uninstall",
+ managerWindow: browser.contentWindow,
+ });
+
+ menu = await reportFromContextMenuRemove();
+ info("Wait for the report panel");
+ panelEl = await onceReportOpened;
+
+ info("Wait for the report panel to be closed");
+ await AbuseReportTestUtils.closeReportPanel(panelEl);
+
+ menu.hidePopup();
+
+ onceExtStarted = AddonTestUtils.promiseWebExtensionStartup(EXT_ID);
+ addon.cancelUninstall();
+ await onceExtStarted;
+ });
+
+ await addon.uninstall();
+});
+
+/*
+ * Test report action hidden on non-supported extension types.
+ */
+add_task(async function test_report_action_hidden_on_builtin_addons() {
+ await openAboutAddons("theme");
+ await AbuseReportTestUtils.assertReportActionHidden(
+ gManagerWindow,
+ DEFAULT_BUILTIN_THEME_ID
+ );
+ await closeAboutAddons();
+});
+
+add_task(async function test_report_action_hidden_on_system_addons() {
+ await openAboutAddons("extension");
+ await AbuseReportTestUtils.assertReportActionHidden(
+ gManagerWindow,
+ EXT_SYSTEM_ADDON_ID
+ );
+ await closeAboutAddons();
+});
+
+add_task(async function test_report_action_hidden_on_dictionary_addons() {
+ await openAboutAddons("dictionary");
+ await AbuseReportTestUtils.assertReportActionHidden(
+ gManagerWindow,
+ EXT_DICTIONARY_ADDON_ID
+ );
+ await closeAboutAddons();
+});
+
+add_task(async function test_report_action_hidden_on_langpack_addons() {
+ await openAboutAddons("locale");
+ await AbuseReportTestUtils.assertReportActionHidden(
+ gManagerWindow,
+ EXT_LANGPACK_ADDON_ID
+ );
+ await closeAboutAddons();
+});
+
+// This test verifies that triggering a report that would be immediately
+// cancelled (e.g. because abuse reports for that extension type are not
+// supported) the abuse report is being hidden as expected.
+add_task(async function test_report_hidden_on_report_unsupported_addontype() {
+ await openAboutAddons();
+
+ let onceCreateReportFailed = AbuseReportTestUtils.promiseMessageBars(1);
+
+ AbuseReportTestUtils.triggerNewReport(EXT_UNSUPPORTED_TYPE_ADDON_ID, "menu");
+
+ await onceCreateReportFailed;
+
+ ok(!AbuseReporter.getOpenDialog(), "report dialog should not be open");
+
+ await closeAboutAddons();
+});
+
+/*
+ * Test regression fixes.
+ */
+
+add_task(async function test_no_broken_suggestion_on_missing_supportURL() {
+ const EXT_ID = "test-no-author@mochi.test";
+ const extension = await installTestExtension(EXT_ID, "extension", {
+ homepage_url: undefined,
+ });
+
+ const abuseReportEl = await AbuseReportTestUtils.openReport(EXT_ID);
+
+ info("Select broken as the abuse reason");
+ abuseReportEl.querySelector("#abuse-reason-broken").checked = true;
+
+ let oncePanelUpdated = AbuseReportTestUtils.promiseReportUpdated(
+ abuseReportEl,
+ "submit"
+ );
+ await AbuseReportTestUtils.clickPanelButton(abuseReportEl._btnNext);
+ await oncePanelUpdated;
+
+ const suggestionEl = abuseReportEl.querySelector(
+ "abuse-report-reason-suggestions"
+ );
+ is(suggestionEl.reason, "broken", "Got the expected suggestion element");
+ ok(suggestionEl.hidden, "suggestion element should be empty");
+
+ await closeAboutAddons();
+ await extension.unload();
+});
+
+// This test verify that the abuse report panel is opening the
+// author link using a null triggeringPrincipal.
+add_task(async function test_abusereport_open_author_url() {
+ const abuseReportEl = await AbuseReportTestUtils.openReport(
+ EXT_WITH_PRIVILEGED_URL_ID
+ );
+
+ const authorLink = abuseReportEl._linkAddonAuthor;
+ ok(authorLink, "Got the author link element");
+ is(
+ authorLink.href,
+ "about:config",
+ "Got a privileged url in the link element"
+ );
+
+ SimpleTest.waitForExplicitFinish();
+ let waitForConsole = new Promise(resolve => {
+ SimpleTest.monitorConsole(resolve, [
+ {
+ message:
+ // eslint-disable-next-line max-len
+ /Security Error: Content at moz-nullprincipal:{.*} may not load or link to about:config/,
+ },
+ ]);
+ });
+
+ let tabSwitched = BrowserTestUtils.waitForEvent(gBrowser, "TabSwitchDone");
+ authorLink.click();
+ await tabSwitched;
+
+ is(
+ gBrowser.selectedBrowser.currentURI.spec,
+ "about:blank",
+ "Got about:blank loaded in the new tab"
+ );
+
+ SimpleTest.endMonitorConsole();
+ await waitForConsole;
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await closeAboutAddons();
+});
+
+add_task(async function test_no_report_checkbox_for_unsupported_addon_types() {
+ async function test_report_checkbox_hidden(addon) {
+ await openAboutAddons(addon.type);
+
+ const addonCard = gManagerWindow.document.querySelector(
+ `addon-list addon-card[addon-id="${addon.id}"]`
+ );
+ ok(addonCard, "Got the addon-card for the test extension");
+
+ const removeButton = addonCard.querySelector("[action=remove]");
+ ok(removeButton, "Got the remove action for the test extension");
+
+ // Prepare the mocked prompt service.
+ const promptService = mockPromptService();
+ promptService.confirmEx = createPromptConfirmEx({
+ remove: true,
+ report: false,
+ expectCheckboxHidden: true,
+ });
+
+ info("Click the report action and wait for the addon to be removed");
+ const promiseCardRemoved = BrowserTestUtils.waitForEvent(
+ addonCard.closest("addon-list"),
+ "remove"
+ );
+ removeButton.click();
+ await promiseCardRemoved;
+
+ await closeAboutAddons();
+ }
+
+ const reportNotSupportedAddons = [
+ {
+ id: "fake-langpack-to-remove@mochi.test",
+ name: "This is a fake langpack",
+ version: "1.1",
+ type: "locale",
+ },
+ {
+ id: "fake-dictionary-to-remove@mochi.test",
+ name: "This is a fake dictionary",
+ version: "1.1",
+ type: "dictionary",
+ },
+ ];
+
+ AbuseReportTestUtils.createMockAddons(reportNotSupportedAddons);
+
+ for (const { id } of reportNotSupportedAddons) {
+ const addon = await AddonManager.getAddonByID(id);
+ await test_report_checkbox_hidden(addon);
+ }
+});
+
+add_task(async function test_author_hidden_when_missing() {
+ const EXT_ID = "test-no-author@mochi.test";
+ const extension = await installTestExtension(EXT_ID, "extension", {
+ author: undefined,
+ });
+
+ const abuseReportEl = await AbuseReportTestUtils.openReport(EXT_ID);
+
+ const addon = await AddonManager.getAddonByID(EXT_ID);
+
+ ok(!addon.creator, "addon.creator should not be undefined");
+ ok(
+ abuseReportEl._addonAuthorContainer.hidden,
+ "author container should be hidden"
+ );
+
+ await closeAboutAddons();
+ await extension.unload();
+});
+
+// Verify addon.siteOrigin is used as a fallback when homepage_url/developer.url
+// or support url are missing.
+//
+// TODO(Bug 1789718): adapt to SitePermAddonProvider implementation.
+add_task(async function test_siteperm_siteorigin_fallback() {
+ const SITEPERM_ADDON_ID = "webmidi-site-origin@mochi.test";
+ const sitePermAddon = await installTestExtension(
+ SITEPERM_ADDON_ID,
+ "sitepermission-deprecated",
+ {
+ homepage_url: undefined,
+ }
+ );
+
+ const abuseReportEl = await AbuseReportTestUtils.openReport(
+ SITEPERM_ADDON_ID
+ );
+ const addon = await AddonManager.getAddonByID(SITEPERM_ADDON_ID);
+
+ ok(addon.siteOrigin, "addon.siteOrigin should not be undefined");
+ ok(!addon.supportURL, "addon.supportURL should not be set");
+ ok(!addon.homepageURL, "addon.homepageURL should not be set");
+ is(
+ abuseReportEl.supportURL,
+ addon.siteOrigin,
+ "Got the expected support_url"
+ );
+
+ await closeAboutAddons();
+ await sitePermAddon.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_abuse_report_dialog.js b/toolkit/mozapps/extensions/test/browser/browser_html_abuse_report_dialog.js
new file mode 100644
index 0000000000..1d62bf265f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_abuse_report_dialog.js
@@ -0,0 +1,178 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* eslint max-len: ["error", 80] */
+
+loadTestSubscript("head_abuse_report.js");
+
+add_setup(async function () {
+ await AbuseReportTestUtils.setup();
+});
+
+/**
+ * Test tasks specific to the abuse report opened in its own dialog window.
+ */
+
+add_task(async function test_close_icon_button_hidden_when_dialog() {
+ const addonId = "addon-to-report@mochi.test";
+ const extension = await installTestExtension(addonId);
+
+ const reportDialog = await AbuseReporter.openDialog(
+ addonId,
+ "menu",
+ gBrowser.selectedBrowser
+ );
+ await AbuseReportTestUtils.promiseReportDialogRendered();
+
+ const panelEl = await reportDialog.promiseReportPanel;
+
+ let promiseClosedWindow = waitClosedWindow();
+
+ EventUtils.synthesizeKey("VK_RETURN", {}, panelEl.ownerGlobal);
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+
+ await promiseClosedWindow;
+ ok(
+ await reportDialog.promiseReport,
+ "expect the report to not be cancelled by pressing enter"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_report_triggered_when_report_dialog_is_open() {
+ const addonId = "addon-to-report@mochi.test";
+ const extension = await installTestExtension(addonId);
+
+ const reportDialog = await AbuseReporter.openDialog(
+ addonId,
+ "menu",
+ gBrowser.selectedBrowser
+ );
+ await AbuseReportTestUtils.promiseReportDialogRendered();
+
+ let promiseClosedWindow = waitClosedWindow();
+
+ const reportDialog2 = await AbuseReporter.openDialog(
+ addonId,
+ "menu",
+ gBrowser.selectedBrowser
+ );
+
+ await promiseClosedWindow;
+
+ // Trigger the report submit and check that the second report is
+ // resolved as expected.
+ await AbuseReportTestUtils.promiseReportDialogRendered();
+
+ ok(
+ !reportDialog.window || reportDialog.window.closed,
+ "expect the first dialog to be closed"
+ );
+ ok(!!reportDialog2.window, "expect the second dialog to be open");
+
+ is(
+ reportDialog2.window,
+ AbuseReportTestUtils.getReportDialog(),
+ "Got a report dialog as expected"
+ );
+
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+
+ // promiseReport is resolved to undefined if the report has been
+ // cancelled, otherwise it is resolved to a report object.
+ ok(
+ !(await reportDialog.promiseReport),
+ "expect the first report to be cancelled"
+ );
+ ok(
+ !!(await reportDialog2.promiseReport),
+ "expect the second report to be resolved"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_report_dialog_window_closed_by_user() {
+ const addonId = "addon-to-report@mochi.test";
+ const extension = await installTestExtension(addonId);
+
+ const reportDialog = await AbuseReporter.openDialog(
+ addonId,
+ "menu",
+ gBrowser.selectedBrowser
+ );
+ await AbuseReportTestUtils.promiseReportDialogRendered();
+
+ let promiseClosedWindow = waitClosedWindow();
+
+ reportDialog.close();
+
+ await promiseClosedWindow;
+
+ ok(
+ !(await reportDialog.promiseReport),
+ "expect promiseReport to be resolved as user cancelled"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_amo_details_for_not_installed_addon() {
+ const addonId = "not-installed-addon@mochi.test";
+ const fakeAMODetails = {
+ name: "fake name",
+ current_version: { version: "1.0" },
+ type: "extension",
+ icon_url: "http://test.addons.org/asserts/fake-icon-url.png",
+ homepage: "http://fake.url/homepage",
+ support_url: "http://fake.url/support",
+ authors: [
+ { name: "author1", url: "http://fake.url/author1" },
+ { name: "author2", url: "http://fake.url/author2" },
+ ],
+ is_recommended: true,
+ };
+
+ AbuseReportTestUtils.amoAddonDetailsMap.set(addonId, fakeAMODetails);
+ registerCleanupFunction(() =>
+ AbuseReportTestUtils.amoAddonDetailsMap.clear()
+ );
+
+ const reportDialog = await AbuseReporter.openDialog(
+ addonId,
+ "menu",
+ gBrowser.selectedBrowser
+ );
+
+ const reportEl = await reportDialog.promiseReportPanel;
+
+ // Assert that the panel has been able to retrieve from AMO
+ // all the addon details needed to render the panel correctly.
+ is(reportEl.addonId, addonId, "Got the expected addonId");
+ is(reportEl.addonName, fakeAMODetails.name, "Got the expected addon name");
+ is(reportEl.addonType, fakeAMODetails.type, "Got the expected addon type");
+ is(
+ reportEl.authorName,
+ fakeAMODetails.authors[0].name,
+ "Got the first author name as expected"
+ );
+ is(
+ reportEl.authorURL,
+ fakeAMODetails.authors[0].url,
+ "Got the first author url as expected"
+ );
+ is(reportEl.iconURL, fakeAMODetails.icon_url, "Got the expected icon url");
+ is(
+ reportEl.supportURL,
+ fakeAMODetails.support_url,
+ "Got the expected support url"
+ );
+ is(
+ reportEl.homepageURL,
+ fakeAMODetails.homepage,
+ "Got the expected homepage url"
+ );
+
+ reportDialog.close();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_detail_permissions.js b/toolkit/mozapps/extensions/test/browser/browser_html_detail_permissions.js
new file mode 100644
index 0000000000..80a697b9f3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_detail_permissions.js
@@ -0,0 +1,826 @@
+/* eslint max-len: ["error", 80] */
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+const { PERMISSION_L10N, PERMISSION_L10N_ID_OVERRIDES } =
+ ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissionMessages.sys.mjs"
+ );
+
+AddonTestUtils.initMochitest(this);
+
+async function background() {
+ browser.permissions.onAdded.addListener(perms => {
+ browser.test.sendMessage("permission-added", perms);
+ });
+ browser.permissions.onRemoved.addListener(perms => {
+ browser.test.sendMessage("permission-removed", perms);
+ });
+}
+
+async function getExtensions({ manifest_version = 2 } = {}) {
+ let extensions = {
+ "addon0@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 0",
+ browser_specific_settings: { gecko: { id: "addon0@mochi.test" } },
+ permissions: ["alarms", "contextMenus"],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "addon1@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 1",
+ browser_specific_settings: { gecko: { id: "addon1@mochi.test" } },
+ permissions: ["alarms", "contextMenus", "tabs", "webNavigation"],
+ // Note: for easier testing, we merge host_permissions into permissions
+ // when loading mv2 extensions, see ExtensionTestCommon.generateFiles.
+ host_permissions: ["<all_urls>", "file://*/*"],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "addon2@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 2",
+ browser_specific_settings: { gecko: { id: "addon2@mochi.test" } },
+ permissions: ["alarms", "contextMenus"],
+ optional_permissions: ["http://mochi.test/*"],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "addon3@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 3",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: "addon3@mochi.test" } },
+ permissions: ["tabs"],
+ optional_permissions: ["webNavigation", "<all_urls>"],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "addon4@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 4",
+ browser_specific_settings: { gecko: { id: "addon4@mochi.test" } },
+ optional_permissions: ["tabs", "webNavigation"],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "addon5@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 5",
+ browser_specific_settings: { gecko: { id: "addon5@mochi.test" } },
+ optional_permissions: ["*://*/*"],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "priv6@mochi.test": ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+ manifest: {
+ manifest_version,
+ name: "Privileged add-on 6",
+ browser_specific_settings: { gecko: { id: "priv6@mochi.test" } },
+ optional_permissions: [
+ "file://*/*",
+ "about:reader*",
+ "resource://pdf.js/*",
+ "*://*.mozilla.com/*",
+ "*://*/*",
+ "<all_urls>",
+ ],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "addon7@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 7",
+ browser_specific_settings: { gecko: { id: "addon7@mochi.test" } },
+ optional_permissions: ["<all_urls>", "https://*/*", "file://*/*"],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "addon8@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 8",
+ browser_specific_settings: { gecko: { id: "addon8@mochi.test" } },
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ optional_permissions: ["https://*/*", "http://*/*", "file://*/*"],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "other@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 6",
+ browser_specific_settings: { gecko: { id: "other@mochi.test" } },
+ optional_permissions: [
+ "tabs",
+ "webNavigation",
+ "<all_urls>",
+ "*://*/*",
+ ],
+ },
+ useAddonManager: "temporary",
+ }),
+ };
+ for (let ext of Object.values(extensions)) {
+ await ext.startup();
+ }
+ return extensions;
+}
+
+async function runTest(options) {
+ let {
+ extension,
+ addonId,
+ permissions = [],
+ optional_permissions = [],
+ optional_overlapping = [],
+ optional_enabled = [],
+ // Map<permission->string> to check optional_permissions against, if set.
+ optional_strings = {},
+ view,
+ } = options;
+ if (extension) {
+ addonId = extension.id;
+ }
+
+ let win = view || (await loadInitialView("extension"));
+
+ let card = getAddonCard(win, addonId);
+ let permsSection = card.querySelector("addon-permissions-list");
+ if (!permsSection) {
+ ok(!card.hasAttribute("expanded"), "The list card is not expanded");
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+ }
+
+ card = getAddonCard(win, addonId);
+ let { deck, tabGroup } = card.details;
+
+ let permsBtn = tabGroup.querySelector('[name="permissions"]');
+ let permsShown = BrowserTestUtils.waitForEvent(deck, "view-changed");
+ permsBtn.click();
+ await permsShown;
+
+ permsSection = card.querySelector("addon-permissions-list");
+
+ let rows = Array.from(permsSection.querySelectorAll(".addon-detail-row"));
+ let permission_rows = Array.from(
+ permsSection.querySelectorAll(".permission-info")
+ );
+
+ // Last row is the learn more link.
+ info("Check learn more link");
+ let link = rows[rows.length - 1].firstElementChild;
+ let rootUrl = Services.urlFormatter.formatURLPref("app.support.baseURL");
+ let url = rootUrl + "extension-permissions";
+ is(link.href, url, "The URL is set");
+ is(link.getAttribute("target"), "_blank", "The link opens in a new tab");
+
+ // We should have one more row (learn more) that the combined permissions,
+ // or if no permissions, 2 rows.
+ let num_permissions = permissions.length + optional_permissions.length;
+ is(
+ permission_rows.length,
+ num_permissions,
+ "correct number of details rows are present"
+ );
+
+ info("Check displayed permissions");
+ if (!num_permissions) {
+ is(
+ win.document.l10n.getAttributes(rows[0]).id,
+ "addon-permissions-empty",
+ "There's a message when no permissions are shown"
+ );
+ }
+ if (permissions.length) {
+ for (let name of permissions) {
+ // Check the permission-info class to make sure it's for a permission.
+ let row = permission_rows.shift();
+ ok(
+ row.classList.contains("permission-info"),
+ `required permission row for ${name}`
+ );
+ }
+ }
+
+ let addon = await AddonManager.getAddonByID(addonId);
+ info(`addon ${addon.id} is ${addon.userDisabled ? "disabled" : "enabled"}`);
+
+ function waitForPermissionChange(id) {
+ return new Promise(resolve => {
+ info(`listening for change on ${id}`);
+ let listener = (type, data) => {
+ info(`change permissions ${JSON.stringify(data)}`);
+ if (data.extensionId !== id) {
+ return;
+ }
+ ExtensionPermissions.removeListener(listener);
+ resolve(data);
+ };
+ ExtensionPermissions.addListener(listener);
+ });
+ }
+
+ // This tests the permission change and button state when the user
+ // changes the state in about:addons.
+ async function testTogglePermissionButton(
+ permissions,
+ button,
+ excpectDisabled = false
+ ) {
+ let enabled = permissions.some(perm => optional_enabled.includes(perm));
+ if (excpectDisabled) {
+ enabled = !enabled;
+ }
+ is(
+ button.pressed,
+ enabled,
+ `permission is set correctly for ${permissions}: ${button.pressed}`
+ );
+ let change;
+ if (addon.userDisabled || !extension) {
+ change = waitForPermissionChange(addonId);
+ } else if (!enabled) {
+ change = extension.awaitMessage("permission-added");
+ } else {
+ change = extension.awaitMessage("permission-removed");
+ }
+
+ button.click();
+
+ let perms = await change;
+ if (addon.userDisabled || !extension) {
+ perms = enabled ? perms.removed : perms.added;
+ }
+
+ ok(
+ perms.permissions.length + perms.origins.length > 0,
+ "Some permission(s) toggled."
+ );
+
+ if (perms.permissions.length) {
+ // Only check api permissions against the first passed permission,
+ // because we treat <all_urls> as an api permission, but not *://*/*.
+ is(perms.permissions.length, 1, "A single api permission toggled.");
+ is(perms.permissions[0], permissions[0], "Correct api permission.");
+ }
+ if (perms.origins.length) {
+ Assert.deepEqual(
+ perms.origins.slice().sort(),
+ permissions.slice().sort(),
+ "Toggled origin permission."
+ );
+ }
+
+ await BrowserTestUtils.waitForCondition(async () => {
+ return button.pressed == !enabled;
+ }, "button changed state");
+ }
+
+ // This tests that the button changes state if the permission is
+ // changed outside of about:addons
+ async function testExternalPermissionChange(permission, button) {
+ let enabled = button.pressed;
+ let type = button.getAttribute("permission-type");
+ let change;
+ if (addon.userDisabled || !extension) {
+ change = waitForPermissionChange(addonId);
+ } else if (!enabled) {
+ change = extension.awaitMessage("permission-added");
+ } else {
+ change = extension.awaitMessage("permission-removed");
+ }
+
+ let permissions = { permissions: [], origins: [] };
+ if (type == "origin") {
+ permissions.origins = [permission];
+ } else {
+ permissions.permissions = [permission];
+ }
+
+ if (enabled) {
+ await ExtensionPermissions.remove(addonId, permissions);
+ } else {
+ await ExtensionPermissions.add(addonId, permissions);
+ }
+
+ let perms = await change;
+ if (addon.userDisabled || !extension) {
+ perms = enabled ? perms.removed : perms.added;
+ }
+ ok(
+ perms.permissions.includes(permission) ||
+ perms.origins.includes(permission),
+ "permission was toggled"
+ );
+
+ await BrowserTestUtils.waitForCondition(async () => {
+ return button.pressed == !enabled;
+ }, "button changed state");
+ }
+
+ // This tests that changing the permission on another addon does
+ // not change the UI for the addon we're testing.
+ async function testOtherPermissionChange(permission, toggle) {
+ let type = toggle.getAttribute("permission-type");
+ let otherId = "other@mochi.test";
+ let change = waitForPermissionChange(otherId);
+ let perms = await ExtensionPermissions.get(otherId);
+ let existing = type == "origin" ? perms.origins : perms.permissions;
+ let permissions = { permissions: [], origins: [] };
+ if (type == "origin") {
+ permissions.origins = [permission];
+ } else {
+ permissions.permissions = [permission];
+ }
+
+ if (existing.includes(permission)) {
+ await ExtensionPermissions.remove(otherId, permissions);
+ } else {
+ await ExtensionPermissions.add(otherId, permissions);
+ }
+ await change;
+ }
+
+ if (optional_permissions.length) {
+ for (let name of optional_permissions) {
+ // Set of permissions represented by this key.
+ let perms = [name];
+ if (name === optional_overlapping[0]) {
+ perms = optional_overlapping;
+ }
+
+ // Check the row is a permission row with the correct key on the toggle
+ // control.
+ let row = permission_rows.shift();
+ let toggle = row.querySelector("moz-toggle");
+ let label = toggle.labelEl;
+
+ let str = optional_strings[name];
+ if (str) {
+ is(label.textContent.trim(), str, `Expected permission string ${str}`);
+ }
+
+ ok(
+ row.classList.contains("permission-info"),
+ `optional permission row for ${name}`
+ );
+ is(
+ toggle.getAttribute("permission-key"),
+ name,
+ `optional permission toggle exists for ${name}`
+ );
+
+ await testTogglePermissionButton(perms, toggle);
+ await testTogglePermissionButton(perms, toggle, true);
+
+ for (let perm of perms) {
+ // make a change "outside" the UI and check the values.
+ // toggle twice to test both add/remove.
+ await testExternalPermissionChange(perm, toggle);
+ // change another addon to mess around with optional permission
+ // values to see if it effects the addon we're testing here. The
+ // next check would fail if anything bleeds onto other addons.
+ await testOtherPermissionChange(perm, toggle);
+ // repeat the "outside" test.
+ await testExternalPermissionChange(perm, toggle);
+ }
+ }
+ }
+
+ if (!view) {
+ await closeView(win);
+ }
+}
+
+async function testPermissionsView({ manifestV3enabled, manifest_version }) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.manifestV3.enabled", manifestV3enabled]],
+ });
+
+ // pre-set a permission prior to starting extensions.
+ await ExtensionPermissions.add("addon4@mochi.test", {
+ permissions: ["tabs"],
+ origins: [],
+ });
+
+ let extensions = await getExtensions({ manifest_version });
+
+ info("Check add-on with required permissions");
+ if (manifest_version < 3) {
+ await runTest({
+ extension: extensions["addon1@mochi.test"],
+ permissions: ["<all_urls>", "tabs", "webNavigation"],
+ });
+ } else {
+ await runTest({
+ extension: extensions["addon1@mochi.test"],
+ permissions: ["tabs", "webNavigation"],
+ optional_permissions: ["<all_urls>"],
+ });
+ }
+
+ info("Check add-on without any displayable permissions");
+ await runTest({ extension: extensions["addon0@mochi.test"] });
+
+ info("Check add-on with only one optional origin.");
+ await runTest({
+ extension: extensions["addon2@mochi.test"],
+ optional_permissions: manifestV3enabled ? ["http://mochi.test/*"] : [],
+ optional_strings: {
+ "http://mochi.test/*": "Access your data for http://mochi.test",
+ },
+ });
+
+ info("Check add-on with both required and optional permissions");
+ await runTest({
+ extension: extensions["addon3@mochi.test"],
+ permissions: ["tabs"],
+ optional_permissions: ["webNavigation", "<all_urls>"],
+ });
+
+ // Grant a specific optional host permission not listed in the manifest.
+ await ExtensionPermissions.add("addon3@mochi.test", {
+ permissions: [],
+ origins: ["https://example.com/*"],
+ });
+ await extensions["addon3@mochi.test"].awaitMessage("permission-added");
+
+ info("Check addon3 again and expect the new optional host permission");
+ await runTest({
+ extension: extensions["addon3@mochi.test"],
+ permissions: ["tabs"],
+ optional_permissions: [
+ "webNavigation",
+ "<all_urls>",
+ ...(manifestV3enabled ? ["https://example.com/*"] : []),
+ ],
+ optional_enabled: ["https://example.com/*"],
+ optional_strings: {
+ "https://example.com/*": "Access your data for https://example.com",
+ },
+ });
+
+ info("Check add-on with only optional permissions, tabs is pre-enabled");
+ await runTest({
+ extension: extensions["addon4@mochi.test"],
+ optional_permissions: ["tabs", "webNavigation"],
+ optional_enabled: ["tabs"],
+ });
+
+ info("Check add-on with a global match pattern in place of all urls");
+ await runTest({
+ extension: extensions["addon5@mochi.test"],
+ optional_permissions: ["*://*/*"],
+ });
+
+ info("Check privileged add-on with non-web origin permissions");
+ await runTest({
+ extension: extensions["priv6@mochi.test"],
+ optional_permissions: [
+ "<all_urls>",
+ ...(manifestV3enabled ? ["*://*.mozilla.com/*"] : []),
+ ],
+ optional_overlapping: ["<all_urls>", "*://*/*"],
+ optional_strings: {
+ "*://*.mozilla.com/*":
+ "Access your data for sites in the *://mozilla.com domain",
+ },
+ });
+
+ info(`Check that <all_urls> is used over other "all websites" permissions`);
+ await runTest({
+ extension: extensions["addon7@mochi.test"],
+ optional_permissions: ["<all_urls>"],
+ optional_overlapping: ["<all_urls>", "https://*/*"],
+ });
+
+ info(`Also check different "all sites" permissions in the manifest`);
+ await runTest({
+ extension: extensions["addon8@mochi.test"],
+ optional_permissions: ["https://*/*"],
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ optional_overlapping: ["https://*/*", "http://*/*"],
+ });
+
+ for (let ext of Object.values(extensions)) {
+ await ext.unload();
+ }
+
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function testPermissionsView_MV2_manifestV3disabled() {
+ await testPermissionsView({ manifestV3enabled: false, manifest_version: 2 });
+});
+
+add_task(async function testPermissionsView_MV2_manifestV3enabled() {
+ await testPermissionsView({ manifestV3enabled: true, manifest_version: 2 });
+});
+
+add_task(async function testPermissionsView_MV3() {
+ await testPermissionsView({ manifestV3enabled: true, manifest_version: 3 });
+});
+
+add_task(async function testPermissionsViewStates() {
+ let ID = "addon@mochi.test";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test add-on 3",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID } },
+ permissions: ["tabs"],
+ optional_permissions: ["webNavigation", "<all_urls>"],
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+
+ info(
+ "Check toggling permissions on a disabled addon with addon3@mochi.test."
+ );
+ let view = await loadInitialView("extension");
+ let addon = await AddonManager.getAddonByID(ID);
+ await addon.disable();
+ ok(addon.userDisabled, "addon is disabled");
+ await runTest({
+ extension,
+ permissions: ["tabs"],
+ optional_permissions: ["webNavigation", "<all_urls>"],
+ view,
+ });
+ await addon.enable();
+ ok(!addon.userDisabled, "addon is enabled");
+
+ async function install_addon(extensionData) {
+ let xpi = await AddonTestUtils.createTempWebExtensionFile(extensionData);
+ let { addon } = await AddonTestUtils.promiseInstallFile(xpi);
+ return addon;
+ }
+
+ function wait_for_addon_item_updated(addonId) {
+ return BrowserTestUtils.waitForEvent(getAddonCard(view, addonId), "update");
+ }
+
+ let promiseItemUpdated = wait_for_addon_item_updated(ID);
+ addon = await install_addon({
+ manifest: {
+ name: "Test add-on 3",
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: ID } },
+ optional_permissions: ["webNavigation"],
+ },
+ useAddonManager: "permanent",
+ });
+ is(addon.version, "2.0", "addon upgraded");
+ await promiseItemUpdated;
+
+ await runTest({
+ addonId: addon.id,
+ optional_permissions: ["webNavigation"],
+ view,
+ });
+
+ // While the view is still available, test setting a permission
+ // that is not in the manifest of the addon.
+ let card = getAddonCard(view, addon.id);
+ await Assert.rejects(
+ card.setAddonPermission("webRequest", "permission", "add"),
+ /permission missing from manifest/,
+ "unable to set the addon permission"
+ );
+
+ await closeView(view);
+ await extension.unload();
+});
+
+add_task(async function testAllUrlsNotGrantedUnconditionally_MV3() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.manifestV3.enabled", true]],
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ host_permissions: ["<all_urls>"],
+ },
+ async background() {
+ const perms = await browser.permissions.getAll();
+ browser.test.sendMessage("granted-permissions", perms);
+ },
+ });
+
+ await extension.startup();
+ const perms = await extension.awaitMessage("granted-permissions");
+ ok(
+ !perms.origins.includes("<all_urls>"),
+ "Optional <all_urls> should not be granted as host permission yet"
+ );
+ ok(
+ !perms.permissions.includes("<all_urls>"),
+ "Optional <all_urls> should not be granted as an API permission neither"
+ );
+
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_OneOfMany_AllSites_toggle() {
+ // ESLint autofix will silently convert http://*/* match patterns into https.
+ /* eslint-disable @microsoft/sdl/no-insecure-url */
+ let id = "addon9@mochi.test";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test add-on 9",
+ browser_specific_settings: { gecko: { id } },
+ optional_permissions: ["http://*/*", "https://*/*"],
+ },
+ background,
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+
+ // Grant the second "all sites" permission as listed in the manifest.
+ await ExtensionPermissions.add("addon9@mochi.test", {
+ permissions: [],
+ origins: ["https://*/*"],
+ });
+ await extension.awaitMessage("permission-added");
+
+ let view = await loadInitialView("extension");
+ let addon = await AddonManager.getAddonByID(id);
+
+ let card = getAddonCard(view, addon.id);
+
+ let permsSection = card.querySelector("addon-permissions-list");
+ if (!permsSection) {
+ ok(!card.hasAttribute("expanded"), "The list card is not expanded");
+ let loaded = waitForViewLoad(view);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+ }
+
+ card = getAddonCard(view, addon.id);
+ let { deck, tabGroup } = card.details;
+
+ let permsBtn = tabGroup.querySelector('[name="permissions"]');
+ let permsShown = BrowserTestUtils.waitForEvent(deck, "view-changed");
+ permsBtn.click();
+ await permsShown;
+
+ permsSection = card.querySelector("addon-permissions-list");
+ let permission_rows = permsSection.querySelectorAll(".permission-info");
+ is(permission_rows.length, 1, "Only one 'all sites' permission toggle.");
+
+ let row = permission_rows[0];
+ let toggle = row.querySelector("moz-toggle");
+ ok(
+ row.classList.contains("permission-info"),
+ `optional permission row for "http://*/*"`
+ );
+ is(
+ toggle.getAttribute("permission-key"),
+ "http://*/*",
+ `optional permission toggle exists for "http://*/*"`
+ );
+ ok(toggle.pressed, "Expect 'all sites' toggle to be set.");
+
+ // Revoke the second "all sites" permission, expect toggle to be unchecked.
+ await ExtensionPermissions.remove("addon9@mochi.test", {
+ permissions: [],
+ origins: ["https://*/*"],
+ });
+ await extension.awaitMessage("permission-removed");
+ ok(!toggle.pressed, "Expect 'all sites' toggle not to be pressed.");
+
+ toggle.click();
+
+ let granted = await extension.awaitMessage("permission-added");
+ Assert.deepEqual(granted, {
+ permissions: [],
+ origins: ["http://*/*", "https://*/*"],
+ });
+
+ await closeView(view);
+ await extension.unload();
+ /* eslint-enable @microsoft/sdl/no-insecure-url */
+});
+
+add_task(async function testOverrideLocalization() {
+ // Mock a fluent file.
+ const l10nReg = L10nRegistry.getInstance();
+ const source = L10nFileSource.createMock(
+ "mock",
+ "app",
+ ["en-US"],
+ "/localization/",
+ [
+ {
+ path: "/localization/mock.ftl",
+ source: `
+webext-perms-description-test-tabs = Custom description for the tabs permission
+`,
+ },
+ ]
+ );
+ l10nReg.registerSources([source]);
+
+ // Add the mocked fluent file to PERMISSION_L10N and override the tabs
+ // permission to use the alternative string. In a real world use-case, this
+ // would be used to add non-toolkit fluent files with permission strings of
+ // APIs which are defined outside of toolkit.
+ PERMISSION_L10N.addResourceIds(["mock.ftl"]);
+ PERMISSION_L10N_ID_OVERRIDES.set(
+ "tabs",
+ "webext-perms-description-test-tabs"
+ );
+
+ let mockCleanup = () => {
+ // Make sure cleanup is executed only once.
+ mockCleanup = () => {};
+
+ // Remove the non-toolkit permission string.
+ PERMISSION_L10N.removeResourceIds(["mock.ftl"]);
+ PERMISSION_L10N_ID_OVERRIDES.delete("tabs");
+ l10nReg.removeSources(["mock"]);
+ };
+ registerCleanupFunction(mockCleanup);
+
+ // Load an example add-on which uses the tabs permission.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 2,
+ name: "Simple test add-on",
+ browser_specific_settings: { gecko: { id: "testAddon@mochi.test" } },
+ permissions: ["tabs"],
+ },
+ background,
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let addonId = extension.id;
+
+ let win = await loadInitialView("extension");
+
+ // Open the card and navigate to its permission list.
+ let card = getAddonCard(win, addonId);
+ let permsSection = card.querySelector("addon-permissions-list");
+ if (!permsSection) {
+ ok(!card.hasAttribute("expanded"), "The list card is not expanded");
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+ }
+
+ card = getAddonCard(win, addonId);
+ let { deck, tabGroup } = card.details;
+
+ let permsBtn = tabGroup.querySelector('[name="permissions"]');
+ let permsShown = BrowserTestUtils.waitForEvent(deck, "view-changed");
+ permsBtn.click();
+ await permsShown;
+ let permissionList = card.querySelector("addon-permissions-list");
+ let permissionEntries = Array.from(permissionList.querySelectorAll("li"));
+ Assert.equal(
+ permissionEntries.length,
+ 1,
+ "Should find a single permission entry"
+ );
+ Assert.equal(
+ permissionEntries[0].textContent,
+ "Custom description for the tabs permission",
+ "Should find the non-default permission description"
+ );
+
+ await closeView(win);
+ await extension.unload();
+
+ mockCleanup();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js b/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
new file mode 100644
index 0000000000..a466b5c1a3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
@@ -0,0 +1,1198 @@
+/* eslint max-len: ["error", 80] */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+
+const SUPPORT_URL = Services.urlFormatter.formatURL(
+ Services.prefs.getStringPref("app.support.baseURL")
+);
+const PB_SUMO_URL = SUPPORT_URL + "extensions-pb";
+const DEFAULT_THEME_ID = "default-theme@mozilla.org";
+const DARK_THEME_ID = "firefox-compact-dark@mozilla.org";
+
+let gProvider;
+let promptService;
+
+AddonTestUtils.initMochitest(this);
+
+function getDetailRows(card) {
+ return Array.from(
+ card.querySelectorAll('[name="details"] .addon-detail-row:not([hidden])')
+ );
+}
+
+function checkLabel(row, name) {
+ let id;
+ if (name == "private-browsing") {
+ // This id is carried over from the old about:addons.
+ id = "detail-private-browsing-label";
+ } else {
+ id = `addon-detail-${name}-label`;
+ }
+ is(
+ row.ownerDocument.l10n.getAttributes(row.querySelector("label")).id,
+ id,
+ `The ${name} label is set`
+ );
+}
+
+function formatUrl(contentAttribute, url) {
+ let parsedUrl = new URL(url);
+ parsedUrl.searchParams.set("utm_source", "firefox-browser");
+ parsedUrl.searchParams.set("utm_medium", "firefox-browser");
+ parsedUrl.searchParams.set("utm_content", contentAttribute);
+ return parsedUrl.href;
+}
+
+function checkLink(link, url, text = url) {
+ ok(link, "There is a link");
+ is(link.href, url, "The link goes to the URL");
+ if (text instanceof Object) {
+ // Check the fluent data.
+ Assert.deepEqual(
+ link.ownerDocument.l10n.getAttributes(link),
+ text,
+ "The fluent data is set correctly"
+ );
+ } else {
+ // Just check text.
+ is(link.textContent, text, "The text is set");
+ }
+ is(link.getAttribute("target"), "_blank", "The link opens in a new tab");
+}
+
+function checkOptions(doc, options, expectedOptions) {
+ let numOptions = expectedOptions.length;
+ is(options.length, numOptions, `There are ${numOptions} options`);
+ for (let i = 0; i < numOptions; i++) {
+ let option = options[i];
+ is(option.children.length, 2, "There are 2 children for the option");
+ let input = option.firstElementChild;
+ is(input.tagName, "INPUT", "The input is first");
+ let text = option.lastElementChild;
+ is(text.tagName, "SPAN", "The label text is second");
+ let expected = expectedOptions[i];
+ is(input.value, expected.value, "The value is right");
+ is(input.checked, expected.checked, "The checked property is correct");
+ Assert.deepEqual(
+ doc.l10n.getAttributes(text),
+ { id: expected.label, args: null },
+ "The label has the right text"
+ );
+ }
+}
+
+function assertDeckHeadingHidden(group) {
+ ok(group.hidden, "The tab group is hidden");
+ let buttons = group.querySelectorAll(".tab-button");
+ for (let button of buttons) {
+ ok(button.offsetHeight == 0, `The ${button.name} is hidden`);
+ }
+}
+
+function assertDeckHeadingButtons(group, visibleButtons) {
+ ok(!group.hidden, "The tab group is shown");
+ let buttons = group.querySelectorAll(".tab-button");
+ ok(
+ buttons.length >= visibleButtons.length,
+ `There should be at least ${visibleButtons.length} buttons`
+ );
+ for (let button of buttons) {
+ if (visibleButtons.includes(button.name)) {
+ ok(!button.hidden, `The ${button.name} is shown`);
+ } else {
+ ok(button.hidden, `The ${button.name} is hidden`);
+ }
+ }
+}
+
+async function hasPrivateAllowed(id) {
+ let perms = await ExtensionPermissions.get(id);
+ return perms.permissions.includes("internal:privateBrowsingAllowed");
+}
+
+async function assertBackButtonIsDisabled(win) {
+ let backButton = await BrowserTestUtils.waitForCondition(async () => {
+ let backButton = win.document.querySelector(".back-button");
+
+ // Wait until the button is visible in the page.
+ return backButton && !backButton.hidden ? backButton : false;
+ });
+
+ ok(backButton, "back button is rendered");
+ ok(backButton.disabled, "back button is disabled");
+}
+
+add_setup(async function enableHtmlViews() {
+ gProvider = new MockProvider(["extension", "sitepermission"]);
+ gProvider.createAddons([
+ {
+ id: "addon1@mochi.test",
+ name: "Test add-on 1",
+ creator: { name: "The creator", url: "http://addons.mozilla.org/me" },
+ version: "3.1",
+ description: "Short description",
+ fullDescription: "Longer description\nWith brs!",
+ type: "extension",
+ contributionURL: "http://example.com/contribute",
+ averageRating: 4.279,
+ userPermissions: {
+ origins: ["<all_urls>", "file://*/*"],
+ permissions: ["alarms", "contextMenus", "tabs", "webNavigation"],
+ },
+ reviewCount: 5,
+ reviewURL: "http://addons.mozilla.org/reviews",
+ homepageURL: "http://example.com/addon1",
+ updateDate: new Date("2019-03-07T01:00:00"),
+ applyBackgroundUpdates: AddonManager.AUTOUPDATE_ENABLE,
+ },
+ {
+ id: "addon2@mochi.test",
+ name: "Test add-on 2",
+ creator: { name: "I made it" },
+ description: "Short description",
+ userPermissions: {
+ origins: [],
+ permissions: ["alarms", "contextMenus"],
+ },
+ type: "extension",
+ },
+ {
+ id: "addon3@mochi.test",
+ name: "Test add-on 3",
+ creator: { name: "Look a super long description" },
+ description: "Short description",
+ fullDescription: "Mozilla\n".repeat(100),
+ userPermissions: {
+ origins: [],
+ permissions: ["alarms", "contextMenus"],
+ },
+ type: "extension",
+ contributionURL: "http://example.com/contribute",
+ updateDate: new Date("2022-03-07T01:00:00"),
+ },
+ {
+ // NOTE: Keep the mock properties in sync with the one that
+ // SitePermsAddonWrapper would be providing in real synthetic
+ // addon entries managed by the SitePermsAddonProvider.
+ id: "sitepermission@mochi.test",
+ version: "2.0",
+ name: "Test site permission add-on",
+ description: "permission description",
+ fullDescription: "detailed description",
+ siteOrigin: "http://mochi.test",
+ sitePermissions: ["midi"],
+ type: "sitepermission",
+ permissions: AddonManager.PERM_CAN_UNINSTALL,
+ },
+ {
+ id: "theme1@mochi.test",
+ name: "Test theme",
+ creator: { name: "Artist", url: "http://example.com/artist" },
+ description: "A nice tree",
+ type: "theme",
+ screenshots: [
+ {
+ url: "http://example.com/preview-wide.png",
+ width: 760,
+ height: 92,
+ },
+ {
+ url: "http://example.com/preview.png",
+ width: 680,
+ height: 92,
+ },
+ ],
+ },
+ ]);
+
+ promptService = mockPromptService();
+});
+
+add_task(async function testOpenDetailView() {
+ let id = "test@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+ let id2 = "test2@mochi.test";
+ let extension2 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test",
+ browser_specific_settings: { gecko: { id: id2 } },
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ await extension2.startup();
+
+ const goBack = async win => {
+ let loaded = waitForViewLoad(win);
+ let backButton = win.document.querySelector(".back-button");
+ ok(!backButton.disabled, "back button is enabled");
+ backButton.click();
+ await loaded;
+ };
+
+ let win = await loadInitialView("extension");
+
+ // Test click on card to open details.
+ let card = getAddonCard(win, id);
+ ok(!card.querySelector("addon-details"), "The card doesn't have details");
+ let loaded = waitForViewLoad(win);
+ EventUtils.synthesizeMouseAtCenter(card, { clickCount: 1 }, win);
+ await loaded;
+
+ card = getAddonCard(win, id);
+ ok(card.querySelector("addon-details"), "The card now has details");
+
+ await goBack(win);
+
+ // Test using more options menu.
+ card = getAddonCard(win, id);
+ loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = getAddonCard(win, id);
+ ok(card.querySelector("addon-details"), "The card now has details");
+
+ await goBack(win);
+
+ card = getAddonCard(win, id2);
+ loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ await goBack(win);
+
+ // Test click on add-on name.
+ card = getAddonCard(win, id2);
+ ok(!card.querySelector("addon-details"), "The card isn't expanded");
+ let addonName = card.querySelector(".addon-name");
+ loaded = waitForViewLoad(win);
+ EventUtils.synthesizeMouseAtCenter(addonName, {}, win);
+ await loaded;
+ card = getAddonCard(win, id2);
+ ok(card.querySelector("addon-details"), "The card is expanded");
+
+ await closeView(win);
+ await extension.unload();
+ await extension2.unload();
+});
+
+add_task(async function testDetailOperations() {
+ let id = "test@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let card = getAddonCard(win, id);
+ ok(!card.querySelector("addon-details"), "The card doesn't have details");
+ let loaded = waitForViewLoad(win);
+ EventUtils.synthesizeMouseAtCenter(card, { clickCount: 1 }, win);
+ await loaded;
+
+ card = getAddonCard(win, id);
+ let panel = card.querySelector("panel-list");
+
+ // Check button visibility.
+ let disableButton = card.querySelector('[action="toggle-disabled"]');
+ ok(!disableButton.hidden, "The disable button is visible");
+
+ let removeButton = panel.querySelector('[action="remove"]');
+ ok(!removeButton.hidden, "The remove button is visible");
+
+ let separator = panel.querySelector("hr:last-of-type");
+ ok(separator.hidden, "The separator is hidden");
+
+ let expandButton = panel.querySelector('[action="expand"]');
+ ok(expandButton.hidden, "The expand button is hidden");
+
+ // Check toggling disabled.
+ let name = card.addonNameEl;
+ is(name.textContent, "Test", "The name is set when enabled");
+ is(doc.l10n.getAttributes(name).id, null, "There is no l10n name");
+
+ // Disable the extension.
+ let disableToggled = BrowserTestUtils.waitForEvent(card, "update");
+ disableButton.click();
+ await disableToggled;
+
+ // The (disabled) text should be shown now.
+ Assert.deepEqual(
+ doc.l10n.getAttributes(name),
+ { id: "addon-name-disabled", args: { name: "Test" } },
+ "The name is updated to the disabled text"
+ );
+
+ // Enable the add-on.
+ let extensionStarted = AddonTestUtils.promiseWebExtensionStartup(id);
+ disableToggled = BrowserTestUtils.waitForEvent(card, "update");
+ disableButton.click();
+ await Promise.all([disableToggled, extensionStarted]);
+
+ // Name is just the add-on name again.
+ is(name.textContent, "Test", "The name is reset when enabled");
+ is(doc.l10n.getAttributes(name).id, null, "There is no l10n name");
+
+ // Remove but cancel.
+ let cancelled = BrowserTestUtils.waitForEvent(card, "remove-cancelled");
+ removeButton.click();
+ await cancelled;
+
+ // Remove the extension.
+ let viewChanged = waitForViewLoad(win);
+ // Tell the mock prompt service that the prompt was accepted.
+ promptService._response = 0;
+ removeButton.click();
+ await viewChanged;
+
+ // We're on the list view now and there's no card for this extension.
+ const addonList = doc.querySelector("addon-list");
+ ok(addonList, "There's an addon-list now");
+ ok(!getAddonCard(win, id), "The extension no longer has a card");
+ let addon = await AddonManager.getAddonByID(id);
+ ok(
+ addon && !!(addon.pendingOperations & AddonManager.PENDING_UNINSTALL),
+ "The addon is pending uninstall"
+ );
+
+ // Ensure that a pending uninstall bar has been created for the
+ // pending uninstall extension, and pressing the undo button will
+ // refresh the list and render a card to the re-enabled extension.
+ assertHasPendingUninstalls(addonList, 1);
+ assertHasPendingUninstallAddon(addonList, addon);
+
+ extensionStarted = AddonTestUtils.promiseWebExtensionStartup(addon.id);
+ await testUndoPendingUninstall(addonList, addon);
+ info("Wait for the pending uninstall addon complete restart");
+ await extensionStarted;
+
+ card = getAddonCard(win, addon.id);
+ ok(card, "Addon card rendered after clicking pending uninstall undo button");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testFullDetails() {
+ let id = "addon1@mochi.test";
+ let headingId = "addon1_mochi_test-heading";
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // The list card.
+ let card = getAddonCard(win, id);
+ ok(!card.hasAttribute("expanded"), "The list card is not expanded");
+
+ // Make sure the preview is hidden.
+ let preview = card.querySelector(".card-heading-image");
+ ok(preview, "There is a preview");
+ is(preview.hidden, true, "The preview is hidden");
+
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ // This is now the detail card.
+ card = getAddonCard(win, id);
+ ok(card.hasAttribute("expanded"), "The detail card is expanded");
+
+ let cardHeading = card.querySelector("h1");
+ is(cardHeading.textContent, "Test add-on 1", "Card heading is set");
+ is(cardHeading.id, headingId, "Heading has correct id");
+ is(
+ card.querySelector(".card").getAttribute("aria-labelledby"),
+ headingId,
+ "Card is labelled by the heading"
+ );
+
+ // Make sure the preview is hidden.
+ preview = card.querySelector(".card-heading-image");
+ ok(preview, "There is a preview");
+ is(preview.hidden, true, "The preview is hidden");
+
+ let details = card.querySelector("addon-details");
+
+ // Check all the deck buttons are hidden.
+ assertDeckHeadingButtons(details.tabGroup, ["details", "permissions"]);
+
+ let desc = details.querySelector(".addon-detail-description");
+ is(
+ desc.innerHTML,
+ "Longer description<br>With brs!",
+ "The full description replaces newlines with <br>"
+ );
+
+ let sitepermissionsRow = details.querySelector(
+ ".addon-detail-sitepermissions"
+ );
+ is(
+ sitepermissionsRow.hidden,
+ true,
+ "AddonSitePermissionsList should be hidden for this addon type"
+ );
+
+ // Check the show more button is not there
+ const showMoreBtn = card.querySelector(".addon-detail-description-toggle");
+ ok(showMoreBtn.hidden, "The show more button is not visible");
+
+ let contrib = details.querySelector(".addon-detail-contribute");
+ ok(contrib, "The contribution section is visible");
+
+ let waitForTab = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "http://example.com/contribute"
+ );
+ contrib.querySelector("button").click();
+ BrowserTestUtils.removeTab(await waitForTab);
+
+ let rows = getDetailRows(card);
+
+ // Auto updates.
+ let row = rows.shift();
+ checkLabel(row, "updates");
+ let expectedOptions = [
+ { value: "1", label: "addon-detail-updates-radio-default", checked: false },
+ { value: "2", label: "addon-detail-updates-radio-on", checked: true },
+ { value: "0", label: "addon-detail-updates-radio-off", checked: false },
+ ];
+ let options = row.lastElementChild.querySelectorAll("label");
+ checkOptions(doc, options, expectedOptions);
+
+ // Private browsing, functionality checked in another test.
+ row = rows.shift();
+ checkLabel(row, "private-browsing");
+
+ // Private browsing help text.
+ row = rows.shift();
+ ok(row.classList.contains("addon-detail-help-row"), "There's a help row");
+ ok(!row.hidden, "The help row is shown");
+ is(
+ doc.l10n.getAttributes(row).id,
+ "addon-detail-private-browsing-help",
+ "The help row is for private browsing"
+ );
+
+ // Author.
+ row = rows.shift();
+ checkLabel(row, "author");
+ let link = row.querySelector("a");
+ let authorLink = formatUrl(
+ "addons-manager-user-profile-link",
+ "http://addons.mozilla.org/me"
+ );
+ checkLink(link, authorLink, "The creator");
+
+ // Version.
+ row = rows.shift();
+ checkLabel(row, "version");
+ let text = row.lastChild;
+ is(text.textContent, "3.1", "The version is set");
+
+ // Last updated.
+ row = rows.shift();
+ checkLabel(row, "last-updated");
+ text = row.lastChild;
+ is(text.textContent, "March 7, 2019", "The last updated date is set");
+
+ // Homepage.
+ row = rows.shift();
+ checkLabel(row, "homepage");
+ link = row.querySelector("a");
+ checkLink(link, "http://example.com/addon1");
+
+ // Reviews.
+ row = rows.shift();
+ checkLabel(row, "rating");
+ let rating = row.lastElementChild;
+ ok(rating.classList.contains("addon-detail-rating"), "Found the rating el");
+ let starsElem = rating.querySelector("five-star-rating");
+ is(starsElem.rating, 4.279, "Exact rating used for calculations");
+ let stars = Array.from(starsElem.shadowRoot.querySelectorAll(".rating-star"));
+ let fullAttrs = stars.map(star => star.getAttribute("fill")).join(",");
+ is(fullAttrs, "full,full,full,full,half", "Four and a half stars are full");
+ link = rating.querySelector("a");
+ let reviewsLink = formatUrl(
+ "addons-manager-reviews-link",
+ "http://addons.mozilla.org/reviews"
+ );
+ checkLink(link, reviewsLink, {
+ id: "addon-detail-reviews-link",
+ args: { numberOfReviews: 5 },
+ });
+
+ // While we are here, let's test edge cases of star ratings.
+ async function testRating(rating, ratingRounded, expectation) {
+ starsElem.rating = rating;
+ await starsElem.ownerDocument.l10n.translateElements([starsElem]);
+ is(
+ starsElem.ratingBuckets.join(","),
+ expectation,
+ `Rendering of rating ${rating}`
+ );
+
+ is(
+ starsElem.title,
+ `Rated ${ratingRounded} out of 5`,
+ "Rendered title must contain at most one fractional digit"
+ );
+ }
+ await testRating(0.0, "0", "empty,empty,empty,empty,empty");
+ await testRating(0.123, "0.1", "empty,empty,empty,empty,empty");
+ await testRating(0.249, "0.2", "empty,empty,empty,empty,empty");
+ await testRating(0.25, "0.3", "half,empty,empty,empty,empty");
+ await testRating(0.749, "0.7", "half,empty,empty,empty,empty");
+ await testRating(0.75, "0.8", "full,empty,empty,empty,empty");
+ await testRating(1.0, "1", "full,empty,empty,empty,empty");
+ await testRating(4.249, "4.2", "full,full,full,full,empty");
+ await testRating(4.25, "4.3", "full,full,full,full,half");
+ await testRating(4.749, "4.7", "full,full,full,full,half");
+ await testRating(5.0, "5", "full,full,full,full,full");
+
+ // That should've been all the rows.
+ is(rows.length, 0, "There are no more rows left");
+
+ await closeView(win);
+});
+
+add_task(async function testFullDetailsShowMoreButton() {
+ const id = "addon3@mochi.test";
+ const win = await loadInitialView("extension");
+
+ // The list card.
+ let card = getAddonCard(win, id);
+ const loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ // This is now the detail card.
+ card = getAddonCard(win, id);
+
+ // Check the show more button is there
+ const showMoreBtn = card.querySelector(".addon-detail-description-toggle");
+ ok(!showMoreBtn.hidden, "The show more button is visible");
+
+ const descriptionWrapper = card.querySelector(
+ ".addon-detail-description-wrapper"
+ );
+ ok(
+ descriptionWrapper.classList.contains("addon-detail-description-collapse"),
+ "The long description is collapsed"
+ );
+
+ // After click the description should be expanded
+ showMoreBtn.click();
+ ok(
+ !descriptionWrapper.classList.contains("addon-detail-description-collapse"),
+ "The long description is expanded"
+ );
+
+ await closeView(win);
+});
+
+add_task(async function testMinimalExtension() {
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let card = getAddonCard(win, "addon2@mochi.test");
+ ok(!card.hasAttribute("expanded"), "The list card is not expanded");
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = getAddonCard(win, "addon2@mochi.test");
+ let details = card.querySelector("addon-details");
+
+ // Check all the deck buttons are hidden.
+ assertDeckHeadingButtons(details.tabGroup, ["details", "permissions"]);
+
+ let desc = details.querySelector(".addon-detail-description");
+ is(desc.textContent, "", "There is no full description");
+
+ let contrib = details.querySelector(".addon-detail-contribute");
+ ok(contrib.hidden, "The contribution element is hidden");
+
+ let rows = getDetailRows(card);
+
+ // Automatic updates.
+ let row = rows.shift();
+ checkLabel(row, "updates");
+
+ // Private browsing settings.
+ row = rows.shift();
+ checkLabel(row, "private-browsing");
+
+ // Private browsing help text.
+ row = rows.shift();
+ ok(row.classList.contains("addon-detail-help-row"), "There's a help row");
+ ok(!row.hidden, "The help row is shown");
+ is(
+ doc.l10n.getAttributes(row).id,
+ "addon-detail-private-browsing-help",
+ "The help row is for private browsing"
+ );
+
+ // Author.
+ row = rows.shift();
+ checkLabel(row, "author");
+ let text = row.lastChild;
+ is(text.textContent, "I made it", "The author is set");
+ ok(Text.isInstance(text), "The author is a text node");
+
+ is(rows.length, 0, "There are no more rows");
+
+ await closeView(win);
+});
+
+add_task(async function testDefaultTheme() {
+ let win = await loadInitialView("theme");
+
+ // The list card.
+ let card = getAddonCard(win, DEFAULT_THEME_ID);
+ ok(!card.hasAttribute("expanded"), "The list card is not expanded");
+
+ let preview = card.querySelector(".card-heading-image");
+ ok(preview, "There is a preview");
+ ok(!preview.hidden, "The preview is visible");
+
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = getAddonCard(win, DEFAULT_THEME_ID);
+
+ preview = card.querySelector(".card-heading-image");
+ ok(preview, "There is a preview");
+ ok(!preview.hidden, "The preview is visible");
+
+ // Check all the deck buttons are hidden.
+ assertDeckHeadingHidden(card.details.tabGroup);
+
+ let rows = getDetailRows(card);
+
+ // Author.
+ let author = rows.shift();
+ checkLabel(author, "author");
+ let text = author.lastChild;
+ is(text.textContent, "Mozilla", "The author is set");
+
+ // Version.
+ let version = rows.shift();
+ checkLabel(version, "version");
+ is(version.lastChild.textContent, "1.3", "It's always version 1.3");
+
+ // Last updated.
+ let lastUpdated = rows.shift();
+ checkLabel(lastUpdated, "last-updated");
+ let dateText = lastUpdated.lastChild.textContent;
+ ok(dateText, "There is a date set");
+ ok(!dateText.includes("Invalid Date"), `"${dateText}" should be a date`);
+
+ is(rows.length, 0, "There are no more rows");
+
+ await closeView(win);
+});
+
+add_task(async function testStaticTheme() {
+ let win = await loadInitialView("theme");
+
+ // The list card.
+ let card = getAddonCard(win, "theme1@mochi.test");
+ ok(!card.hasAttribute("expanded"), "The list card is not expanded");
+
+ // Make sure the preview is set.
+ let preview = card.querySelector(".card-heading-image");
+ ok(preview, "There is a preview");
+ is(preview.src, "http://example.com/preview.png", "The preview URL is set");
+ is(preview.width, 664, "The width is set");
+ is(preview.height, 90, "The height is set");
+ is(preview.hidden, false, "The preview is visible");
+
+ // Load the detail view.
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = getAddonCard(win, "theme1@mochi.test");
+
+ // Make sure the preview is still set.
+ preview = card.querySelector(".card-heading-image");
+ ok(preview, "There is a preview");
+ is(preview.src, "http://example.com/preview.png", "The preview URL is set");
+ is(preview.width, 664, "The width is set");
+ is(preview.height, 90, "The height is set");
+ is(preview.hidden, false, "The preview is visible");
+
+ // Check all the deck buttons are hidden.
+ assertDeckHeadingHidden(card.details.tabGroup);
+
+ let rows = getDetailRows(card);
+
+ // Automatic updates.
+ let row = rows.shift();
+ checkLabel(row, "updates");
+
+ // Author.
+ let author = rows.shift();
+ checkLabel(author, "author");
+ let text = author.lastElementChild;
+ is(text.textContent, "Artist", "The author is set");
+
+ is(rows.length, 0, "There was only 1 row");
+
+ await closeView(win);
+});
+
+add_task(async function testSitePermission() {
+ let win = await loadInitialView("sitepermission");
+
+ // The list card.
+ let card = getAddonCard(win, "sitepermission@mochi.test");
+ ok(!card.hasAttribute("expanded"), "The list card is not expanded");
+
+ // Load the detail view.
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = getAddonCard(win, "sitepermission@mochi.test");
+
+ // Check all the deck buttons are hidden.
+ assertDeckHeadingHidden(card.details.tabGroup);
+
+ let sitepermissionsRow = card.querySelector(".addon-detail-sitepermissions");
+ is(
+ BrowserTestUtils.is_visible(sitepermissionsRow),
+ true,
+ "AddonSitePermissionsList should be visible for this addon type"
+ );
+
+ let [versionRow, ...restRows] = getDetailRows(card);
+ checkLabel(versionRow, "version");
+
+ Assert.deepEqual(
+ restRows.map(row => row.getAttribute("class")),
+ [],
+ "All other details row are hidden as expected"
+ );
+
+ let permissions = Array.from(
+ card.querySelectorAll(".addon-permissions-list .permission-info")
+ );
+ is(permissions.length, 1, "a permission is listed");
+ is(permissions[0].textContent, "Access MIDI devices", "got midi permission");
+
+ await closeView(win);
+});
+
+add_task(async function testPrivateBrowsingExtension() {
+ let id = "pb@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "My PB extension",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "permanent",
+ });
+
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // The add-on shouldn't show that it's allowed yet.
+ let card = getAddonCard(win, id);
+ let badge = card.querySelector(".addon-badge-private-browsing-allowed");
+ ok(badge.hidden, "The PB badge is hidden initially");
+ ok(!(await hasPrivateAllowed(id)), "PB is not allowed");
+
+ // Load the detail view.
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ // The badge is still hidden on the detail view.
+ card = getAddonCard(win, id);
+ badge = card.querySelector(".addon-badge-private-browsing-allowed");
+ ok(badge.hidden, "The PB badge is hidden on the detail view");
+ ok(!(await hasPrivateAllowed(id)), "PB is not allowed");
+
+ let pbRow = card.querySelector(".addon-detail-row-private-browsing");
+ let name = card.querySelector(".addon-name");
+
+ // Allow private browsing.
+ let [allow, disallow] = pbRow.querySelectorAll("input");
+ let updated = BrowserTestUtils.waitForEvent(card, "update");
+
+ // Check that the disabled state isn't shown while reloading the add-on.
+ let addonDisabled = AddonTestUtils.promiseAddonEvent("onDisabled");
+ allow.click();
+ await addonDisabled;
+ is(
+ doc.l10n.getAttributes(name).id,
+ null,
+ "The disabled message is not shown for the add-on"
+ );
+
+ // Check the PB stuff.
+ await updated;
+
+ // Not sure what better to await here.
+ await TestUtils.waitForCondition(() => !badge.hidden);
+
+ ok(!badge.hidden, "The PB badge is now shown");
+ ok(await hasPrivateAllowed(id), "PB is allowed");
+ is(
+ doc.l10n.getAttributes(name).id,
+ null,
+ "The disabled message is not shown for the add-on"
+ );
+
+ info("Verify the badge links to the support page");
+ let tabOpened = BrowserTestUtils.waitForNewTab(gBrowser, PB_SUMO_URL);
+ EventUtils.synthesizeMouseAtCenter(badge, {}, win);
+ let tab = await tabOpened;
+ BrowserTestUtils.removeTab(tab);
+
+ // Disable the add-on and change the value.
+ updated = BrowserTestUtils.waitForEvent(card, "update");
+ card.querySelector('[action="toggle-disabled"]').click();
+ await updated;
+
+ // It's still allowed in PB.
+ ok(await hasPrivateAllowed(id), "PB is allowed");
+ ok(!badge.hidden, "The PB badge is shown");
+
+ // Disallow PB.
+ updated = BrowserTestUtils.waitForEvent(card, "update");
+ disallow.click();
+ await updated;
+
+ ok(badge.hidden, "The PB badge is hidden");
+ ok(!(await hasPrivateAllowed(id)), "PB is disallowed");
+
+ // Allow PB.
+ updated = BrowserTestUtils.waitForEvent(card, "update");
+ allow.click();
+ await updated;
+
+ ok(!badge.hidden, "The PB badge is hidden");
+ ok(await hasPrivateAllowed(id), "PB is disallowed");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testInvalidExtension() {
+ let win = await open_manager("addons://detail/foo");
+ let categoryUtils = new CategoryUtilities(win);
+ is(
+ categoryUtils.selectedCategory,
+ "discover",
+ "Should fall back to the discovery pane"
+ );
+
+ ok(!gBrowser.canGoBack, "The view has been replaced");
+
+ await close_manager(win);
+});
+
+add_task(async function testInvalidExtensionNoDiscover() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.getAddons.showPane", false]],
+ });
+
+ let win = await open_manager("addons://detail/foo");
+ let categoryUtils = new CategoryUtilities(win);
+ is(
+ categoryUtils.selectedCategory,
+ "extension",
+ "Should fall back to the extension list if discover is disabled"
+ );
+
+ ok(!gBrowser.canGoBack, "The view has been replaced");
+
+ await close_manager(win);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function testExternalUninstall() {
+ let id = "remove@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Remove me",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let addon = await AddonManager.getAddonByID(id);
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // Load the detail view.
+ let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+ let detailsLoaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await detailsLoaded;
+
+ // Uninstall the add-on with undo. Should go to extension list.
+ let listLoaded = waitForViewLoad(win);
+ await addon.uninstall(true);
+ await listLoaded;
+
+ // Verify the list view was loaded and the card is gone.
+ let list = doc.querySelector("addon-list");
+ ok(list, "Moved to a list page");
+ is(list.type, "extension", "We're on the extension list page");
+ card = list.querySelector(`addon-card[addon-id="${id}"]`);
+ ok(!card, "The card has been removed");
+
+ await extension.unload();
+ closeView(win);
+});
+
+add_task(async function testExternalThemeUninstall() {
+ let id = "remove-theme@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ name: "Remove theme",
+ theme: {},
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let addon = await AddonManager.getAddonByID(id);
+
+ let win = await loadInitialView("theme");
+ let doc = win.document;
+
+ // Load the detail view.
+ let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+ let detailsLoaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await detailsLoaded;
+
+ // Uninstall the add-on without undo. Should go to theme list.
+ let listLoaded = waitForViewLoad(win);
+ await addon.uninstall();
+ await listLoaded;
+
+ // Verify the list view was loaded and the card is gone.
+ let list = doc.querySelector("addon-list");
+ ok(list, "Moved to a list page");
+ is(list.type, "theme", "We're on the theme list page");
+ card = list.querySelector(`addon-card[addon-id="${id}"]`);
+ ok(!card, "The card has been removed");
+
+ await extension.unload();
+ closeView(win);
+});
+
+add_task(async function testPrivateBrowsingAllowedListView() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Allowed PB extension",
+ browser_specific_settings: { gecko: { id: "allowed@mochi.test" } },
+ },
+ useAddonManager: "permanent",
+ });
+
+ await extension.startup();
+ let perms = { permissions: ["internal:privateBrowsingAllowed"], origins: [] };
+ await ExtensionPermissions.add("allowed@mochi.test", perms);
+ let addon = await AddonManager.getAddonByID("allowed@mochi.test");
+ await addon.reload();
+
+ let win = await loadInitialView("extension");
+
+ // The allowed extension should have a badge on load.
+ let card = getAddonCard(win, "allowed@mochi.test");
+ let badge = card.querySelector(".addon-badge-private-browsing-allowed");
+ ok(!badge.hidden, "The PB badge is shown for the allowed add-on");
+
+ await extension.unload();
+ await closeView(win);
+});
+
+// When the back button is used, its disabled state will be updated. If it
+// isn't updated when showing a view, then it will be disabled on the next
+// use (bug 1551213) if the last use caused it to become disabled.
+add_task(async function testGoBackButton() {
+ // Make sure the list view is the first loaded view so you cannot go back.
+ Services.prefs.setCharPref(PREF_UI_LASTCATEGORY, "addons://list/extension");
+
+ let id = "addon1@mochi.test";
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+ let backButton = doc.querySelector(".back-button");
+
+ let loadDetailView = () => {
+ let loaded = waitForViewLoad(win);
+ getAddonCard(win, id).querySelector("[action=expand]").click();
+ return loaded;
+ };
+
+ let checkBackButtonState = () => {
+ is_element_visible(backButton, "Back button is visible on the detail page");
+ ok(!backButton.disabled, "Back button is enabled");
+ };
+
+ // Load the detail view, first time should be fine.
+ await loadDetailView();
+ checkBackButtonState();
+
+ // Use the back button directly to pop from history and trigger its disabled
+ // state to be updated.
+ let loaded = waitForViewLoad(win);
+ backButton.click();
+ await loaded;
+
+ await loadDetailView();
+ checkBackButtonState();
+
+ await closeView(win);
+});
+
+add_task(async function testEmptyMoreOptionsMenu() {
+ let theme = await AddonManager.getAddonByID(DEFAULT_THEME_ID);
+ ok(theme.isActive, "The default theme is enabled");
+
+ let win = await loadInitialView("theme");
+
+ let card = getAddonCard(win, DEFAULT_THEME_ID);
+ let enabledItems = card.options.visibleItems;
+ is(enabledItems.length, 1, "There is one enabled item");
+ is(enabledItems[0].getAttribute("action"), "expand", "Expand is enabled");
+ let moreOptionsButton = card.querySelector(".more-options-button");
+ ok(!moreOptionsButton.hidden, "The more options button is visible");
+
+ let loaded = waitForViewLoad(win);
+ enabledItems[0].click();
+ await loaded;
+
+ card = getAddonCard(win, DEFAULT_THEME_ID);
+ let toggleDisabledButton = card.querySelector('[action="toggle-disabled"]');
+ enabledItems = card.options.visibleItems;
+ is(enabledItems.length, 0, "There are no enabled items");
+ moreOptionsButton = card.querySelector(".more-options-button");
+ ok(moreOptionsButton.hidden, "The more options button is now hidden");
+ ok(toggleDisabledButton.hidden, "The disable button is hidden");
+
+ // Switch themes, the menu should be hidden, but enable button should appear.
+ let darkTheme = await AddonManager.getAddonByID(DARK_THEME_ID);
+ let updated = BrowserTestUtils.waitForEvent(card, "update");
+ await darkTheme.enable();
+ await updated;
+
+ ok(moreOptionsButton.hidden, "The more options button is still hidden");
+ ok(!toggleDisabledButton.hidden, "The enable button is visible");
+
+ updated = BrowserTestUtils.waitForEvent(card, "update");
+ await toggleDisabledButton.click();
+ await updated;
+
+ ok(moreOptionsButton.hidden, "The more options button is hidden");
+ ok(toggleDisabledButton.hidden, "The disable button is hidden");
+
+ await closeView(win);
+});
+
+add_task(async function testGoBackButtonIsDisabledWhenHistoryIsEmpty() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { name: "Test Go Back Button" },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let viewID = `addons://detail/${encodeURIComponent(extension.id)}`;
+
+ // When we have a fresh new tab, `about:addons` is opened in it.
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, null);
+ // Simulate a click on "Manage extension" from a context menu.
+ let win = await BrowserOpenAddonsMgr(viewID);
+ await assertBackButtonIsDisabled(win);
+
+ BrowserTestUtils.removeTab(tab);
+ await extension.unload();
+});
+
+add_task(async function testGoBackButtonIsDisabledWhenHistoryIsEmptyInNewTab() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { name: "Test Go Back Button" },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let viewID = `addons://detail/${encodeURIComponent(extension.id)}`;
+
+ // When we have a tab with a page loaded, `about:addons` will be opened in a
+ // new tab.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.org"
+ );
+ let addonsTabLoaded = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:addons",
+ true
+ );
+ // Simulate a click on "Manage extension" from a context menu.
+ let win = await BrowserOpenAddonsMgr(viewID);
+ let addonsTab = await addonsTabLoaded;
+ await assertBackButtonIsDisabled(win);
+
+ BrowserTestUtils.removeTab(addonsTab);
+ BrowserTestUtils.removeTab(tab);
+ await extension.unload();
+});
+
+add_task(async function testGoBackButtonIsDisabledAfterBrowserBackButton() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { name: "Test Go Back Button" },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let viewID = `addons://detail/${encodeURIComponent(extension.id)}`;
+
+ // When we have a fresh new tab, `about:addons` is opened in it.
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, null);
+ // Simulate a click on "Manage extension" from a context menu.
+ let win = await BrowserOpenAddonsMgr(viewID);
+ await assertBackButtonIsDisabled(win);
+
+ // Navigate to the extensions list.
+ await new CategoryUtilities(win).openType("extension");
+
+ // Click on the browser back button.
+ gBrowser.goBack();
+ await assertBackButtonIsDisabled(win);
+
+ BrowserTestUtils.removeTab(tab);
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js
new file mode 100644
index 0000000000..9dcc3a0006
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js
@@ -0,0 +1,660 @@
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+loadTestSubscript("head_disco.js");
+
+// The response to the discovery API, as documented at:
+// https://addons-server.readthedocs.io/en/latest/topics/api/discovery.html
+//
+// The test is designed to easily verify whether the discopane works with the
+// latest AMO API, by replacing API_RESPONSE_FILE's content with latest AMO API
+// response, e.g. from https://addons.allizom.org/api/v4/discovery/?lang=en-US
+// The response must contain at least one theme, and one extension.
+
+const API_RESPONSE_FILE = PathUtils.join(
+ Services.dirsvc.get("CurWorkD", Ci.nsIFile).path,
+ // Trim empty component from splitting with trailing slash.
+ ...RELATIVE_DIR.split("/").filter(c => c.length),
+ "discovery",
+ "api_response.json"
+);
+
+const AMO_TEST_HOST = "rewritten-for-testing.addons.allizom.org";
+
+const ArrayBufferInputStream = Components.Constructor(
+ "@mozilla.org/io/arraybuffer-input-stream;1",
+ "nsIArrayBufferInputStream",
+ "setData"
+);
+
+const amoServer = AddonTestUtils.createHttpServer({ hosts: [AMO_TEST_HOST] });
+
+amoServer.registerFile(
+ "/png",
+ FileUtils.getFile(
+ "CurWorkD",
+ `${RELATIVE_DIR}discovery/small-1x1.png`.split("/")
+ )
+);
+amoServer.registerPathHandler("/dummy", (request, response) => {
+ response.write("Dummy");
+});
+
+// `result` is an element in the `results` array from AMO's discovery API,
+// stored in API_RESPONSE_FILE.
+function getTestExpectationFromApiResult(result) {
+ return {
+ typeIsTheme: result.addon.type === "statictheme",
+ addonName: result.addon.name,
+ authorName: result.addon.authors[0].name,
+ editorialBody: result.description_text,
+ dailyUsers: result.addon.average_daily_users,
+ rating: result.addon.ratings.average,
+ };
+}
+
+// A helper to declare a response to discovery API requests.
+class DiscoveryAPIHandler {
+ constructor(responseText) {
+ this.setResponseText(responseText);
+ this.requestCount = 0;
+
+ // Overwrite the previous discovery response handler.
+ amoServer.registerPathHandler("/discoapi", this);
+ }
+
+ setResponseText(responseText) {
+ this.responseBody = new TextEncoder().encode(responseText).buffer;
+ }
+
+ // Suspend discovery API requests until unblockResponses is called.
+ blockNextResponses() {
+ this._unblockPromise = new Promise(resolve => {
+ this.unblockResponses = resolve;
+ });
+ }
+
+ unblockResponses(responseText) {
+ throw new Error("You need to call blockNextResponses first!");
+ }
+
+ // nsIHttpRequestHandler::handle
+ async handle(request, response) {
+ ++this.requestCount;
+
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.processAsync();
+ await this._unblockPromise;
+
+ let body = this.responseBody;
+ let binStream = new ArrayBufferInputStream(body, 0, body.byteLength);
+ response.bodyOutputStream.writeFrom(binStream, body.byteLength);
+ response.finish();
+ }
+}
+
+// Retrieve the list of visible action elements inside a document or container.
+function getVisibleActions(documentOrElement) {
+ return Array.from(documentOrElement.querySelectorAll("[action]")).filter(
+ elem =>
+ elem.getAttribute("action") !== "page-options" &&
+ elem.offsetWidth &&
+ elem.offsetHeight
+ );
+}
+
+function getActionName(actionElement) {
+ return actionElement.getAttribute("action");
+}
+
+function getCardByAddonId(win, addonId) {
+ for (let card of win.document.querySelectorAll("recommended-addon-card")) {
+ if (card.addonId === addonId) {
+ return card;
+ }
+ }
+ return null;
+}
+
+// Switch to a different view so we can switch back to the discopane later.
+async function switchToNonDiscoView(win) {
+ // Listeners registered while the discopane was the active view continue to be
+ // active when the view switches to the extensions list, because both views
+ // share the same document.
+ win.gViewController.loadView("addons://list/extension");
+ await wait_for_view_load(win);
+ ok(
+ win.document.querySelector("addon-list"),
+ "Should be at the extension list view"
+ );
+}
+
+// Switch to the discopane and wait until it has fully rendered, including any
+// cards from the discovery API.
+async function switchToDiscoView(win) {
+ is(
+ getDiscoveryElement(win),
+ null,
+ "Cannot switch to discopane when the discopane is already shown"
+ );
+ win.gViewController.loadView("addons://discover/");
+ await wait_for_view_load(win);
+ await promiseDiscopaneUpdate(win);
+}
+
+// Wait until all images in the DOM have successfully loaded.
+// There must be at least one `<img>` in the document.
+// Returns the number of loaded images.
+async function waitForAllImagesLoaded(win) {
+ let imgs = Array.from(
+ win.document.querySelectorAll("discovery-pane img[src]")
+ );
+ function areAllImagesLoaded() {
+ let loadCount = imgs.filter(img => img.naturalWidth).length;
+ info(`Loaded ${loadCount} out of ${imgs.length} images`);
+ return loadCount === imgs.length;
+ }
+ if (!areAllImagesLoaded()) {
+ await promiseEvent(win.document, "load", true, areAllImagesLoaded);
+ }
+ return imgs.length;
+}
+
+// Install an add-on by clicking on the card.
+// The promise resolves once the card has been updated.
+async function testCardInstall(card) {
+ Assert.deepEqual(
+ getVisibleActions(card).map(getActionName),
+ ["install-addon"],
+ "Should have an Install button before install"
+ );
+
+ let installButton =
+ card.querySelector("[data-l10n-id='install-extension-button']") ||
+ card.querySelector("[data-l10n-id='install-theme-button']");
+
+ let updatePromise = promiseEvent(card, "disco-card-updated");
+ installButton.click();
+ await updatePromise;
+
+ Assert.deepEqual(
+ getVisibleActions(card).map(getActionName),
+ ["manage-addon"],
+ "Should have a Manage button after install"
+ );
+}
+
+// Uninstall the add-on (not via the card, since it has no uninstall button).
+// The promise resolves once the card has been updated.
+async function testAddonUninstall(card) {
+ Assert.deepEqual(
+ getVisibleActions(card).map(getActionName),
+ ["manage-addon"],
+ "Should have a Manage button before uninstall"
+ );
+
+ let addon = await AddonManager.getAddonByID(card.addonId);
+
+ let updatePromise = promiseEvent(card, "disco-card-updated");
+ await addon.uninstall();
+ await updatePromise;
+
+ Assert.deepEqual(
+ getVisibleActions(card).map(getActionName),
+ ["install-addon"],
+ "Should have an Install button after uninstall"
+ );
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "extensions.getAddons.discovery.api_url",
+ `http://${AMO_TEST_HOST}/discoapi`,
+ ],
+ // Disable non-discopane recommendations to avoid unexpected discovery
+ // API requests.
+ ["extensions.htmlaboutaddons.recommendations.enabled", false],
+ // Disable the telemetry client ID (and its associated UI warning).
+ // browser_html_discover_view_clientid.js covers this functionality.
+ ["browser.discovery.enabled", false],
+ ],
+ });
+});
+
+// Test that the discopane can be loaded and that meaningful results are shown.
+// This relies on response data from the AMO API, stored in API_RESPONSE_FILE.
+add_task(async function discopane_with_real_api_data() {
+ const apiText = await readAPIResponseFixture(
+ AMO_TEST_HOST,
+ API_RESPONSE_FILE
+ );
+ let apiHandler = new DiscoveryAPIHandler(apiText);
+
+ const apiResultArray = JSON.parse(apiText).results;
+ ok(apiResultArray.length, `Mock has ${apiResultArray.length} results`);
+
+ apiHandler.blockNextResponses();
+ let win = await loadInitialView("discover");
+
+ Assert.deepEqual(
+ getVisibleActions(win.document).map(getActionName),
+ [],
+ "The AMO button should be invisible when the AMO API hasn't responded"
+ );
+
+ apiHandler.unblockResponses();
+ await promiseDiscopaneUpdate(win);
+
+ let actionElements = getVisibleActions(win.document);
+ Assert.deepEqual(
+ actionElements.map(getActionName),
+ [
+ // Expecting an install button for every result.
+ ...new Array(apiResultArray.length).fill("install-addon"),
+ "open-amo",
+ ],
+ "All add-on cards should be rendered, with AMO button at the end."
+ );
+
+ let imgCount = await waitForAllImagesLoaded(win);
+ is(imgCount, apiResultArray.length, "Expected an image for every result");
+
+ // Check that the cards have the expected content.
+ let cards = Array.from(
+ win.document.querySelectorAll("recommended-addon-card")
+ );
+ is(cards.length, apiResultArray.length, "Every API result has a card");
+ for (let [i, card] of cards.entries()) {
+ let expectations = getTestExpectationFromApiResult(apiResultArray[i]);
+ info(`Expectations for card ${i}: ${JSON.stringify(expectations)}`);
+
+ let checkContent = (selector, expectation) => {
+ let text = card.querySelector(selector).textContent;
+ is(text, expectation, `Content of selector "${selector}"`);
+ };
+ checkContent(".disco-addon-name", expectations.addonName);
+ await win.document.l10n.translateFragment(card);
+ checkContent(
+ ".disco-addon-author [data-l10n-name='author']",
+ expectations.authorName
+ );
+
+ let amoListingLink = card.querySelector(".disco-addon-author a");
+ ok(
+ amoListingLink.search.includes("utm_source=firefox-browser"),
+ `Listing link should have attribution parameter, url=${amoListingLink}`
+ );
+
+ let actions = getVisibleActions(card);
+ is(actions.length, 1, "Card should only have one install button");
+ let installButton = actions[0];
+ if (expectations.typeIsTheme) {
+ // Theme button + screenshot
+ ok(
+ installButton.matches("[data-l10n-id='install-theme-button'"),
+ "Has theme install button"
+ );
+ ok(
+ card.querySelector(".card-heading-image").offsetWidth,
+ "Preview image must be visible"
+ );
+ } else {
+ // Extension button + extended description.
+ ok(
+ installButton.matches("[data-l10n-id='install-extension-button'"),
+ "Has extension install button"
+ );
+ checkContent(".disco-description-main", expectations.editorialBody);
+
+ let ratingElem = card.querySelector("five-star-rating");
+ if (expectations.rating) {
+ is(ratingElem.rating, expectations.rating, "Expected rating value");
+ ok(ratingElem.offsetWidth, "Rating element is visible");
+ } else {
+ is(ratingElem.offsetWidth, 0, "Rating element is not visible");
+ }
+
+ let userCountElem = card.querySelector(".disco-user-count");
+ if (expectations.dailyUsers) {
+ Assert.deepEqual(
+ win.document.l10n.getAttributes(userCountElem),
+ { id: "user-count", args: { dailyUsers: expectations.dailyUsers } },
+ "Card count should be rendered"
+ );
+ } else {
+ is(userCountElem.offsetWidth, 0, "User count element is not visible");
+ }
+ }
+ }
+
+ is(apiHandler.requestCount, 1, "Discovery API should be fetched once");
+
+ await closeView(win);
+});
+
+// Test whether extensions and themes can be installed from the discopane.
+// Also checks that items in the list do not change position after installation,
+// and that they are shown at the bottom of the list when the discopane is
+// reopened.
+add_task(async function install_from_discopane() {
+ const apiText = await readAPIResponseFixture(
+ AMO_TEST_HOST,
+ API_RESPONSE_FILE
+ );
+ const apiResultArray = JSON.parse(apiText).results;
+ let getAddonIdByAMOAddonType = type =>
+ apiResultArray.find(r => r.addon.type === type).addon.guid;
+ const FIRST_EXTENSION_ID = getAddonIdByAMOAddonType("extension");
+ const FIRST_THEME_ID = getAddonIdByAMOAddonType("statictheme");
+
+ let apiHandler = new DiscoveryAPIHandler(apiText);
+
+ let win = await loadInitialView("discover");
+ await promiseDiscopaneUpdate(win);
+ await waitForAllImagesLoaded(win);
+
+ // Test extension install.
+ let installExtensionPromise = promiseAddonInstall(amoServer, {
+ manifest: {
+ name: "My Awesome Add-on",
+ description: "Test extension install button",
+ browser_specific_settings: { gecko: { id: FIRST_EXTENSION_ID } },
+ permissions: ["<all_urls>"],
+ },
+ });
+ await testCardInstall(getCardByAddonId(win, FIRST_EXTENSION_ID));
+ await installExtensionPromise;
+
+ // Test theme install.
+ let installThemePromise = promiseAddonInstall(amoServer, {
+ manifest: {
+ name: "My Fancy Theme",
+ description: "Test theme install button",
+ browser_specific_settings: { gecko: { id: FIRST_THEME_ID } },
+ theme: {
+ colors: {
+ tab_selected: "red",
+ },
+ },
+ },
+ });
+ let promiseThemeChange = promiseObserved("lightweight-theme-styling-update");
+ await testCardInstall(getCardByAddonId(win, FIRST_THEME_ID));
+ await installThemePromise;
+ await promiseThemeChange;
+
+ // After installing, the cards should have manage buttons instead of install
+ // buttons. The cards should still be at the top of the pane (and not be
+ // moved to the bottom).
+ Assert.deepEqual(
+ getVisibleActions(win.document).map(getActionName),
+ [
+ "manage-addon",
+ "manage-addon",
+ ...new Array(apiResultArray.length - 2).fill("install-addon"),
+ "open-amo",
+ ],
+ "The Install buttons should be replaced with Manage buttons"
+ );
+
+ // End of the testing installation from a card.
+
+ // Click on the Manage button to verify that it does something useful,
+ // and in order to be able to force the discovery pane to be rendered again.
+ let loaded = waitForViewLoad(win);
+ getCardByAddonId(win, FIRST_EXTENSION_ID)
+ .querySelector("[action='manage-addon']")
+ .click();
+ await loaded;
+ {
+ let addonCard = win.document.querySelector(
+ `addon-card[addon-id="${FIRST_EXTENSION_ID}"]`
+ );
+ ok(addonCard, "Add-on details should be shown");
+ ok(addonCard.expanded, "The card should have been expanded");
+ // TODO bug 1540253: Check that the "recommended" badge is visible.
+ }
+
+ // Now we are going to force an updated rendering and check that the cards are
+ // in the expected order, and then test uninstallation of the above add-ons.
+ await switchToDiscoView(win);
+ await waitForAllImagesLoaded(win);
+
+ Assert.deepEqual(
+ getVisibleActions(win.document).map(getActionName),
+ [
+ ...new Array(apiResultArray.length - 2).fill("install-addon"),
+ "manage-addon",
+ "manage-addon",
+ "open-amo",
+ ],
+ "Already-installed add-ons should be rendered at the end of the list"
+ );
+
+ promiseThemeChange = promiseObserved("lightweight-theme-styling-update");
+ await testAddonUninstall(getCardByAddonId(win, FIRST_THEME_ID));
+ await promiseThemeChange;
+ await testAddonUninstall(getCardByAddonId(win, FIRST_EXTENSION_ID));
+
+ is(apiHandler.requestCount, 1, "Discovery API should be fetched once");
+
+ await closeView(win);
+});
+
+// Tests that the page is able to switch views while the discopane is loading,
+// without inadvertently replacing the page when the request finishes.
+add_task(async function discopane_navigate_while_loading() {
+ let apiHandler = new DiscoveryAPIHandler(`{"results": []}`);
+
+ apiHandler.blockNextResponses();
+ let win = await loadInitialView("discover");
+
+ let updatePromise = promiseDiscopaneUpdate(win);
+ let didUpdateDiscopane = false;
+ updatePromise.then(() => {
+ didUpdateDiscopane = true;
+ });
+
+ // Switch views while the request is pending.
+ await switchToNonDiscoView(win);
+
+ is(
+ didUpdateDiscopane,
+ false,
+ "discopane should still not be updated because the request is blocked"
+ );
+ is(
+ getDiscoveryElement(win),
+ null,
+ "Discopane should be removed after switching to the extension list"
+ );
+
+ // Release pending requests, to verify that completing the request will not
+ // cause changes to the visible view. The updatePromise will still resolve
+ // though, because the event is dispatched to the removed `<discovery-pane>`.
+ apiHandler.unblockResponses();
+
+ await updatePromise;
+ ok(
+ win.document.querySelector("addon-list"),
+ "Should still be at the extension list view"
+ );
+ is(
+ getDiscoveryElement(win),
+ null,
+ "Discopane should not be in the document when it is not the active view"
+ );
+
+ is(apiHandler.requestCount, 1, "Discovery API should be fetched once");
+
+ await closeView(win);
+});
+
+// Tests that invalid responses are handled correctly and not cached.
+// Also verifies that the response is cached as long as the page is active,
+// but not when the page is fully reloaded.
+add_task(async function discopane_cache_api_responses() {
+ const INVALID_RESPONSE_BODY = `{"This is some": invalid} JSON`;
+ let apiHandler = new DiscoveryAPIHandler(INVALID_RESPONSE_BODY);
+
+ let expectedErrMsg;
+ try {
+ JSON.parse(INVALID_RESPONSE_BODY);
+ ok(false, "JSON.parse should have thrown");
+ } catch (e) {
+ expectedErrMsg = e.message;
+ }
+
+ let invalidResponseHandledPromise = new Promise(resolve => {
+ Services.console.registerListener(function listener(msg) {
+ if (msg.message.includes(expectedErrMsg)) {
+ resolve();
+ Services.console.unregisterListener(listener);
+ }
+ });
+ });
+
+ let win = await loadInitialView("discover"); // Request #1
+ await promiseDiscopaneUpdate(win);
+
+ info("Waiting for expected error");
+ await invalidResponseHandledPromise;
+ is(apiHandler.requestCount, 1, "Discovery API should be fetched once");
+
+ Assert.deepEqual(
+ getVisibleActions(win.document).map(getActionName),
+ ["open-amo"],
+ "The AMO button should be visible even when the response was invalid"
+ );
+
+ // Change to a valid response, so that the next response will be cached.
+ apiHandler.setResponseText(`{"results": []}`);
+
+ await switchToNonDiscoView(win);
+ await switchToDiscoView(win); // Request #2
+
+ is(
+ apiHandler.requestCount,
+ 2,
+ "Should fetch new data because an invalid response should not be cached"
+ );
+
+ await switchToNonDiscoView(win);
+ await switchToDiscoView(win);
+ await closeView(win);
+
+ is(
+ apiHandler.requestCount,
+ 2,
+ "The previous response was valid and should have been reused"
+ );
+
+ // Now open a new about:addons page and verify that a new API request is sent.
+ let anotherWin = await loadInitialView("discover");
+ await promiseDiscopaneUpdate(anotherWin);
+ await closeView(anotherWin);
+
+ is(apiHandler.requestCount, 3, "discovery API should be requested again");
+});
+
+add_task(async function discopane_no_cookies() {
+ let requestPromise = new Promise(resolve => {
+ amoServer.registerPathHandler("/discoapi", resolve);
+ });
+ Services.cookies.add(
+ AMO_TEST_HOST,
+ "/",
+ "name",
+ "value",
+ false,
+ false,
+ false,
+ Date.now() / 1000 + 600,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+ let win = await loadInitialView("discover");
+ let request = await requestPromise;
+ ok(!request.hasHeader("Cookie"), "discovery API should not receive cookies");
+ await closeView(win);
+});
+
+// The CSP of about:addons whitelists http:, but not data:, hence we are
+// loading a little red data: image which gets blocked by the CSP.
+add_task(async function csp_img_src() {
+ const RED_DATA_IMAGE =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAA" +
+ "AHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";
+
+ // Minimal API response to get the image in recommended-addon-card to render.
+ const DUMMY_EXTENSION_ID = "dummy-csp@extensionid";
+ const apiResponse = {
+ results: [
+ {
+ addon: {
+ guid: DUMMY_EXTENSION_ID,
+ type: "extension",
+ authors: [
+ {
+ name: "Some CSP author",
+ },
+ ],
+ url: `http://${AMO_TEST_HOST}/dummy`,
+ icon_url: RED_DATA_IMAGE,
+ },
+ },
+ ],
+ };
+
+ let apiHandler = new DiscoveryAPIHandler(JSON.stringify(apiResponse));
+ apiHandler.blockNextResponses();
+ let win = await loadInitialView("discover");
+
+ let cspPromise = new Promise(resolve => {
+ win.addEventListener("securitypolicyviolation", e => {
+ // non http(s) loads only report the scheme
+ is(e.blockedURI, "data", "CSP: blocked URI");
+ is(e.violatedDirective, "img-src", "CSP: violated directive");
+ resolve();
+ });
+ });
+
+ apiHandler.unblockResponses();
+ await cspPromise;
+
+ await closeView(win);
+});
+
+add_task(async function checkDiscopaneNotice() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.discovery.enabled", true],
+ // Enabling the Data Upload pref may upload data.
+ // Point data reporting services to localhost so the data doesn't escape.
+ ["toolkit.telemetry.server", "https://localhost:1337"],
+ ["telemetry.fog.test.localhost_port", -1],
+ ["datareporting.healthreport.uploadEnabled", true],
+ ["extensions.htmlaboutaddons.recommendations.enabled", true],
+ ["extensions.recommendations.hideNotice", false],
+ ],
+ });
+
+ let win = await loadInitialView("extension");
+ let messageBar = win.document.querySelector("message-bar.discopane-notice");
+ ok(messageBar, "Recommended notice should exist in extensions view");
+ await switchToDiscoView(win);
+ messageBar = win.document.querySelector("message-bar.discopane-notice");
+ ok(messageBar, "Recommended notice should exist in disco view");
+
+ messageBar.closeButton.click();
+ messageBar = win.document.querySelector("message-bar.discopane-notice");
+ ok(!messageBar, "Recommended notice should not exist in disco view");
+ await switchToNonDiscoView(win);
+ messageBar = win.document.querySelector("message-bar.discopane-notice");
+ ok(!messageBar, "Recommended notice should not exist in extensions view");
+
+ await closeView(win);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_clientid.js b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_clientid.js
new file mode 100644
index 0000000000..034a42be5a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_clientid.js
@@ -0,0 +1,219 @@
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+const { ClientID } = ChromeUtils.importESModule(
+ "resource://gre/modules/ClientID.sys.mjs"
+);
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+const server = AddonTestUtils.createHttpServer();
+const serverBaseUrl = `http://localhost:${server.identity.primaryPort}/`;
+server.registerPathHandler("/sumo/personalized-addons", (request, response) => {
+ response.write("This is a SUMO page that explains personalized add-ons.");
+});
+
+// Before a discovery API request is triggered, this method should be called.
+// Resolves with the value of the "telemetry-client-id" query parameter.
+async function promiseOneDiscoveryApiRequest() {
+ return new Promise(resolve => {
+ let requestCount = 0;
+ // Overwrite previous request handler, if any.
+ server.registerPathHandler("/discoapi", (request, response) => {
+ is(++requestCount, 1, "Expecting one discovery API request");
+ response.write(`{"results": []}`);
+ let searchParams = new URLSearchParams(request.queryString);
+ let clientId = searchParams.get("telemetry-client-id");
+ resolve(clientId);
+ });
+ });
+}
+
+function getNoticeButton(win) {
+ return win.document.querySelector("[action='notice-learn-more']");
+}
+
+function isNoticeVisible(win) {
+ let message = win.document.querySelector("taar-notice");
+ return message && message.offsetHeight > 0;
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Enable clientid - see Discovery.jsm for the first two prefs.
+ ["browser.discovery.enabled", true],
+ // Enabling the Data Upload pref may upload data.
+ // Point data reporting services to localhost so the data doesn't escape.
+ ["toolkit.telemetry.server", "https://localhost:1337"],
+ ["telemetry.fog.test.localhost_port", -1],
+ ["datareporting.healthreport.uploadEnabled", true],
+ ["extensions.getAddons.discovery.api_url", `${serverBaseUrl}discoapi`],
+ ["app.support.baseURL", `${serverBaseUrl}sumo/`],
+ // Discovery API requests can be triggered by the discopane and the
+ // recommendations in the list view. To make sure that the every test
+ // checks the behavior of the view they're testing, ensure that only one
+ // of the two views is enabled at a time.
+ ["extensions.htmlaboutaddons.recommendations.enabled", false],
+ ],
+ });
+});
+
+// Test that the clientid is passed to the API when enabled via prefs.
+add_task(async function clientid_enabled() {
+ let EXPECTED_CLIENT_ID = await ClientID.getClientIdHash();
+ ok(EXPECTED_CLIENT_ID, "ClientID should be available");
+
+ let requestPromise = promiseOneDiscoveryApiRequest();
+ let win = await loadInitialView("discover");
+
+ ok(isNoticeVisible(win), "Notice about personalization should be visible");
+
+ // TODO: This should ideally check whether the result is the expected ID.
+ // But run with --verify, the test may fail with EXPECTED_CLIENT_ID being
+ // "baae8d197cf6b0865d7ba7ddf83829cd2d9844374d7271a5c704199d91059316",
+ // which is sha256(TelemetryUtils.knownClientId).
+ // This happens because at the end of the test, the pushPrefEnv from setup is
+ // reverted, which resets datareporting.healthreport.uploadEnabled to false.
+ // When TelemetryController.sys.mjs detects this, it asynchronously resets the
+ // ClientID to knownClientId - which may happen at the next run of the test.
+ // TODO: Fix this together with bug 1537933
+ //
+ // is(await requestPromise, EXPECTED_CLIENT_ID,
+ ok(
+ await requestPromise,
+ "Moz-Client-Id should be set when telemetry & discovery are enabled"
+ );
+
+ let tabbrowser = win.windowRoot.ownerGlobal.gBrowser;
+ let expectedUrl = `${serverBaseUrl}sumo/personalized-addons`;
+ let tabPromise = BrowserTestUtils.waitForNewTab(tabbrowser, expectedUrl);
+
+ getNoticeButton(win).click();
+
+ info(`Waiting for new tab with URL: ${expectedUrl}`);
+ let tab = await tabPromise;
+ BrowserTestUtils.removeTab(tab);
+
+ await closeView(win);
+});
+
+// Test that the clientid is not sent when disabled via prefs.
+add_task(async function clientid_disabled() {
+ // Temporarily override the prefs that we had set in setup.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.discovery.enabled", false]],
+ });
+ let requestPromise = promiseOneDiscoveryApiRequest();
+ let win = await loadInitialView("discover");
+ ok(!isNoticeVisible(win), "Notice about personalization should be hidden");
+ is(
+ await requestPromise,
+ null,
+ "Moz-Client-Id should not be sent when discovery is disabled"
+ );
+ await closeView(win);
+ await SpecialPowers.popPrefEnv();
+});
+
+// Test that the clientid is not sent from private windows.
+add_task(async function clientid_from_private_window() {
+ let privateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ let requestPromise = promiseOneDiscoveryApiRequest();
+ let managerWindow = await open_manager(
+ "addons://discover/",
+ null,
+ null,
+ null,
+ privateWindow
+ );
+ ok(
+ PrivateBrowsingUtils.isContentWindowPrivate(managerWindow),
+ "Addon-manager is in a private window"
+ );
+
+ is(
+ await requestPromise,
+ null,
+ "Moz-Client-Id should not be sent in private windows"
+ );
+
+ await close_manager(managerWindow);
+ await BrowserTestUtils.closeWindow(privateWindow);
+});
+
+add_task(async function clientid_enabled_from_extension_list() {
+ await SpecialPowers.pushPrefEnv({
+ // Override prefs from setup to enable recommendations.
+ set: [
+ ["extensions.htmlaboutaddons.recommendations.enabled", true],
+ ["extensions.getAddons.showPane", false],
+ ],
+ });
+
+ // Force the extension list to be the first load. This pref will be
+ // overwritten once the view loads.
+ Services.prefs.setCharPref(PREF_UI_LASTCATEGORY, "addons://list/extension");
+
+ let requestPromise = promiseOneDiscoveryApiRequest();
+ let win = await loadInitialView("extension");
+
+ ok(isNoticeVisible(win), "Notice about personalization should be visible");
+
+ ok(
+ await requestPromise,
+ "Moz-Client-Id should be set when telemetry & discovery are enabled"
+ );
+
+ // Make sure switching to the theme view doesn't trigger another request.
+ await switchView(win, "theme");
+
+ // Wait until the request would have happened so promiseOneDiscoveryApiRequest
+ // can fail if it does.
+ let recommendations = win.document.querySelector("recommended-addon-list");
+ await recommendations.loadCardsIfNeeded();
+
+ await closeView(win);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function clientid_enabled_from_theme_list() {
+ await SpecialPowers.pushPrefEnv({
+ // Override prefs from setup to enable recommendations.
+ set: [
+ ["extensions.htmlaboutaddons.recommendations.enabled", true],
+ ["extensions.getAddons.showPane", false],
+ ],
+ });
+
+ // Force the theme list to be the first load. This pref will be overwritten
+ // once the view loads.
+ Services.prefs.setCharPref(PREF_UI_LASTCATEGORY, "addons://list/theme");
+
+ let requestPromise = promiseOneDiscoveryApiRequest();
+ let win = await loadInitialView("theme");
+
+ ok(!isNoticeVisible(win), "Notice about personalization should be hidden");
+
+ is(
+ await requestPromise,
+ null,
+ "Moz-Client-Id should not be sent when loading themes initially"
+ );
+
+ info("Load the extension list and verify the client ID is now sent");
+
+ requestPromise = promiseOneDiscoveryApiRequest();
+ await switchView(win, "extension");
+
+ ok(await requestPromise, "Moz-Client-Id is now sent for extensions");
+
+ await closeView(win);
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_prefs.js b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_prefs.js
new file mode 100644
index 0000000000..474cd424b9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_prefs.js
@@ -0,0 +1,83 @@
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+const server = AddonTestUtils.createHttpServer();
+const TEST_API_URL = `http://localhost:${server.identity.primaryPort}/discoapi`;
+
+async function checkIfDiscoverVisible(expectVisible) {
+ let requestCount = 0;
+ let requestPromise = new Promise(resolve => {
+ // Overwrites previous request handler, if any.
+ server.registerPathHandler("/discoapi", (request, response) => {
+ ++requestCount;
+ response.write(`{"results": []}`);
+ resolve();
+ });
+ });
+
+ // Open about:addons with default view.
+ let managerWindow = await open_manager(null);
+ let categoryUtilities = new CategoryUtilities(managerWindow);
+
+ is(
+ categoryUtilities.isTypeVisible("discover"),
+ expectVisible,
+ "Visibility of discopane"
+ );
+
+ await wait_for_view_load(managerWindow);
+ if (expectVisible) {
+ is(
+ categoryUtilities.selectedCategory,
+ "discover",
+ "Expected discopane as the default view"
+ );
+ await requestPromise;
+ is(requestCount, 1, "Expected discovery API request");
+ } else {
+ // The next view (after discopane) is the extension list.
+ is(
+ categoryUtilities.selectedCategory,
+ "extension",
+ "Should fall back to another view when the discopane is disabled"
+ );
+ is(requestCount, 0, "Discovery API should not be requested");
+ }
+
+ await close_manager(managerWindow);
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.getAddons.discovery.api_url", TEST_API_URL],
+ // Disable recommendations at the HTML about:addons view to avoid sending
+ // a discovery API request from the fallback view (extension list) in the
+ // showPane_false test.
+ ["extensions.htmlaboutaddons.recommendations.enabled", false],
+ ],
+ });
+});
+
+add_task(async function showPane_true() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_DISCOVER_ENABLED, true]],
+ clear: [[PREF_UI_LASTCATEGORY]],
+ });
+ await checkIfDiscoverVisible(true);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function showPane_false() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_DISCOVER_ENABLED, false]],
+ clear: [[PREF_UI_LASTCATEGORY]],
+ });
+ await checkIfDiscoverVisible(false);
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_list_view.js b/toolkit/mozapps/extensions/test/browser/browser_html_list_view.js
new file mode 100644
index 0000000000..527b8cfddc
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_list_view.js
@@ -0,0 +1,1038 @@
+/* eslint max-len: ["error", 80] */
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+let promptService;
+
+const SUPPORT_URL = Services.urlFormatter.formatURL(
+ Services.prefs.getStringPref("app.support.baseURL")
+);
+const REMOVE_SUMO_URL = SUPPORT_URL + "cant-remove-addon";
+
+function getTestCards(root) {
+ return root.querySelectorAll('addon-card[addon-id$="@mochi.test"]');
+}
+
+function getCardByAddonId(root, id) {
+ return root.querySelector(`addon-card[addon-id="${id}"]`);
+}
+
+function isEmpty(el) {
+ return !el.children.length;
+}
+
+function waitForThemeChange(list) {
+ // Wait for two move events. One theme will be enabled and another disabled.
+ let moveCount = 0;
+ return BrowserTestUtils.waitForEvent(list, "move", () => ++moveCount == 2);
+}
+
+let mockProvider;
+
+add_setup(async function () {
+ mockProvider = new MockProvider(["extension", "sitepermission"]);
+ promptService = mockPromptService();
+});
+
+let extensionsCreated = 0;
+
+function createExtensions(manifestExtras) {
+ return manifestExtras.map(extra =>
+ ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test extension",
+ browser_specific_settings: {
+ gecko: { id: `test-${extensionsCreated++}@mochi.test` },
+ },
+ icons: {
+ 32: "test-icon.png",
+ },
+ ...extra,
+ },
+ useAddonManager: "temporary",
+ })
+ );
+}
+
+add_task(async function testExtensionList() {
+ let id = "test@mochi.test";
+ let headingId = "test_mochi_test-heading";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test extension",
+ browser_specific_settings: { gecko: { id } },
+ icons: {
+ 32: "test-icon.png",
+ },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let addon = await AddonManager.getAddonByID(id);
+ ok(addon, "The add-on can be found");
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // Find the addon-list to listen for events.
+ let list = doc.querySelector("addon-list");
+
+ // There shouldn't be any disabled extensions.
+ let disabledSection = getSection(doc, "extension-disabled-section");
+ ok(isEmpty(disabledSection), "The disabled section is empty");
+
+ // The loaded extension should be in the enabled list.
+ let enabledSection = getSection(doc, "extension-enabled-section");
+ ok(
+ enabledSection && !isEmpty(enabledSection),
+ "The enabled section isn't empty"
+ );
+ let card = getCardByAddonId(enabledSection, id);
+ ok(card, "The card is in the enabled section");
+
+ // Check the properties of the card.
+ is(card.addonNameEl.textContent, "Test extension", "The name is set");
+ is(
+ card.querySelector("h3").id,
+ headingId,
+ "The add-on name has the correct id"
+ );
+ is(
+ card.querySelector(".card").getAttribute("aria-labelledby"),
+ headingId,
+ "The card is labelled by the heading"
+ );
+ let icon = card.querySelector(".addon-icon");
+ ok(icon.src.endsWith("/test-icon.png"), "The icon is set");
+
+ // Disable the extension.
+ let disableToggle = card.querySelector('[action="toggle-disabled"]');
+ ok(disableToggle.pressed, "The disable toggle is pressed");
+ is(
+ doc.l10n.getAttributes(disableToggle).id,
+ "extension-enable-addon-button-label",
+ "The toggle has the enable label"
+ );
+ ok(disableToggle.getAttribute("aria-label"), "There's an aria-label");
+ ok(!disableToggle.hidden, "The toggle is visible");
+
+ let disabled = BrowserTestUtils.waitForEvent(list, "move");
+ disableToggle.click();
+ await disabled;
+ is(
+ card.parentNode,
+ disabledSection,
+ "The card is now in the disabled section"
+ );
+
+ // The disable button is now enabled.
+ ok(!disableToggle.pressed, "The disable toggle is not pressed");
+ is(
+ doc.l10n.getAttributes(disableToggle).id,
+ "extension-enable-addon-button-label",
+ "The button has the same enable label"
+ );
+ ok(disableToggle.getAttribute("aria-label"), "There's an aria-label");
+
+ // Remove the add-on.
+ let removeButton = card.querySelector('[action="remove"]');
+ is(
+ doc.l10n.getAttributes(removeButton).id,
+ "remove-addon-button",
+ "The button has the remove label"
+ );
+ // There is a support link when the add-on isn't removeable, verify we don't
+ // always include one.
+ ok(!removeButton.querySelector("a"), "There isn't a link in the item");
+
+ // Remove but cancel.
+ let cancelled = BrowserTestUtils.waitForEvent(card, "remove-cancelled");
+ removeButton.click();
+ await cancelled;
+
+ let removed = BrowserTestUtils.waitForEvent(list, "remove");
+ // Tell the mock prompt service that the prompt was accepted.
+ promptService._response = 0;
+ removeButton.click();
+ await removed;
+
+ addon = await AddonManager.getAddonByID(id);
+ ok(
+ addon && !!(addon.pendingOperations & AddonManager.PENDING_UNINSTALL),
+ "The addon is pending uninstall"
+ );
+
+ // Ensure that a pending uninstall bar has been created for the
+ // pending uninstall extension, and pressing the undo button will
+ // refresh the list and render a card to the re-enabled extension.
+ assertHasPendingUninstalls(list, 1);
+ assertHasPendingUninstallAddon(list, addon);
+
+ // Add a second pending uninstall extension.
+ info("Install a second test extension and wait for addon card rendered");
+ let added = BrowserTestUtils.waitForEvent(list, "add");
+ const extension2 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test extension 2",
+ browser_specific_settings: { gecko: { id: "test-2@mochi.test" } },
+ icons: {
+ 32: "test-icon.png",
+ },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension2.startup();
+
+ await added;
+ ok(
+ getCardByAddonId(list, extension2.id),
+ "Got a card added for the second extension"
+ );
+
+ info("Uninstall the second test extension and wait for addon card removed");
+ removed = BrowserTestUtils.waitForEvent(list, "remove");
+ const addon2 = await AddonManager.getAddonByID(extension2.id);
+ addon2.uninstall(true);
+ await removed;
+
+ ok(
+ !getCardByAddonId(list, extension2.id),
+ "Addon card for the second extension removed"
+ );
+
+ assertHasPendingUninstalls(list, 2);
+ assertHasPendingUninstallAddon(list, addon2);
+
+ // Addon2 was enabled before entering the pending uninstall state,
+ // wait for its startup after pressing undo.
+ let addon2Started = AddonTestUtils.promiseWebExtensionStartup(addon2.id);
+ await testUndoPendingUninstall(list, addon);
+ await testUndoPendingUninstall(list, addon2);
+ info("Wait for the second pending uninstal add-ons startup");
+ await addon2Started;
+
+ ok(
+ getCardByAddonId(disabledSection, addon.id),
+ "The card for the first extension is in the disabled section"
+ );
+ ok(
+ getCardByAddonId(enabledSection, addon2.id),
+ "The card for the second extension is in the enabled section"
+ );
+
+ await extension2.unload();
+ await extension.unload();
+
+ // Install a theme and verify that it is not listed in the pending
+ // uninstall message bars while the list extensions view is loaded.
+ const themeXpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ name: "My theme",
+ browser_specific_settings: { gecko: { id: "theme@mochi.test" } },
+ theme: {},
+ },
+ });
+ const themeAddon = await AddonManager.installTemporaryAddon(themeXpi);
+ // Leave it pending uninstall, the following assertions related to
+ // the pending uninstall message bars will fail if the theme is listed.
+ await themeAddon.uninstall(true);
+
+ // Install a third addon to verify that is being fully removed once the
+ // about:addons page is closed.
+ const xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ name: "Test extension 3",
+ browser_specific_settings: { gecko: { id: "test-3@mochi.test" } },
+ icons: {
+ 32: "test-icon.png",
+ },
+ },
+ });
+
+ added = BrowserTestUtils.waitForEvent(list, "add");
+ const addon3 = await AddonManager.installTemporaryAddon(xpi);
+ await added;
+ ok(
+ getCardByAddonId(list, addon3.id),
+ "Addon card for the third extension added"
+ );
+
+ removed = BrowserTestUtils.waitForEvent(list, "remove");
+ addon3.uninstall(true);
+ await removed;
+ ok(
+ !getCardByAddonId(list, addon3.id),
+ "Addon card for the third extension removed"
+ );
+
+ assertHasPendingUninstalls(list, 1);
+ ok(
+ addon3 && !!(addon3.pendingOperations & AddonManager.PENDING_UNINSTALL),
+ "The third addon is pending uninstall"
+ );
+
+ await closeView(win);
+
+ ok(
+ !(await AddonManager.getAddonByID(addon3.id)),
+ "The third addon has been fully uninstalled"
+ );
+
+ ok(
+ themeAddon.pendingOperations & AddonManager.PENDING_UNINSTALL,
+ "The theme addon is pending after the list extension view is closed"
+ );
+
+ await themeAddon.uninstall();
+
+ ok(
+ !(await AddonManager.getAddonByID(themeAddon.id)),
+ "The theme addon is fully uninstalled"
+ );
+});
+
+add_task(async function testMouseSupport() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test extension",
+ browser_specific_settings: { gecko: { id: "test@mochi.test" } },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let [card] = getTestCards(doc);
+ is(card.addon.id, "test@mochi.test", "The right card is found");
+
+ let panel = card.querySelector("panel-list");
+
+ ok(!panel.open, "The panel is initially closed");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "addon-card[addon-id$='@mochi.test'] button[action='more-options']",
+ { type: "mousedown" },
+ win.docShell.browsingContext
+ );
+ ok(panel.open, "The panel is now open");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testKeyboardSupport() {
+ let id = "test@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test extension",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // Some helpers.
+ let tab = event => EventUtils.synthesizeKey("VK_TAB", event);
+ let space = () => EventUtils.synthesizeKey(" ", {});
+ let isFocused = (el, msg) => is(doc.activeElement, el, msg);
+
+ // Find the addon-list to listen for events.
+ let list = doc.querySelector("addon-list");
+ let enabledSection = getSection(doc, "extension-enabled-section");
+ let disabledSection = getSection(doc, "extension-disabled-section");
+
+ // Find the card.
+ let [card] = getTestCards(list);
+ is(card.addon.id, "test@mochi.test", "The right card is found");
+
+ // Focus the more options menu button.
+ let moreOptionsButton = card.querySelector('[action="more-options"]');
+ moreOptionsButton.focus();
+ isFocused(moreOptionsButton, "The more options button is focused");
+
+ // Test opening and closing the menu.
+ let moreOptionsMenu = card.querySelector("panel-list");
+ let expandButton = moreOptionsMenu.querySelector('[action="expand"]');
+ let removeButton = card.querySelector('[action="remove"]');
+ is(moreOptionsMenu.open, false, "The menu is closed");
+ let shown = BrowserTestUtils.waitForEvent(moreOptionsMenu, "shown");
+ space();
+ await shown;
+ is(moreOptionsMenu.open, true, "The menu is open");
+ isFocused(removeButton, "The remove button is now focused");
+ tab({ shiftKey: true });
+ is(moreOptionsMenu.open, true, "The menu stays open");
+ isFocused(expandButton, "The focus has looped to the bottom");
+ tab();
+ is(moreOptionsMenu.open, true, "The menu stays open");
+ isFocused(removeButton, "The focus has looped to the top");
+
+ let hidden = BrowserTestUtils.waitForEvent(moreOptionsMenu, "hidden");
+ EventUtils.synthesizeKey("Escape", {});
+ await hidden;
+ isFocused(moreOptionsButton, "Escape closed the menu");
+
+ // Disable the add-on.
+ let disableButton = card.querySelector('[action="toggle-disabled"]');
+ tab({ shiftKey: true });
+ isFocused(disableButton, "The disable toggle is focused");
+ is(card.parentNode, enabledSection, "The card is in the enabled section");
+ space();
+ // Wait for the add-on state to change.
+ let [disabledAddon] = await AddonTestUtils.promiseAddonEvent("onDisabled");
+ is(disabledAddon.id, id, "The right add-on was disabled");
+ is(
+ card.parentNode,
+ enabledSection,
+ "The card is still in the enabled section"
+ );
+ isFocused(disableButton, "The disable button is still focused");
+ let moved = BrowserTestUtils.waitForEvent(list, "move");
+ // Click outside the list to clear any focus.
+ EventUtils.synthesizeMouseAtCenter(
+ doc.querySelector(".header-name"),
+ {},
+ win
+ );
+ await moved;
+ is(
+ card.parentNode,
+ disabledSection,
+ "The card moved when keyboard focus left the list"
+ );
+
+ // Remove the add-on.
+ moreOptionsButton.focus();
+ shown = BrowserTestUtils.waitForEvent(moreOptionsMenu, "shown");
+ space();
+ is(moreOptionsMenu.open, true, "The menu is open");
+ await shown;
+ isFocused(removeButton, "The remove button is focused");
+ let removed = BrowserTestUtils.waitForEvent(list, "remove");
+ space();
+ await removed;
+ is(card.parentNode, null, "The card is no longer on the page");
+
+ await extension.unload();
+ await closeView(win);
+});
+
+add_task(async function testOpenDetailFromNameKeyboard() {
+ let id = "details@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Detail extension",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+
+ let card = getCardByAddonId(win.document, id);
+
+ info("focus the add-on's name, which should be an <a>");
+ card.addonNameEl.focus();
+
+ let detailsLoaded = waitForViewLoad(win);
+ EventUtils.synthesizeKey("KEY_Enter", {}, win);
+ await detailsLoaded;
+
+ card = getCardByAddonId(win.document, id);
+ is(
+ card.addonNameEl.textContent,
+ "Detail extension",
+ "The right detail view is laoded"
+ );
+
+ await extension.unload();
+ await closeView(win);
+});
+
+add_task(async function testExtensionReordering() {
+ let extensions = createExtensions([
+ { name: "Extension One" },
+ { name: "This is last" },
+ { name: "An extension, is first" },
+ ]);
+
+ await Promise.all(extensions.map(extension => extension.startup()));
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // Get a reference to the addon-list for events.
+ let list = doc.querySelector("addon-list");
+
+ // Find the related cards, they should all have @mochi.test ids.
+ let enabledSection = getSection(doc, "extension-enabled-section");
+ let cards = getTestCards(enabledSection);
+
+ is(cards.length, 3, "Each extension has an addon-card");
+
+ let order = Array.from(cards).map(card => card.addon.name);
+ Assert.deepEqual(
+ order,
+ ["An extension, is first", "Extension One", "This is last"],
+ "The add-ons are sorted by name"
+ );
+
+ // Disable the second extension.
+ let disabledSection = getSection(doc, "extension-disabled-section");
+ ok(isEmpty(disabledSection), "The disabled section is initially empty");
+
+ // Disable the add-ons in a different order.
+ let reorderedCards = [cards[1], cards[0], cards[2]];
+ for (let { addon } of reorderedCards) {
+ let moved = BrowserTestUtils.waitForEvent(list, "move");
+ await addon.disable();
+ await moved;
+ }
+
+ order = Array.from(getTestCards(disabledSection)).map(
+ card => card.addon.name
+ );
+ Assert.deepEqual(
+ order,
+ ["An extension, is first", "Extension One", "This is last"],
+ "The add-ons are sorted by name"
+ );
+
+ // All of our installed add-ons are disabled, install a new one.
+ let [newExtension] = createExtensions([{ name: "Extension New" }]);
+ let added = BrowserTestUtils.waitForEvent(list, "add");
+ await newExtension.startup();
+ await added;
+
+ let [newCard] = getTestCards(enabledSection);
+ is(
+ newCard.addon.name,
+ "Extension New",
+ "The new add-on is in the enabled list"
+ );
+
+ // Enable everything again.
+ for (let { addon } of cards) {
+ let moved = BrowserTestUtils.waitForEvent(list, "move");
+ await addon.enable();
+ await moved;
+ }
+
+ order = Array.from(getTestCards(enabledSection)).map(card => card.addon.name);
+ Assert.deepEqual(
+ order,
+ [
+ "An extension, is first",
+ "Extension New",
+ "Extension One",
+ "This is last",
+ ],
+ "The add-ons are sorted by name"
+ );
+
+ // Remove the new extension.
+ let removed = BrowserTestUtils.waitForEvent(list, "remove");
+ await newExtension.unload();
+ await removed;
+ is(newCard.parentNode, null, "The new card has been removed");
+
+ await Promise.all(extensions.map(extension => extension.unload()));
+ await closeView(win);
+});
+
+add_task(async function testThemeList() {
+ let theme = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "theme@mochi.test" } },
+ name: "My theme",
+ theme: {},
+ },
+ useAddonManager: "temporary",
+ });
+
+ let win = await loadInitialView("theme");
+ let doc = win.document;
+
+ let list = doc.querySelector("addon-list");
+
+ let cards = getTestCards(list);
+ is(cards.length, 0, "There are no test themes to start");
+
+ let added = BrowserTestUtils.waitForEvent(list, "add");
+ await theme.startup();
+ await added;
+
+ cards = getTestCards(list);
+ is(cards.length, 1, "There is now one custom theme");
+
+ let [card] = cards;
+ is(card.addon.name, "My theme", "The card is for the test theme");
+
+ let enabledSection = getSection(doc, "theme-enabled-section");
+ let disabledSection = getSection(doc, "theme-disabled-section");
+
+ await TestUtils.waitForCondition(
+ () => enabledSection.querySelectorAll("addon-card").length == 1
+ );
+
+ is(
+ card.parentNode,
+ enabledSection,
+ "The new theme card is in the enabled section"
+ );
+ is(
+ enabledSection.querySelectorAll("addon-card").length,
+ 1,
+ "There is one enabled theme"
+ );
+
+ let toggleThemeEnabled = async () => {
+ let themesChanged = waitForThemeChange(list);
+ card.querySelector('[action="toggle-disabled"]').click();
+ await themesChanged;
+
+ await TestUtils.waitForCondition(
+ () => enabledSection.querySelectorAll("addon-card").length == 1
+ );
+ };
+
+ await toggleThemeEnabled();
+
+ is(
+ card.parentNode,
+ disabledSection,
+ "The card is now in the disabled section"
+ );
+ is(
+ enabledSection.querySelectorAll("addon-card").length,
+ 1,
+ "There is one enabled theme"
+ );
+
+ // Re-enable the theme.
+ await toggleThemeEnabled();
+ is(card.parentNode, enabledSection, "Card is back in the Enabled section");
+
+ // Remove theme and verify that the default theme is re-enabled.
+ let removed = BrowserTestUtils.waitForEvent(list, "remove");
+ // Confirm removal.
+ promptService._response = 0;
+ card.querySelector('[action="remove"]').click();
+ await removed;
+ is(card.parentNode, null, "Card has been removed from the view");
+ await TestUtils.waitForCondition(
+ () => enabledSection.querySelectorAll("addon-card").length == 1
+ );
+
+ let defaultTheme = getCardByAddonId(doc, "default-theme@mozilla.org");
+ is(defaultTheme.parentNode, enabledSection, "The default theme is reenabled");
+
+ await testUndoPendingUninstall(list, card.addon);
+ await TestUtils.waitForCondition(
+ () => enabledSection.querySelectorAll("addon-card").length == 1
+ );
+ is(defaultTheme.parentNode, disabledSection, "The default theme is disabled");
+ ok(getCardByAddonId(enabledSection, theme.id), "Theme should be reenabled");
+
+ await theme.unload();
+ await closeView(win);
+});
+
+add_task(async function testBuiltInThemeButtons() {
+ let win = await loadInitialView("theme");
+ let doc = win.document;
+
+ // Find the addon-list to listen for events.
+ let list = doc.querySelector("addon-list");
+ let enabledSection = getSection(doc, "theme-enabled-section");
+ let disabledSection = getSection(doc, "theme-disabled-section");
+
+ let defaultTheme = getCardByAddonId(doc, "default-theme@mozilla.org");
+ let darkTheme = getCardByAddonId(doc, "firefox-compact-dark@mozilla.org");
+
+ // Check that themes are in the expected spots.
+ is(defaultTheme.parentNode, enabledSection, "The default theme is enabled");
+ is(darkTheme.parentNode, disabledSection, "The dark theme is disabled");
+
+ // The default theme shouldn't have remove or disable options.
+ let defaultButtons = {
+ toggleDisabled: defaultTheme.querySelector('[action="toggle-disabled"]'),
+ remove: defaultTheme.querySelector('[action="remove"]'),
+ };
+ is(defaultButtons.toggleDisabled.hidden, true, "Disable is hidden");
+ is(defaultButtons.remove.hidden, true, "Remove is hidden");
+
+ // The dark theme should have an enable button, but not remove.
+ let darkButtons = {
+ toggleDisabled: darkTheme.querySelector('[action="toggle-disabled"]'),
+ remove: darkTheme.querySelector('[action="remove"]'),
+ };
+ is(darkButtons.toggleDisabled.hidden, false, "Enable is visible");
+ is(darkButtons.remove.hidden, true, "Remove is hidden");
+
+ // Enable the dark theme and check the buttons again.
+ let themesChanged = waitForThemeChange(list);
+ darkButtons.toggleDisabled.click();
+ await themesChanged;
+
+ await TestUtils.waitForCondition(
+ () => enabledSection.querySelectorAll("addon-card").length == 1
+ );
+
+ // Check the buttons.
+ is(defaultButtons.toggleDisabled.hidden, false, "Enable is visible");
+ is(defaultButtons.remove.hidden, true, "Remove is hidden");
+ is(darkButtons.toggleDisabled.hidden, false, "Disable is visible");
+ is(darkButtons.remove.hidden, true, "Remove is hidden");
+
+ // Disable the dark theme.
+ themesChanged = waitForThemeChange(list);
+ darkButtons.toggleDisabled.click();
+ await themesChanged;
+
+ await TestUtils.waitForCondition(
+ () => enabledSection.querySelectorAll("addon-card").length == 1
+ );
+
+ // The themes are back to their starting posititons.
+ is(defaultTheme.parentNode, enabledSection, "Default is enabled");
+ is(darkTheme.parentNode, disabledSection, "Dark is disabled");
+
+ await closeView(win);
+});
+
+add_task(async function testSideloadRemoveButton() {
+ const id = "sideload@mochi.test";
+ mockProvider.createAddons([
+ {
+ id,
+ name: "Sideloaded",
+ permissions: 0,
+ },
+ ]);
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let card = getCardByAddonId(doc, id);
+
+ let moreOptionsPanel = card.querySelector("panel-list");
+ let moreOptionsButton = card.querySelector('[action="more-options"]');
+ let panelOpened = BrowserTestUtils.waitForEvent(moreOptionsPanel, "shown");
+ EventUtils.synthesizeMouseAtCenter(moreOptionsButton, {}, win);
+ await panelOpened;
+
+ // Verify the remove button is visible with a SUMO link.
+ let removeButton = card.querySelector('[action="remove"]');
+ ok(removeButton.disabled, "Remove is disabled");
+ ok(!removeButton.hidden, "Remove is visible");
+
+ // Remove but cancel.
+ let prevented = BrowserTestUtils.waitForEvent(card, "remove-disabled");
+ removeButton.click();
+ await prevented;
+
+ // reopen the panel
+ panelOpened = BrowserTestUtils.waitForEvent(moreOptionsPanel, "shown");
+ EventUtils.synthesizeMouseAtCenter(moreOptionsButton, {}, win);
+ await panelOpened;
+
+ let sumoLink = removeButton.querySelector("a");
+ ok(sumoLink, "There's a link");
+ is(
+ doc.l10n.getAttributes(removeButton).id,
+ "remove-addon-disabled-button",
+ "The can't remove text is shown"
+ );
+ sumoLink.focus();
+ is(doc.activeElement, sumoLink, "The link can be focused");
+
+ let newTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, REMOVE_SUMO_URL);
+ sumoLink.click();
+ BrowserTestUtils.removeTab(await newTabOpened);
+
+ await closeView(win);
+});
+
+add_task(async function testOnlyTypeIsShown() {
+ let win = await loadInitialView("theme");
+ let doc = win.document;
+
+ // Find the addon-list to listen for events.
+ let list = doc.querySelector("addon-list");
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test extension",
+ browser_specific_settings: { gecko: { id: "test@mochi.test" } },
+ },
+ useAddonManager: "temporary",
+ });
+
+ let skipped = BrowserTestUtils.waitForEvent(
+ list,
+ "skip-add",
+ e => e.detail == "type-mismatch"
+ );
+ await extension.startup();
+ await skipped;
+
+ let cards = getTestCards(list);
+ is(cards.length, 0, "There are no test extension cards");
+
+ await extension.unload();
+ await closeView(win);
+});
+
+add_task(async function testPluginIcons() {
+ const pluginIconUrl = "chrome://global/skin/icons/plugin.svg";
+
+ let win = await loadInitialView("plugin");
+ let doc = win.document;
+
+ // Check that the icons are set to the plugin icon.
+ let icons = doc.querySelectorAll(".card-heading-icon");
+ ok(!!icons.length, "There are some plugins listed");
+
+ for (let icon of icons) {
+ is(icon.src, pluginIconUrl, "Plugins use the plugin icon");
+ }
+
+ await closeView(win);
+});
+
+add_task(async function testExtensionGenericIcon() {
+ const extensionIconUrl =
+ "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+
+ let id = "test@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test extension",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let card = getCardByAddonId(doc, id);
+ let icon = card.querySelector(".addon-icon");
+ is(icon.src, extensionIconUrl, "Extensions without icon use the generic one");
+
+ await extension.unload();
+ await closeView(win);
+});
+
+add_task(async function testSectionHeadingKeys() {
+ mockProvider.createAddons([
+ {
+ id: "test-theme",
+ name: "Test Theme",
+ type: "theme",
+ },
+ {
+ id: "test-extension-disabled",
+ name: "Test Disabled Extension",
+ type: "extension",
+ userDisabled: true,
+ },
+ {
+ id: "test-plugin-disabled",
+ name: "Test Disabled Plugin",
+ type: "plugin",
+ userDisabled: true,
+ },
+ {
+ id: "test-locale",
+ name: "Test Enabled Locale",
+ type: "locale",
+ },
+ {
+ id: "test-locale-disabled",
+ name: "Test Disabled Locale",
+ type: "locale",
+ userDisabled: true,
+ },
+ {
+ id: "test-dictionary",
+ name: "Test Enabled Dictionary",
+ type: "dictionary",
+ },
+ {
+ id: "test-dictionary-disabled",
+ name: "Test Disabled Dictionary",
+ type: "dictionary",
+ userDisabled: true,
+ },
+ {
+ id: "test-sitepermission",
+ name: "Test Enabled Site Permission",
+ type: "sitepermission",
+ },
+ {
+ id: "test-sitepermission-disabled",
+ name: "Test Disabled Site Permission",
+ type: "sitepermission",
+ userDisabled: true,
+ },
+ ]);
+
+ for (let type of [
+ "extension",
+ "theme",
+ "plugin",
+ "locale",
+ "dictionary",
+ "sitepermission",
+ ]) {
+ info(`loading view for addon type ${type}`);
+ let win = await loadInitialView(type);
+ let doc = win.document;
+
+ for (let status of ["enabled", "disabled"]) {
+ let section = getSection(doc, `${type}-${status}-section`);
+ let el = section?.querySelector(".list-section-heading");
+ isnot(el, null, `Should have ${status} heading for ${type} section`);
+ is(
+ el && doc.l10n.getAttributes(el).id,
+ win.getL10nIdMapping(`${type}-${status}-heading`),
+ `Should have correct ${status} heading for ${type} section`
+ );
+ }
+
+ await closeView(win);
+ }
+});
+
+add_task(async function testDisabledDimming() {
+ const id = "disabled@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Disable me",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let addon = await AddonManager.getAddonByID(id);
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+ let pageHeader = doc.querySelector("addon-page-header");
+
+ // Ensure there's no focus on the list.
+ EventUtils.synthesizeMouseAtCenter(pageHeader, {}, win);
+
+ const checkOpacity = (card, expected, msg) => {
+ let { opacity } = card.ownerGlobal.getComputedStyle(card.firstElementChild);
+ let normalize = val => Math.floor(val * 10);
+ is(normalize(opacity), normalize(expected), msg);
+ };
+ const waitForTransition = card =>
+ BrowserTestUtils.waitForEvent(
+ card.firstElementChild,
+ "transitionend",
+ e => e.propertyName === "opacity" && e.target.classList.contains("card")
+ );
+
+ let card = getCardByAddonId(doc, id);
+ checkOpacity(card, "1", "The opacity is 1 when enabled");
+
+ // Disable the add-on, check again.
+ let list = doc.querySelector("addon-list");
+ let moved = BrowserTestUtils.waitForEvent(list, "move");
+ await addon.disable();
+ await moved;
+
+ let disabledSection = getSection(doc, "extension-disabled-section");
+ is(card.parentNode, disabledSection, "The card is in the disabled section");
+ checkOpacity(card, "0.6", "The opacity is dimmed when disabled");
+
+ // Click on the menu button, this should un-dim the card.
+ let transitionEnded = waitForTransition(card);
+ let moreOptionsButton = card.querySelector(".more-options-button");
+ EventUtils.synthesizeMouseAtCenter(moreOptionsButton, {}, win);
+ await transitionEnded;
+ checkOpacity(card, "1", "The opacity is 1 when the menu is open");
+
+ // Close the menu, opacity should return.
+ transitionEnded = waitForTransition(card);
+ EventUtils.synthesizeMouseAtCenter(pageHeader, {}, win);
+ await transitionEnded;
+ checkOpacity(card, "0.6", "The card is dimmed again");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testEmptyMessage() {
+ let tests = [
+ {
+ type: "extension",
+ message: "Get extensions and themes on ",
+ },
+ {
+ type: "theme",
+ message: "Get extensions and themes on ",
+ },
+ {
+ type: "plugin",
+ message: "Get extensions and themes on ",
+ },
+ {
+ type: "locale",
+ message: "Get language packs on ",
+ },
+ {
+ type: "dictionary",
+ message: "Get dictionaries on ",
+ },
+ ];
+
+ for (let test of tests) {
+ let win = await loadInitialView(test.type);
+ let doc = win.document;
+ let enabledSection = getSection(doc, `${test.type}-enabled-section`);
+ let disabledSection = getSection(doc, `${test.type}-disabled-section`);
+ const message = doc.querySelector("#empty-addons-message");
+
+ // Test if the correct locale has been applied.
+ ok(
+ message.textContent.startsWith(test.message),
+ `View ${test.type} has correct empty list message`
+ );
+
+ // With at least one enabled/disabled add-on (see testSectionHeadingKeys),
+ // the message is hidden.
+ is_element_hidden(message, "Empty addons message hidden");
+
+ // The test runner (Mochitest) relies on add-ons that should not be removed.
+ // Simulate the scenario of zero add-ons by clearing all rendered sections.
+ while (enabledSection.firstChild) {
+ enabledSection.firstChild.remove();
+ }
+
+ while (disabledSection.firstChild) {
+ disabledSection.firstChild.remove();
+ }
+
+ // Message should now be displayed
+ is_element_visible(message, "Empty addons message visible");
+
+ await closeView(win);
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_list_view_recommendations.js b/toolkit/mozapps/extensions/test/browser/browser_html_list_view_recommendations.js
new file mode 100644
index 0000000000..db4067ab35
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_list_view_recommendations.js
@@ -0,0 +1,293 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+function makeResult({ guid, type }) {
+ return {
+ addon: {
+ authors: [{ name: "Some author" }],
+ current_version: {
+ files: [{ platform: "all", url: "data:," }],
+ },
+ url: "data:,",
+ guid,
+ type,
+ },
+ };
+}
+
+function mockResults() {
+ let types = ["extension", "theme", "extension", "extension", "theme"];
+ return {
+ results: types.map((type, i) =>
+ makeResult({
+ guid: `${type}${i}@mochi.test`,
+ type,
+ })
+ ),
+ };
+}
+
+add_setup(async function () {
+ let results = btoa(JSON.stringify(mockResults()));
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Disable personalized recommendations, they will break the data URI.
+ ["browser.discovery.enabled", false],
+ ["extensions.getAddons.discovery.api_url", `data:;base64,${results}`],
+ [
+ "extensions.recommendations.themeRecommendationUrl",
+ "https://example.com/theme",
+ ],
+ ],
+ });
+});
+
+function checkExtraContents(doc, type, opts = {}) {
+ let { showThemeRecommendationFooter = type === "theme" } = opts;
+ let footer = doc.querySelector("footer");
+ let amoButton = footer.querySelector('[action="open-amo"]');
+ let privacyPolicyLink = footer.querySelector(".privacy-policy-link");
+ let themeRecommendationFooter = footer.querySelector(".theme-recommendation");
+ let themeRecommendationLink =
+ themeRecommendationFooter && themeRecommendationFooter.querySelector("a");
+ let taarNotice = doc.querySelector("taar-notice");
+
+ is_element_visible(footer, "The footer is visible");
+
+ if (type == "extension") {
+ ok(taarNotice, "There is a TAAR notice");
+ is_element_visible(amoButton, "The AMO button is shown");
+ is_element_visible(privacyPolicyLink, "The privacy policy is visible");
+ } else if (type == "theme") {
+ ok(!taarNotice, "There is no TAAR notice");
+ ok(amoButton, "AMO button is shown");
+ ok(!privacyPolicyLink, "There is no privacy policy");
+ } else {
+ throw new Error(`Unknown type ${type}`);
+ }
+
+ if (showThemeRecommendationFooter) {
+ is_element_visible(
+ themeRecommendationFooter,
+ "There's a theme recommendation footer"
+ );
+ is_element_visible(themeRecommendationLink, "There's a link to the theme");
+ is(themeRecommendationLink.target, "_blank", "The link opens in a new tab");
+ is(
+ themeRecommendationLink.href,
+ "https://example.com/theme",
+ "The link goes to the pref's URL"
+ );
+ is(
+ doc.l10n.getAttributes(themeRecommendationFooter).id,
+ "recommended-theme-1",
+ "The recommendation has the right l10n-id"
+ );
+ } else {
+ ok(
+ !themeRecommendationFooter || themeRecommendationFooter.hidden,
+ "There's no theme recommendation"
+ );
+ }
+}
+
+async function installAddon({ card, recommendedList, manifestExtra = {} }) {
+ // Install an add-on to hide the card.
+ let hidden = BrowserTestUtils.waitForEvent(
+ recommendedList,
+ "card-hidden",
+ false,
+ e => e.detail.card == card
+ );
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: card.addonId } },
+ ...manifestExtra,
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ await hidden;
+ return extension;
+}
+
+async function testListRecommendations({ type, manifestExtra = {} }) {
+ let win = await loadInitialView(type);
+ let doc = win.document;
+
+ // Wait for the list to render, rendering is tested with the discovery pane.
+ let recommendedList = doc.querySelector("recommended-addon-list");
+ await recommendedList.cardsReady;
+
+ checkExtraContents(doc, type);
+
+ // Check that the cards are all for the right type.
+ let cards = doc.querySelectorAll("recommended-addon-card");
+ ok(!!cards.length, "There were some cards found");
+ for (let card of cards) {
+ is(card.discoAddon.type, type, `The card is for a ${type}`);
+ is_element_visible(card, "The card is visible");
+ }
+
+ // Install an add-on for the first card, verify it is hidden.
+ let { addonId } = cards[0];
+ ok(addonId, "The card has an addonId");
+
+ // Installing the add-on will fail since the URL doesn't point to a valid
+ // XPI.
+ let installButton = cards[0].querySelector('[action="install-addon"]');
+ let { panel } = PopupNotifications;
+ let popupId = "addon-install-failed-notification";
+ let failPromise = TestUtils.topicObserved("addon-install-failed");
+ installButton.click();
+ await failPromise;
+ // Wait for the installing popup to be hidden and leave just the error popup.
+ await BrowserTestUtils.waitForCondition(() => {
+ return panel.children.length == 1 && panel.firstElementChild.id == popupId;
+ });
+
+ // Dismiss the popup.
+ panel.firstElementChild.button.click();
+ await BrowserTestUtils.waitForPopupEvent(panel, "hidden");
+
+ let extension = await installAddon({ card: cards[0], recommendedList });
+ is_element_hidden(cards[0], "The card is now hidden");
+
+ // Switch away and back, there should still be a hidden card.
+ await closeView(win);
+ win = await loadInitialView(type);
+ doc = win.document;
+ recommendedList = doc.querySelector("recommended-addon-list");
+ await recommendedList.cardsReady;
+
+ cards = Array.from(doc.querySelectorAll("recommended-addon-card"));
+
+ let hiddenCard = cards.pop();
+ is(hiddenCard.addonId, addonId, "The expected card was found");
+ is_element_hidden(hiddenCard, "The card is still hidden");
+
+ ok(!!cards.length, "There are still some visible cards");
+ for (let card of cards) {
+ is(card.discoAddon.type, type, `The card is for a ${type}`);
+ is_element_visible(card, "The card is visible");
+ }
+
+ // Uninstall the add-on, verify the card is shown again.
+ let shown = BrowserTestUtils.waitForEvent(recommendedList, "card-shown");
+ await extension.unload();
+ await shown;
+
+ is_element_visible(hiddenCard, "The card is now shown");
+
+ await closeView(win);
+}
+
+add_task(async function testExtensionList() {
+ await testListRecommendations({ type: "extension" });
+});
+
+add_task(async function testThemeList() {
+ await testListRecommendations({
+ type: "theme",
+ manifestExtra: { theme: {} },
+ });
+});
+
+add_task(async function testInstallAllExtensions() {
+ let type = "extension";
+ let win = await loadInitialView(type);
+ let doc = win.document;
+
+ // Wait for the list to render, rendering is tested with the discovery pane.
+ let recommendedList = doc.querySelector("recommended-addon-list");
+ await recommendedList.cardsReady;
+
+ // Find more button is shown.
+ checkExtraContents(doc, type);
+
+ let cards = Array.from(doc.querySelectorAll("recommended-addon-card"));
+ is(cards.length, 3, "We found some cards");
+
+ let extensions = await Promise.all(
+ cards.map(card => installAddon({ card, recommendedList }))
+ );
+
+ // The find more on AMO button is shown.
+ checkExtraContents(doc, type);
+
+ // Uninstall one of the extensions, the button should still be shown.
+ let extension = extensions.pop();
+ let shown = BrowserTestUtils.waitForEvent(recommendedList, "card-shown");
+ await extension.unload();
+ await shown;
+
+ // The find more on AMO button is shown.
+ checkExtraContents(doc, type);
+
+ await Promise.all(extensions.map(extension => extension.unload()));
+ await closeView(win);
+});
+
+add_task(async function testError() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.getAddons.discovery.api_url", "data:,"]],
+ });
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // Wait for the list to render, rendering is tested with the discovery pane.
+ let recommendedList = doc.querySelector("recommended-addon-list");
+ await recommendedList.cardsReady;
+
+ checkExtraContents(doc, "extension");
+
+ await closeView(win);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function testThemesNoRecommendationUrl() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.recommendations.themeRecommendationUrl", ""]],
+ });
+
+ let win = await loadInitialView("theme");
+ let doc = win.document;
+
+ // Wait for the list to render, rendering is tested with the discovery pane.
+ let recommendedList = doc.querySelector("recommended-addon-list");
+ await recommendedList.cardsReady;
+
+ checkExtraContents(doc, "theme", { showThemeRecommendationFooter: false });
+
+ await closeView(win);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function testRecommendationsDisabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.htmlaboutaddons.recommendations.enabled", false]],
+ });
+
+ let types = ["extension", "theme"];
+
+ for (let type of types) {
+ let win = await loadInitialView(type);
+ let doc = win.document;
+
+ let recommendedList = doc.querySelector("recommended-addon-list");
+ ok(!recommendedList, `There are no recommendations on the ${type} page`);
+
+ await closeView(win);
+ }
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_message_bar.js b/toolkit/mozapps/extensions/test/browser/browser_html_message_bar.js
new file mode 100644
index 0000000000..b60baf8799
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_message_bar.js
@@ -0,0 +1,185 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint max-len: ["error", 80] */
+
+let htmlAboutAddonsWindow;
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+function clickElement(el) {
+ el.dispatchEvent(new CustomEvent("click"));
+}
+
+function createMessageBar(messageBarStack, { attrs, children, onclose } = {}) {
+ const win = messageBarStack.ownerGlobal;
+ const messageBar = win.document.createElementNS(HTML_NS, "message-bar");
+ if (attrs) {
+ for (const [k, v] of Object.entries(attrs)) {
+ messageBar.setAttribute(k, v);
+ }
+ }
+ if (children) {
+ if (Array.isArray(children)) {
+ messageBar.append(...children);
+ } else {
+ messageBar.append(children);
+ }
+ }
+ messageBar.addEventListener("message-bar:close", onclose, { once: true });
+ messageBarStack.append(messageBar);
+ return messageBar;
+}
+
+add_setup(async function () {
+ htmlAboutAddonsWindow = await loadInitialView("extension");
+ registerCleanupFunction(() => closeView(htmlAboutAddonsWindow));
+});
+
+add_task(async function test_message_bar_stack() {
+ const win = htmlAboutAddonsWindow;
+
+ let messageBarStack = win.document.getElementById("abuse-reports-messages");
+
+ ok(messageBarStack, "Got a message-bar-stack in HTML about:addons page");
+
+ is(
+ messageBarStack.maxMessageBarCount,
+ 3,
+ "Got the expected max-message-bar-count property"
+ );
+
+ is(
+ messageBarStack.childElementCount,
+ 0,
+ "message-bar-stack is initially empty"
+ );
+});
+
+add_task(async function test_create_message_bar_create_and_onclose() {
+ const win = htmlAboutAddonsWindow;
+ const messageBarStack = win.document.getElementById("abuse-reports-messages");
+
+ let messageEl = win.document.createElementNS(HTML_NS, "span");
+ messageEl.textContent = "A message bar text";
+ let buttonEl = win.document.createElementNS(HTML_NS, "button");
+ buttonEl.textContent = "An action button";
+
+ let messageBar;
+ let onceMessageBarClosed = new Promise(resolve => {
+ messageBar = createMessageBar(messageBarStack, {
+ children: [messageEl, buttonEl],
+ onclose: resolve,
+ });
+ });
+
+ is(
+ messageBarStack.childElementCount,
+ 1,
+ "message-bar-stack has a child element"
+ );
+ is(
+ messageBarStack.firstElementChild,
+ messageBar,
+ "newly created message-bar added as message-bar-stack child element"
+ );
+
+ const slot = messageBar.shadowRoot.querySelector("slot");
+ is(
+ slot.assignedNodes()[0],
+ messageEl,
+ "Got the expected span element assigned to the message-bar slot"
+ );
+ is(
+ slot.assignedNodes()[1],
+ buttonEl,
+ "Got the expected button element assigned to the message-bar slot"
+ );
+
+ let dismissed = BrowserTestUtils.waitForEvent(
+ messageBar,
+ "message-bar:user-dismissed"
+ );
+ info("Click the close icon on the newly created message-bar");
+ clickElement(messageBar.closeButton);
+ await dismissed;
+
+ info("Expect the onclose function to be called");
+ await onceMessageBarClosed;
+
+ is(
+ messageBarStack.childElementCount,
+ 0,
+ "message-bar-stack has no child elements"
+ );
+});
+
+add_task(async function test_max_message_bar_count() {
+ const win = htmlAboutAddonsWindow;
+ const messageBarStack = win.document.getElementById("abuse-reports-messages");
+
+ info("Create a new message-bar");
+ let messageElement = document.createElementNS(HTML_NS, "span");
+ messageElement = "message bar label";
+
+ let onceMessageBarClosed = new Promise(resolve => {
+ createMessageBar(messageBarStack, {
+ children: messageElement,
+ onclose: resolve,
+ });
+ });
+
+ is(
+ messageBarStack.childElementCount,
+ 1,
+ "message-bar-stack has the expected number of children"
+ );
+
+ info("Create 3 more message bars");
+ const allBarsPromises = [];
+ for (let i = 2; i <= 4; i++) {
+ allBarsPromises.push(
+ new Promise(resolve => {
+ createMessageBar(messageBarStack, {
+ attrs: { dismissable: "" },
+ children: [messageElement, i],
+ onclose: resolve,
+ });
+ })
+ );
+ }
+
+ info("Expect first message-bar to closed automatically");
+ await onceMessageBarClosed;
+
+ is(
+ messageBarStack.childElementCount,
+ 3,
+ "message-bar-stack has the expected number of children"
+ );
+
+ info("Click on close icon for the second message-bar");
+ clickElement(messageBarStack.firstElementChild.closeButton);
+
+ info("Expect the second message-bar to be closed");
+ await allBarsPromises[0];
+
+ is(
+ messageBarStack.childElementCount,
+ 2,
+ "message-bar-stack has the expected number of children"
+ );
+
+ info("Clear the entire message-bar-stack content");
+ messageBarStack.textContent = "";
+
+ info("Expect all the created message-bar to be closed automatically");
+ await Promise.all(allBarsPromises);
+
+ is(
+ messageBarStack.childElementCount,
+ 0,
+ "message-bar-stack has no child elements"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_options_ui.js b/toolkit/mozapps/extensions/test/browser/browser_html_options_ui.js
new file mode 100644
index 0000000000..29e4b1a4ec
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_options_ui.js
@@ -0,0 +1,650 @@
+/* eslint max-len: ["error", 80] */
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+// This test function helps to detect when an addon options browser have been
+// inserted in the about:addons page.
+function waitOptionsBrowserInserted() {
+ return new Promise(resolve => {
+ async function listener(eventName, browser) {
+ // wait for a webextension XUL browser element that is owned by the
+ // "about:addons" page.
+ if (browser.ownerGlobal.top.location.href == "about:addons") {
+ ExtensionParent.apiManager.off("extension-browser-inserted", listener);
+ resolve(browser);
+ }
+ }
+ ExtensionParent.apiManager.on("extension-browser-inserted", listener);
+ });
+}
+
+add_task(async function enableHtmlViews() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.htmlaboutaddons.inline-options.enabled", true]],
+ });
+});
+
+add_task(async function testInlineOptions() {
+ const HEIGHT_SHORT = 300;
+ const HEIGHT_TALL = 600;
+
+ let id = "inline@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ options_ui: {
+ page: "options.html",
+ },
+ },
+ files: {
+ "options.html": `
+ <html>
+ <head>
+ <style type="text/css">
+ body > p { height: ${HEIGHT_SHORT}px; margin: 0; }
+ body.bigger > p { height: ${HEIGHT_TALL}px; }
+ </style>
+ <script src="options.js"></script>
+ </head>
+ <body>
+ <p>Some text</p>
+ </body>
+ </html>
+ `,
+ "options.js": () => {
+ browser.test.onMessage.addListener(msg => {
+ if (msg == "toggle-class") {
+ document.body.classList.toggle("bigger");
+ } else if (msg == "get-height") {
+ browser.test.sendMessage("height", document.body.clientHeight);
+ }
+ });
+
+ browser.test.sendMessage("options-loaded", window.location.href);
+ },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // Make sure we found the right card.
+ let card = getAddonCard(win, id);
+ ok(card, "Found the card");
+
+ // The preferences option should be visible.
+ let preferences = card.querySelector('[action="preferences"]');
+ ok(!preferences.hidden, "The preferences option is visible");
+
+ // Open the preferences page.
+ let loaded = waitForViewLoad(win);
+ preferences.click();
+ await loaded;
+
+ // Verify we're on the preferences tab.
+ card = doc.querySelector("addon-card");
+ is(card.addon.id, id, "The right page was loaded");
+ let { deck, tabGroup } = card.details;
+ let { selectedViewName } = deck;
+ is(selectedViewName, "preferences", "The preferences tab is shown");
+
+ info("Check that there are two buttons and they're visible");
+ let detailsBtn = tabGroup.querySelector('[name="details"]');
+ ok(!detailsBtn.hidden, "The details button is visible");
+ let prefsBtn = tabGroup.querySelector('[name="preferences"]');
+ ok(!prefsBtn.hidden, "The preferences button is visible");
+
+ // Wait for the browser to load.
+ let url = await extension.awaitMessage("options-loaded");
+
+ // Check the attributes of the options browser.
+ let browser = card.querySelector("inline-options-browser browser");
+ ok(browser, "The visible view has a browser");
+ is(
+ browser.currentURI.spec,
+ card.addon.optionsURL,
+ "The browser has the expected options URL"
+ );
+ is(url, card.addon.optionsURL, "Browser has the expected options URL loaded");
+ let stack = browser.closest("stack");
+ is(
+ browser.clientWidth,
+ stack.clientWidth,
+ "Browser should be the same width as its direct parent"
+ );
+ ok(stack.clientWidth > 0, "The stack has a width");
+ ok(
+ card.querySelector('[action="preferences"]').hidden,
+ "The preferences option is hidden now"
+ );
+
+ let waitForHeightChange = expectedHeight =>
+ TestUtils.waitForCondition(() => browser.clientHeight === expectedHeight);
+
+ await waitForHeightChange(HEIGHT_SHORT);
+
+ // Check resizing the browser through extension CSS.
+ await extension.sendMessage("get-height");
+ let height = await extension.awaitMessage("height");
+ is(height, HEIGHT_SHORT, "The height is smaller to start");
+ is(height, browser.clientHeight, "The browser is the same size");
+
+ info("Resize the browser to be taller");
+ await extension.sendMessage("toggle-class");
+ await waitForHeightChange(HEIGHT_TALL);
+ await extension.sendMessage("get-height");
+ height = await extension.awaitMessage("height");
+ is(height, HEIGHT_TALL, "The height is bigger now");
+ is(height, browser.clientHeight, "The browser is the same size");
+
+ info("Shrink the browser again");
+ await extension.sendMessage("toggle-class");
+ await waitForHeightChange(HEIGHT_SHORT);
+ await extension.sendMessage("get-height");
+ height = await extension.awaitMessage("height");
+ is(height, HEIGHT_SHORT, "The browser shrunk back");
+ is(height, browser.clientHeight, "The browser is the same size");
+
+ info("Switching to details view");
+ detailsBtn.click();
+
+ info("Check the browser dimensions to make sure it's hidden");
+ is(browser.clientWidth, 0, "The browser is hidden now");
+
+ info("Switch back, check browser is shown");
+ prefsBtn.click();
+
+ is(browser.clientWidth, stack.clientWidth, "The browser width is set again");
+ ok(stack.clientWidth > 0, "The stack has a width");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+// Regression test against bug 1409697
+add_task(async function testCardRerender() {
+ let id = "rerender@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ options_ui: {
+ page: "options.html",
+ },
+ },
+ files: {
+ "options.html": `
+ <html>
+ <body>
+ <p>Some text</p>
+ </body>
+ </html>
+ `,
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let card = getAddonCard(win, id);
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = doc.querySelector("addon-card");
+
+ let browserAdded = waitOptionsBrowserInserted();
+ card.querySelector('.tab-button[name="preferences"]').click();
+ await browserAdded;
+
+ is(
+ doc.querySelectorAll("inline-options-browser").length,
+ 1,
+ "There is 1 inline-options-browser"
+ );
+ is(doc.querySelectorAll("browser").length, 1, "There is 1 browser");
+
+ info("Reload the add-on and ensure there's still only one browser");
+ let updated = BrowserTestUtils.waitForEvent(card, "update");
+ card.addon.reload();
+ await updated;
+
+ // Since the add-on was disabled, we'll be on the details tab.
+ is(card.details.deck.selectedViewName, "details", "View changed to details");
+ is(
+ doc.querySelectorAll("inline-options-browser").length,
+ 1,
+ "There is 1 inline-options-browser"
+ );
+ is(doc.querySelectorAll("browser").length, 0, "The browser was destroyed");
+
+ // Load the permissions tab again.
+ browserAdded = waitOptionsBrowserInserted();
+ card.querySelector('.tab-button[name="preferences"]').click();
+ await browserAdded;
+
+ // Switching to preferences will create a new browser element.
+ is(
+ card.details.deck.selectedViewName,
+ "preferences",
+ "View switched to preferences"
+ );
+ is(
+ doc.querySelectorAll("inline-options-browser").length,
+ 1,
+ "There is 1 inline-options-browser"
+ );
+ is(doc.querySelectorAll("browser").length, 1, "There is a new browser");
+
+ info("Re-rendering card to ensure a second browser isn't added");
+ updated = BrowserTestUtils.waitForEvent(card, "update");
+ card.render();
+ await updated;
+
+ is(
+ card.details.deck.selectedViewName,
+ "details",
+ "Rendering reverted to the details view"
+ );
+ is(
+ doc.querySelectorAll("inline-options-browser").length,
+ 1,
+ "There is still only 1 inline-options-browser after re-render"
+ );
+ is(doc.querySelectorAll("browser").length, 0, "There is no browser");
+
+ let newBrowserAdded = waitOptionsBrowserInserted();
+ card.showPrefs();
+ await newBrowserAdded;
+
+ is(
+ doc.querySelectorAll("inline-options-browser").length,
+ 1,
+ "There is still only 1 inline-options-browser after opening preferences"
+ );
+ is(doc.querySelectorAll("browser").length, 1, "There is 1 browser");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testRemovedOnDisable() {
+ let id = "disable@mochi.test";
+ const xpiFile = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ options_ui: {
+ page: "options.html",
+ },
+ },
+ files: {
+ "options.html": "<h1>Options!</h1>",
+ },
+ });
+ let addon = await AddonManager.installTemporaryAddon(xpiFile);
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // Opens the prefs page.
+ let loaded = waitForViewLoad(win);
+ getAddonCard(win, id).querySelector("[action=preferences]").click();
+ await loaded;
+
+ let inlineOptions = doc.querySelector("inline-options-browser");
+ ok(inlineOptions, "There's an inline-options-browser element");
+ ok(inlineOptions.querySelector("browser"), "The browser exists");
+
+ let card = getAddonCard(win, id);
+ let { deck } = card.details;
+ is(deck.selectedViewName, "preferences", "Preferences are the active tab");
+
+ info("Disabling the add-on");
+ let updated = BrowserTestUtils.waitForEvent(card, "update");
+ await addon.disable();
+ await updated;
+
+ is(deck.selectedViewName, "details", "Details are now the active tab");
+ ok(inlineOptions, "There's an inline-options-browser element");
+ ok(!inlineOptions.querySelector("browser"), "The browser has been removed");
+
+ info("Enabling the add-on");
+ updated = BrowserTestUtils.waitForEvent(card, "update");
+ await addon.enable();
+ await updated;
+
+ is(deck.selectedViewName, "details", "Details are still the active tab");
+ ok(inlineOptions, "There's an inline-options-browser element");
+ ok(!inlineOptions.querySelector("browser"), "The browser is not created yet");
+
+ info("Switching to preferences tab");
+ let changed = BrowserTestUtils.waitForEvent(deck, "view-changed");
+ let browserAdded = waitOptionsBrowserInserted();
+ deck.selectedViewName = "preferences";
+ await changed;
+ await browserAdded;
+
+ is(deck.selectedViewName, "preferences", "Preferences are selected");
+ ok(inlineOptions, "There's an inline-options-browser element");
+ ok(inlineOptions.querySelector("browser"), "The browser is re-created");
+
+ await closeView(win);
+ await addon.uninstall();
+});
+
+add_task(async function testUpgradeTemporary() {
+ let id = "upgrade-temporary@mochi.test";
+ async function loadExtension(version) {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ version,
+ options_ui: {
+ page: "options.html",
+ },
+ },
+ files: {
+ "options.html": `
+ <html>
+ <head>
+ <script src="options.js"></script>
+ </head>
+ <body>
+ <p>Version <pre>${version}</pre></p>
+ </body>
+ </html>
+ `,
+ "options.js": () => {
+ browser.test.onMessage.addListener(msg => {
+ if (msg === "get-version") {
+ let version = document.querySelector("pre").textContent;
+ browser.test.sendMessage("version", version);
+ }
+ });
+ window.onload = () => browser.test.sendMessage("options-loaded");
+ },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ return extension;
+ }
+
+ let firstExtension = await loadExtension("1");
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let card = getAddonCard(win, id);
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = doc.querySelector("addon-card");
+ let browserAdded = waitOptionsBrowserInserted();
+ card.querySelector('.tab-button[name="preferences"]').click();
+ await browserAdded;
+
+ await firstExtension.awaitMessage("options-loaded");
+ await firstExtension.sendMessage("get-version");
+ let version = await firstExtension.awaitMessage("version");
+ is(version, "1", "Version 1 page is loaded");
+
+ let updated = BrowserTestUtils.waitForEvent(card, "update");
+ browserAdded = waitOptionsBrowserInserted();
+ let secondExtension = await loadExtension("2");
+ await updated;
+ await browserAdded;
+ await secondExtension.awaitMessage("options-loaded");
+
+ await secondExtension.sendMessage("get-version");
+ version = await secondExtension.awaitMessage("version");
+ is(version, "2", "Version 2 page is loaded");
+ let { deck } = card.details;
+ is(deck.selectedViewName, "preferences", "Preferences are still shown");
+
+ await closeView(win);
+ await firstExtension.unload();
+ await secondExtension.unload();
+});
+
+add_task(async function testReloadExtension() {
+ let id = "reload@mochi.test";
+ let xpiFile = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ options_ui: {
+ page: "options.html",
+ },
+ },
+ files: {
+ "options.html": `
+ <html>
+ <head>
+ </head>
+ <body>
+ <p>Options</p>
+ </body>
+ </html>
+ `,
+ },
+ });
+ let addon = await AddonManager.installTemporaryAddon(xpiFile);
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let card = getAddonCard(win, id);
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = doc.querySelector("addon-card");
+ let { deck } = card.details;
+ is(deck.selectedViewName, "details", "Details load first");
+
+ let browserAdded = waitOptionsBrowserInserted();
+ card.querySelector('.tab-button[name="preferences"]').click();
+ await browserAdded;
+
+ is(deck.selectedViewName, "preferences", "Preferences are shown");
+
+ let updated = BrowserTestUtils.waitForEvent(card, "update");
+ browserAdded = waitOptionsBrowserInserted();
+ let addonStarted = AddonTestUtils.promiseWebExtensionStartup(id);
+ await addon.reload();
+ await addonStarted;
+ await updated;
+ await browserAdded;
+ is(deck.selectedViewName, "preferences", "Preferences are still shown");
+
+ await closeView(win);
+ await addon.uninstall();
+});
+
+async function testSelectPosition(optionsBrowser, zoom) {
+ let popupShownPromise = BrowserTestUtils.waitForSelectPopupShown(window);
+ await BrowserTestUtils.synthesizeMouseAtCenter("select", {}, optionsBrowser);
+ let popup = await popupShownPromise;
+ let popupLeft = popup.shadowRoot.querySelector(
+ ".menupopup-arrowscrollbox"
+ ).screenX;
+ let browserLeft = optionsBrowser.screenX * zoom;
+ ok(
+ Math.abs(popupLeft - browserLeft) <= 1,
+ `Popup should be correctly positioned: ${popupLeft} vs. ${browserLeft}`
+ );
+ popup.hidePopup();
+}
+
+async function testOptionsZoom(type = "full") {
+ let id = `${type}-zoom@mochi.test`;
+ let zoomProp = `${type}Zoom`;
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ options_ui: {
+ page: "options.html",
+ },
+ },
+ files: {
+ "options.html": `
+ <!doctype html>
+ <script src="options.js"></script>
+ <body style="height: 500px">
+ <p>Some text</p>
+ <p>
+ <select>
+ <option>A</option>
+ <option>B</option>
+ </select>
+ </p>
+ </body>
+ `,
+ "options.js": () => {
+ window.addEventListener("load", function () {
+ browser.test.sendMessage("options-loaded");
+ });
+ },
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ gBrowser.selectedBrowser[zoomProp] = 2;
+
+ let card = getAddonCard(win, id);
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = doc.querySelector("addon-card");
+
+ let browserAdded = waitOptionsBrowserInserted();
+ card.querySelector('.tab-button[name="preferences"]').click();
+ let optionsBrowser = await browserAdded;
+ // Wait for the browser to load.
+ await extension.awaitMessage("options-loaded");
+
+ is(optionsBrowser[zoomProp], 2, `Options browser inherited ${zoomProp}`);
+
+ await testSelectPosition(optionsBrowser, type == "full" ? 2 : 1);
+
+ gBrowser.selectedBrowser[zoomProp] = 0.5;
+
+ is(
+ optionsBrowser[zoomProp],
+ 0.5,
+ `Options browser reacts to ${zoomProp} change`
+ );
+
+ await closeView(win);
+ await extension.unload();
+}
+
+add_task(function testOptionsFullZoom() {
+ return testOptionsZoom("full");
+});
+
+add_task(function testOptionsTextZoom() {
+ return testOptionsZoom("text");
+});
+
+add_task(async function testInputAndQuickFind() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ options_ui: {
+ page: "options.html",
+ },
+ },
+ files: {
+ "options.html": `
+ <html>
+ <body>
+ <input name="some-input" type="text">
+ <script src="options.js"></script>
+ </body>
+ </html>
+ `,
+ "options.js": () => {
+ let input = document.querySelector("input");
+ browser.test.assertEq(
+ "some-input",
+ input.getAttribute("name"),
+ "Expected options page input"
+ );
+ input.addEventListener("input", event => {
+ browser.test.sendMessage("input-changed", event.target.value);
+ });
+
+ browser.test.sendMessage("options-loaded", window.location.href);
+ },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // Make sure we found the right card.
+ let card = getAddonCard(win, extension.id);
+ ok(card, "Found the card");
+
+ // The preferences option should be visible.
+ let preferences = card.querySelector('[action="preferences"]');
+ ok(!preferences.hidden, "The preferences option is visible");
+
+ // Open the preferences page.
+ let loaded = waitForViewLoad(win);
+ preferences.click();
+ await loaded;
+
+ // Verify we're on the preferences tab.
+ card = doc.querySelector("addon-card");
+ is(card.addon.id, extension.id, "The right page was loaded");
+
+ // Wait for the browser to load.
+ let url = await extension.awaitMessage("options-loaded");
+
+ // Check the attributes of the options browser.
+ let browser = card.querySelector("inline-options-browser browser");
+ ok(browser, "The visible view has a browser");
+ ok(card.addon.optionsURL.length, "Options URL is not empty");
+ is(
+ browser.currentURI.spec,
+ card.addon.optionsURL,
+ "The browser has the expected options URL"
+ );
+ is(url, card.addon.optionsURL, "Browser has the expected options URL loaded");
+
+ // Focus the options browser.
+ browser.focus();
+
+ // Focus the input in the options page.
+ await SpecialPowers.spawn(browser, [], () => {
+ content.document.querySelector("input").focus();
+ });
+
+ info("input in options page should be focused, typing...");
+ // Type '/'.
+ EventUtils.synthesizeKey("/");
+
+ let inputValue = await extension.awaitMessage("input-changed");
+ is(inputValue, "/", "Expected input to contain a slash");
+
+ await closeView(win);
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_options_ui_in_tab.js b/toolkit/mozapps/extensions/test/browser/browser_html_options_ui_in_tab.js
new file mode 100644
index 0000000000..68faecfec0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_options_ui_in_tab.js
@@ -0,0 +1,136 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint max-len: ["error", 80] */
+
+"use strict";
+
+add_task(async function enableHtmlViews() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.htmlaboutaddons.inline-options.enabled", true]],
+ });
+});
+
+async function testOptionsInTab({ id, options_ui_options }) {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Prefs extension",
+ browser_specific_settings: { gecko: { id } },
+ options_ui: {
+ page: "options.html",
+ ...options_ui_options,
+ },
+ },
+ background() {
+ browser.test.sendMessage(
+ "options-url",
+ browser.runtime.getURL("options.html")
+ );
+ },
+ files: {
+ "options.html": `<script src="options.js"></script>`,
+ "options.js": () => {
+ browser.test.sendMessage("options-loaded");
+ },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let optionsUrl = await extension.awaitMessage("options-url");
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+ let aboutAddonsTab = gBrowser.selectedTab;
+
+ let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+
+ let prefsBtn = card.querySelector('panel-item[action="preferences"]');
+ ok(!prefsBtn.hidden, "The button is not hidden");
+
+ info("Open the preferences page from list");
+ let tabLoaded = BrowserTestUtils.waitForNewTab(gBrowser, optionsUrl);
+ prefsBtn.click();
+ await extension.awaitMessage("options-loaded");
+ BrowserTestUtils.removeTab(await tabLoaded);
+
+ info("Load details page");
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ // Find the expanded card.
+ card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+
+ info("Check that the button is still visible");
+ prefsBtn = card.querySelector('panel-item[action="preferences"]');
+ ok(!prefsBtn.hidden, "The button is not hidden");
+
+ info("Open the preferences page from details");
+ tabLoaded = BrowserTestUtils.waitForNewTab(gBrowser, optionsUrl);
+ prefsBtn.click();
+ let prefsTab = await tabLoaded;
+ await extension.awaitMessage("options-loaded");
+
+ info("Switch back to about:addons and open prefs again");
+ await BrowserTestUtils.switchTab(gBrowser, aboutAddonsTab);
+ let tabSwitched = BrowserTestUtils.waitForEvent(gBrowser, "TabSwitchDone");
+ prefsBtn.click();
+ await tabSwitched;
+ is(gBrowser.selectedTab, prefsTab, "The prefs tab was selected");
+
+ BrowserTestUtils.removeTab(prefsTab);
+
+ await closeView(win);
+ await extension.unload();
+}
+
+add_task(async function testPreferencesLink() {
+ let id = "prefs@mochi.test";
+ await testOptionsInTab({ id, options_ui_options: { open_in_tab: true } });
+});
+
+add_task(async function testPreferencesInlineDisabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.htmlaboutaddons.inline-options.enabled", false]],
+ });
+
+ let id = "inline-disabled@mochi.test";
+ await testOptionsInTab({ id, options_ui_options: {} });
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function testNoPreferences() {
+ let id = "no-prefs@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "No Prefs extension",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+
+ info("Check button on list");
+ let prefsBtn = card.querySelector('panel-item[action="preferences"]');
+ ok(prefsBtn.hidden, "The button is hidden");
+
+ info("Load details page");
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ // Find the expanded card.
+ card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+
+ info("Check that the button is still hidden on detail");
+ prefsBtn = card.querySelector('panel-item[action="preferences"]');
+ ok(prefsBtn.hidden, "The button is hidden");
+
+ await closeView(win);
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_pending_updates.js b/toolkit/mozapps/extensions/test/browser/browser_html_pending_updates.js
new file mode 100644
index 0000000000..f3616cd080
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_pending_updates.js
@@ -0,0 +1,311 @@
+/* eslint max-len: ["error", 80] */
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const server = AddonTestUtils.createHttpServer();
+
+const LOCALE_ADDON_ID = "postponed-langpack@mochi.test";
+
+let gProvider;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.checkUpdateSecurity", false]],
+ });
+
+ // Also include a langpack with a pending postponed install.
+ const fakeLocalePostponedInstall = {
+ name: "updated langpack",
+ version: "2.0",
+ state: AddonManager.STATE_POSTPONED,
+ };
+
+ gProvider = new MockProvider();
+ gProvider.createAddons([
+ {
+ id: LOCALE_ADDON_ID,
+ name: "Postponed Langpack",
+ type: "locale",
+ version: "1.0",
+ // Mock pending upgrade property on the mocked langpack add-on.
+ pendingUpgrade: {
+ install: fakeLocalePostponedInstall,
+ },
+ },
+ ]);
+
+ fakeLocalePostponedInstall.existingAddon = gProvider.addons[0];
+ gProvider.createInstalls([fakeLocalePostponedInstall]);
+
+ registerCleanupFunction(() => {
+ cleanupPendingNotifications();
+ });
+});
+
+function createTestExtension({
+ id = "test-pending-update@test",
+ newManifest = {},
+}) {
+ function background() {
+ browser.runtime.onUpdateAvailable.addListener(() => {
+ browser.test.sendMessage("update-available");
+ });
+
+ browser.test.sendMessage("bgpage-ready");
+ }
+
+ const serverHost = `http://localhost:${server.identity.primaryPort}`;
+ const updatesPath = `/ext-updates-${id}.json`;
+ const update_url = `${serverHost}${updatesPath}`;
+
+ const manifest = {
+ name: "Test Pending Update",
+ browser_specific_settings: {
+ gecko: { id, update_url },
+ },
+ version: "1",
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest,
+ // Use permanent so the add-on can be updated.
+ useAddonManager: "permanent",
+ });
+
+ let updateXpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ ...manifest,
+ ...newManifest,
+ version: "2",
+ },
+ });
+
+ let xpiFilename = `/update-${id}.xpi`;
+ server.registerFile(xpiFilename, updateXpi);
+ AddonTestUtils.registerJSON(server, updatesPath, {
+ addons: {
+ [id]: {
+ updates: [
+ {
+ version: "2",
+ update_link: serverHost + xpiFilename,
+ },
+ ],
+ },
+ },
+ });
+
+ return { extension, updateXpi };
+}
+
+async function promiseUpdateAvailable(extension) {
+ info("Wait for the extension to receive onUpdateAvailable event");
+ await extension.awaitMessage("update-available");
+}
+
+function expectUpdatesAvailableBadgeCount({ win, expectedNumber }) {
+ const categoriesSidebar = win.document.querySelector("categories-box");
+ ok(categoriesSidebar, "Found the categories-box element");
+ const availableButton =
+ categoriesSidebar.getButtonByName("available-updates");
+ is(
+ availableButton.badgeCount,
+ 1,
+ `Expect only ${expectedNumber} available updates`
+ );
+ ok(
+ !availableButton.hidden,
+ "Expecte the available updates category to be visible"
+ );
+}
+
+async function expectAddonInstallStatePostponed(id) {
+ const [addonInstall] = (await AddonManager.getAllInstalls()).filter(
+ install => install.existingAddon && install.existingAddon.id == id
+ );
+ is(
+ addonInstall && addonInstall.state,
+ AddonManager.STATE_POSTPONED,
+ "AddonInstall is in the postponed state"
+ );
+}
+
+function expectCardOptionsButtonBadged({ id, win, hasBadge = true }) {
+ const card = getAddonCard(win, id);
+ const moreOptionsEl = card.querySelector(".more-options-button");
+ is(
+ moreOptionsEl.classList.contains("more-options-button-badged"),
+ hasBadge,
+ `The options button should${hasBadge || "n't"} have the update badge`
+ );
+}
+
+function getCardPostponedBar({ id, win }) {
+ const card = getAddonCard(win, id);
+ return card.querySelector(".update-postponed-bar");
+}
+
+function waitCardAndAddonUpdated({ id, win }) {
+ const card = getAddonCard(win, id);
+ const updatedExtStarted = AddonTestUtils.promiseWebExtensionStartup(id);
+ const updatedCard = BrowserTestUtils.waitForEvent(card, "update");
+ return Promise.all([updatedExtStarted, updatedCard]);
+}
+
+async function testPostponedBarVisibility({ id, win, hidden = false }) {
+ const postponedBar = getCardPostponedBar({ id, win });
+ is(
+ postponedBar.hidden,
+ hidden,
+ `${id} update postponed message bar should be ${
+ hidden ? "hidden" : "visible"
+ }`
+ );
+
+ if (!hidden) {
+ await expectAddonInstallStatePostponed(id);
+ }
+}
+
+async function assertPostponedBarVisibleInAllViews({ id, win }) {
+ info("Test postponed bar visibility in extension list view");
+ await testPostponedBarVisibility({ id, win });
+
+ info("Test postponed bar visibility in available view");
+ await switchView(win, "available-updates");
+ await testPostponedBarVisibility({ id, win });
+
+ info("Test that available updates count do not include postponed langpacks");
+ expectUpdatesAvailableBadgeCount({ win, expectedNumber: 1 });
+
+ info("Test postponed langpacks are not listed in the available updates view");
+ ok(
+ !getAddonCard(win, LOCALE_ADDON_ID),
+ "Locale addon is expected to not be listed in the updates view"
+ );
+
+ info("Test that postponed bar isn't visible on postponed langpacks");
+ await switchView(win, "locale");
+ await testPostponedBarVisibility({ id: LOCALE_ADDON_ID, win, hidden: true });
+
+ info("Test postponed bar visibility in extension detail view");
+ await switchView(win, "extension");
+ await switchToDetailView({ win, id });
+ await testPostponedBarVisibility({ id, win });
+}
+
+async function completePostponedUpdate({ id, win }) {
+ expectCardOptionsButtonBadged({ id, win, hasBadge: false });
+
+ await testPostponedBarVisibility({ id, win });
+
+ let addon = await AddonManager.getAddonByID(id);
+ is(addon.version, "1", "Addon version is 1");
+
+ const promiseUpdated = waitCardAndAddonUpdated({ id, win });
+ const postponedBar = getCardPostponedBar({ id, win });
+ postponedBar.querySelector("button").click();
+ await promiseUpdated;
+
+ addon = await AddonManager.getAddonByID(id);
+ is(addon.version, "2", "Addon version is 2");
+
+ await testPostponedBarVisibility({ id, win, hidden: true });
+}
+
+add_task(async function test_pending_update_with_prompted_permission() {
+ const id = "test-pending-update-with-prompted-permission@mochi.test";
+
+ const { extension } = createTestExtension({
+ id,
+ newManifest: { permissions: ["<all_urls>"] },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("bgpage-ready");
+
+ const win = await loadInitialView("extension");
+
+ // Force about:addons to check for updates.
+ let promisePermissionHandled = handlePermissionPrompt({
+ addonId: extension.id,
+ assertIcon: false,
+ });
+ win.checkForUpdates();
+ await promisePermissionHandled;
+
+ await promiseUpdateAvailable(extension);
+ await expectAddonInstallStatePostponed(id);
+
+ await completePostponedUpdate({ id, win });
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function test_pending_manual_install_over_existing() {
+ const id = "test-pending-manual-install-over-existing@mochi.test";
+
+ const { extension, updateXpi } = createTestExtension({
+ id,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("bgpage-ready");
+
+ let win = await loadInitialView("extension");
+
+ info("Manually install new xpi over the existing extension");
+ const promiseInstalled = AddonTestUtils.promiseInstallFile(updateXpi);
+ await promiseUpdateAvailable(extension);
+
+ await assertPostponedBarVisibleInAllViews({ id, win });
+
+ info("Test postponed bar visibility after reopening about:addons");
+ await closeView(win);
+ win = await loadInitialView("extension");
+ await assertPostponedBarVisibleInAllViews({ id, win });
+
+ await completePostponedUpdate({ id, win });
+ await promiseInstalled;
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function test_pending_update_no_prompted_permission() {
+ const id = "test-pending-update-no-prompted-permission@mochi.test";
+
+ const { extension } = createTestExtension({ id });
+
+ await extension.startup();
+ await extension.awaitMessage("bgpage-ready");
+
+ let win = await loadInitialView("extension");
+
+ info("Force about:addons to check for updates");
+ win.checkForUpdates();
+ await promiseUpdateAvailable(extension);
+
+ await assertPostponedBarVisibleInAllViews({ id, win });
+
+ info("Test postponed bar visibility after reopening about:addons");
+ await closeView(win);
+ win = await loadInitialView("extension");
+ await assertPostponedBarVisibleInAllViews({ id, win });
+
+ await completePostponedUpdate({ id, win });
+
+ info("Reopen about:addons again and verify postponed bar hidden");
+ await closeView(win);
+ win = await loadInitialView("extension");
+ await testPostponedBarVisibility({ id, win, hidden: true });
+
+ await closeView(win);
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_recent_updates.js b/toolkit/mozapps/extensions/test/browser/browser_html_recent_updates.js
new file mode 100644
index 0000000000..66ce20b989
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_recent_updates.js
@@ -0,0 +1,180 @@
+/* eslint max-len: ["error", 80] */
+let gProvider;
+
+function dateHoursAgo(hours) {
+ let date = new Date();
+ date.setTime(date.getTime() - hours * 3600000);
+ return date;
+}
+
+add_task(async function enableHtmlViews() {
+ gProvider = new MockProvider();
+ gProvider.createAddons([
+ {
+ id: "addon-today-2@mochi.test",
+ name: "Updated today two",
+ creator: { name: "The creator" },
+ version: "3.3",
+ type: "extension",
+ updateDate: dateHoursAgo(6),
+ },
+ {
+ id: "addon-today-3@mochi.test",
+ name: "Updated today three",
+ creator: { name: "The creator" },
+ version: "3.3",
+ type: "extension",
+ updateDate: dateHoursAgo(9),
+ },
+ {
+ id: "addon-today-1@mochi.test",
+ name: "Updated today",
+ creator: { name: "The creator" },
+ version: "3.1",
+ type: "extension",
+ releaseNotesURI: "http://example.com/notes.txt",
+ updateDate: dateHoursAgo(1),
+ },
+ {
+ id: "addon-yesterday-1@mochi.test",
+ name: "Updated yesterday one",
+ creator: { name: "The creator" },
+ version: "3.3",
+ type: "extension",
+ updateDate: dateHoursAgo(15),
+ },
+ {
+ id: "addon-earlier@mochi.test",
+ name: "Updated earlier",
+ creator: { name: "The creator" },
+ version: "3.3",
+ type: "extension",
+ updateDate: dateHoursAgo(49),
+ },
+ {
+ id: "addon-yesterday-2@mochi.test",
+ name: "Updated yesterday",
+ creator: { name: "The creator" },
+ version: "3.3",
+ type: "extension",
+ updateDate: dateHoursAgo(24),
+ },
+ {
+ id: "addon-lastweek@mochi.test",
+ name: "Updated last week",
+ creator: { name: "The creator" },
+ version: "3.3",
+ type: "extension",
+ updateDate: dateHoursAgo(192),
+ },
+ ]);
+});
+
+add_task(async function testRecentUpdatesList() {
+ // Load extension view first so we can mock the startOfDay property.
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+ let categoryUtils = new CategoryUtilities(win);
+ const RECENT_URL = "addons://updates/recent";
+ let recentCat = categoryUtils.get("recent-updates");
+
+ ok(recentCat.hidden, "Recent updates category is initially hidden");
+
+ // Load the recent updates view.
+ let loaded = waitForViewLoad(win);
+ doc.querySelector('#page-options [action="view-recent-updates"]').click();
+ await loaded;
+
+ is(
+ categoryUtils.getSelectedViewId(),
+ RECENT_URL,
+ "Recent updates is selected"
+ );
+ ok(!recentCat.hidden, "Recent updates category is now shown");
+
+ // Find all the add-on ids.
+ let list = doc.querySelector("addon-list");
+ let addonsInOrder = () =>
+ Array.from(list.querySelectorAll("addon-card"))
+ .map(card => card.addon.id)
+ .filter(id => id.endsWith("@mochi.test"));
+
+ // Verify that the add-ons are in the right order.
+ Assert.deepEqual(
+ addonsInOrder(),
+ [
+ "addon-today-1@mochi.test",
+ "addon-today-2@mochi.test",
+ "addon-today-3@mochi.test",
+ "addon-yesterday-1@mochi.test",
+ "addon-yesterday-2@mochi.test",
+ ],
+ "The add-ons are in the right order"
+ );
+
+ info("Check that release notes are shown on the details page");
+ let card = list.querySelector(
+ 'addon-card[addon-id="addon-today-1@mochi.test"]'
+ );
+ loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = doc.querySelector("addon-card");
+ ok(card.expanded, "The card is expanded");
+ ok(!card.details.tabGroup.hidden, "The tabs are shown");
+ ok(
+ !card.details.tabGroup.querySelector('[name="release-notes"]').hidden,
+ "The release notes button is shown"
+ );
+
+ info("Go back to the recent updates view");
+ loaded = waitForViewLoad(win);
+ doc.querySelector('#page-options [action="view-recent-updates"]').click();
+ await loaded;
+
+ // Find the list again.
+ list = doc.querySelector("addon-list");
+
+ info("Install a new add-on, it should be first in the list");
+ // Install a new extension.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "New extension",
+ browser_specific_settings: { gecko: { id: "new@mochi.test" } },
+ },
+ useAddonManager: "temporary",
+ });
+ let added = BrowserTestUtils.waitForEvent(list, "add");
+ await extension.startup();
+ await added;
+
+ // The new extension should now be at the top of the list.
+ Assert.deepEqual(
+ addonsInOrder(),
+ [
+ "new@mochi.test",
+ "addon-today-1@mochi.test",
+ "addon-today-2@mochi.test",
+ "addon-today-3@mochi.test",
+ "addon-yesterday-1@mochi.test",
+ "addon-yesterday-2@mochi.test",
+ ],
+ "The new add-on went to the top"
+ );
+
+ // Open the detail view for the new add-on.
+ card = list.querySelector('addon-card[addon-id="new@mochi.test"]');
+ loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ is(
+ categoryUtils.getSelectedViewId(),
+ "addons://list/extension",
+ "The extensions category is selected"
+ );
+
+ await closeView(win);
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_recommendations.js b/toolkit/mozapps/extensions/test/browser/browser_html_recommendations.js
new file mode 100644
index 0000000000..045e58d706
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_recommendations.js
@@ -0,0 +1,165 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint max-len: ["error", 80] */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const SUPPORT_URL = "http://support.allizom.org/support-dummy/";
+const SUMO_URL = SUPPORT_URL + "add-on-badges";
+const SUPPORTED_BADGES = ["recommended", "line", "verified"];
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["app.support.baseURL", SUPPORT_URL]],
+ });
+});
+
+const server = AddonTestUtils.createHttpServer({
+ hosts: ["support.allizom.org"],
+});
+server.registerPathHandler("/support-dummy", (request, response) => {
+ response.write("Dummy");
+});
+
+async function checkRecommendedBadge(id, badges = []) {
+ async function checkBadge() {
+ let card = win.document.querySelector(`addon-card[addon-id="${id}"]`);
+ for (let badgeName of SUPPORTED_BADGES) {
+ let badge = card.querySelector(`.addon-badge-${badgeName}`);
+ let hidden = !badges.includes(badgeName);
+ is(
+ badge.hidden,
+ hidden,
+ `badge ${badgeName} is ${hidden ? "hidden" : "shown"}`
+ );
+ // Verify the utm params.
+ ok(
+ badge.href.startsWith(SUMO_URL),
+ "links to sumo correctly " + badge.href
+ );
+ if (!hidden) {
+ info(`Verify the ${badgeName} badge links to the support page`);
+ let tabLoaded = BrowserTestUtils.waitForNewTab(gBrowser, badge.href);
+ EventUtils.synthesizeMouseAtCenter(badge, {}, win);
+ BrowserTestUtils.removeTab(await tabLoaded);
+ }
+ let url = new URL(badge.href);
+ is(
+ url.searchParams.get("utm_content"),
+ "promoted-addon-badge",
+ "content param correct"
+ );
+ is(
+ url.searchParams.get("utm_source"),
+ "firefox-browser",
+ "source param correct"
+ );
+ is(
+ url.searchParams.get("utm_medium"),
+ "firefox-browser",
+ "medium param correct"
+ );
+ }
+ for (let badgeName of badges) {
+ if (!SUPPORTED_BADGES.includes(badgeName)) {
+ ok(
+ !card.querySelector(`.addon-badge-${badgeName}`),
+ `no badge element for ${badgeName}`
+ );
+ }
+ }
+ return card;
+ }
+
+ let win = await loadInitialView("extension");
+
+ // Check list view.
+ let card = await checkBadge();
+
+ // Load detail view.
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ // Check detail view.
+ await checkBadge();
+
+ await closeView(win);
+}
+
+add_task(async function testNotRecommended() {
+ let id = "not-recommended@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { browser_specific_settings: { gecko: { id } } },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ await checkRecommendedBadge(id);
+
+ await extension.unload();
+});
+
+async function test_badged_addon(addon) {
+ let provider = new MockProvider();
+ provider.createAddons([addon]);
+ await checkRecommendedBadge(addon.id, addon.recommendationStates);
+
+ provider.unregister();
+}
+
+add_task(async function testRecommended() {
+ await test_badged_addon({
+ id: "recommended@mochi.test",
+ isRecommended: true,
+ recommendationStates: ["recommended"],
+ name: "Recommended",
+ type: "extension",
+ });
+});
+
+add_task(async function testLine() {
+ await test_badged_addon({
+ id: "line@mochi.test",
+ isRecommended: false,
+ recommendationStates: ["line"],
+ name: "Line",
+ type: "extension",
+ });
+});
+
+add_task(async function testVerified() {
+ await test_badged_addon({
+ id: "verified@mochi.test",
+ isRecommended: false,
+ recommendationStates: ["verified"],
+ name: "Verified",
+ type: "extension",
+ });
+});
+
+add_task(async function testOther() {
+ await test_badged_addon({
+ id: "other@mochi.test",
+ isRecommended: false,
+ recommendationStates: ["other"],
+ name: "No Badge",
+ type: "extension",
+ });
+});
+
+add_task(async function testMultiple() {
+ await test_badged_addon({
+ id: "multiple@mochi.test",
+ isRecommended: false,
+ recommendationStates: ["verified", "recommended", "other"],
+ name: "Multiple",
+ type: "extension",
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_scroll_restoration.js b/toolkit/mozapps/extensions/test/browser/browser_html_scroll_restoration.js
new file mode 100644
index 0000000000..8376782762
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_scroll_restoration.js
@@ -0,0 +1,223 @@
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+const server = AddonTestUtils.createHttpServer();
+const TEST_API_URL = `http://localhost:${server.identity.primaryPort}/discoapi`;
+
+const EXT_ID_EXTENSION = "extension@example.com";
+const EXT_ID_THEME = "theme@example.com";
+
+let requestCount = 0;
+server.registerPathHandler("/discoapi", (request, response) => {
+ // This test is expected to load the results only once, and then cache the
+ // results.
+ is(++requestCount, 1, "Expect only one discoapi request");
+
+ let results = {
+ results: [
+ {
+ addon: {
+ authors: [{ name: "Some author" }],
+ current_version: {
+ files: [{ platform: "all", url: "data:," }],
+ },
+ url: "data:,",
+ guid: "recommendation@example.com",
+ type: "extension",
+ },
+ },
+ ],
+ };
+ response.write(JSON.stringify(results));
+});
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.getAddons.discovery.api_url", TEST_API_URL]],
+ });
+
+ let mockProvider = new MockProvider();
+ mockProvider.createAddons([
+ {
+ id: EXT_ID_EXTENSION,
+ name: "Mock 1",
+ type: "extension",
+ userPermissions: {
+ origins: ["<all_urls>"],
+ permissions: ["tabs"],
+ },
+ },
+ {
+ id: EXT_ID_THEME,
+ name: "Mock 2",
+ type: "theme",
+ },
+ ]);
+});
+
+async function switchToView(win, type, param = "") {
+ let loaded = waitForViewLoad(win);
+ win.gViewController.loadView(`addons://${type}/${param}`);
+ await loaded;
+ await waitForStableLayout(win);
+}
+
+// delta = -1 = go back.
+// delta = +1 = go forwards.
+async function historyGo(win, delta, expectedViewType) {
+ let loaded = waitForViewLoad(win);
+ win.history.go(delta);
+ await loaded;
+ is(
+ win.gViewController.currentViewId,
+ expectedViewType,
+ "Expected view after history navigation"
+ );
+ await waitForStableLayout(win);
+}
+
+async function waitForStableLayout(win) {
+ // In the test, it is important that the layout is fully stable before we
+ // consider the view loaded, because those affect the offset calculations.
+ await TestUtils.waitForCondition(
+ () => isLayoutStable(win),
+ "Waiting for layout to stabilize"
+ );
+}
+
+function isLayoutStable(win) {
+ // <message-bar> elements may affect the layout of a page, and therefore we
+ // should check whether its embedded style sheet has finished loading.
+ for (let bar of win.document.querySelectorAll("message-bar")) {
+ // Check for the existence of a CSS property from message-bar.css.
+ if (!win.getComputedStyle(bar).getPropertyValue("--message-bar-icon-url")) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function getScrollOffset(win) {
+ let { scrollTop: top, scrollLeft: left } = win.document.documentElement;
+ return { top, left };
+}
+
+// Scroll an element into view. The purpose of this is to simulate a real-world
+// scenario where the user has moved part of the UI is in the viewport.
+function scrollTopLeftIntoView(elem) {
+ elem.scrollIntoView({ block: "start", inline: "start" });
+ // Sanity check: In this test, a large padding has been added to the top and
+ // left of the document. So when an element has been scrolled into view, the
+ // top and left offsets must be non-zero.
+ assertNonZeroScrollOffsets(getScrollOffset(elem.ownerGlobal));
+}
+
+function assertNonZeroScrollOffsets(offsets) {
+ ok(offsets.left, "Should have scrolled to the right");
+ ok(offsets.top, "Should have scrolled down");
+}
+
+function checkScrollOffset(win, expected, msg = "") {
+ let actual = getScrollOffset(win);
+ let fuzz = AppConstants.platform == "macosx" ? 3 : 1;
+ isfuzzy(actual.top, expected.top, fuzz, `Top scroll offset - ${msg}`);
+ isfuzzy(actual.left, expected.left, fuzz, `Left scroll offset - ${msg}`);
+}
+
+add_task(async function test_scroll_restoration() {
+ let win = await loadInitialView("discover");
+
+ // Wait until the recommendations have been loaded. These are cached after
+ // the first load, so we only need to wait once, at the start of the test.
+ await win.document.querySelector("recommended-addon-list").cardsReady;
+
+ // Force scrollbar to appear, by adding enough space at the top and left.
+ win.document.body.style.paddingTop = "200vh";
+ win.document.body.style.paddingLeft = "100vw";
+ win.document.body.style.width = "200vw";
+
+ checkScrollOffset(win, { top: 0, left: 0 }, "initial page load");
+
+ scrollTopLeftIntoView(win.document.querySelector("recommended-addon-card"));
+ let discoOffsets = getScrollOffset(win);
+ assertNonZeroScrollOffsets(discoOffsets);
+
+ // Switch from disco pane to extension list
+
+ await switchToView(win, "list", "extension");
+ checkScrollOffset(win, { top: 0, left: 0 }, "initial extension list");
+
+ scrollTopLeftIntoView(getAddonCard(win, EXT_ID_EXTENSION));
+ let extListOffsets = getScrollOffset(win);
+ assertNonZeroScrollOffsets(extListOffsets);
+
+ // Switch from extension list to details view.
+
+ let loaded = waitForViewLoad(win);
+ getAddonCard(win, EXT_ID_EXTENSION).click();
+ await loaded;
+
+ checkScrollOffset(win, { top: 0, left: 0 }, "initial details view");
+ scrollTopLeftIntoView(getAddonCard(win, EXT_ID_EXTENSION));
+ let detailsOffsets = getScrollOffset(win);
+ assertNonZeroScrollOffsets(detailsOffsets);
+
+ // Switch from details view back to extension list.
+
+ await historyGo(win, -1, "addons://list/extension");
+ checkScrollOffset(win, extListOffsets, "back to extension list");
+
+ // Now scroll to the bottom-right corner, so we can check whether the scroll
+ // offset is correctly restored when the extension view is loaded, even when
+ // the recommendations are loaded after the initial render.
+ ok(
+ win.document.querySelector("recommended-addon-card"),
+ "Recommendations have already been loaded"
+ );
+ win.document.body.scrollIntoView({ block: "end", inline: "end" });
+ extListOffsets = getScrollOffset(win);
+ assertNonZeroScrollOffsets(extListOffsets);
+
+ // Switch back from the extension list to the details view.
+
+ await historyGo(win, +1, `addons://detail/${EXT_ID_EXTENSION}`);
+ checkScrollOffset(win, detailsOffsets, "details view with default tab");
+
+ // Switch from the default details tab to the permissions tab.
+ // (this does not change the history).
+ win.document.querySelector(".tab-button[name='permissions']").click();
+
+ // Switch back from the details view to the extension list.
+
+ await historyGo(win, -1, "addons://list/extension");
+ checkScrollOffset(win, extListOffsets, "bottom-right of extension list");
+ ok(
+ win.document.querySelector("recommended-addon-card"),
+ "Recommendations should have been loaded again"
+ );
+
+ // Switch back from extension list to the details view.
+
+ await historyGo(win, +1, `addons://detail/${EXT_ID_EXTENSION}`);
+ // Scroll offsets are not remembered for the details view, because at the
+ // time of leaving the details view, the non-default tab was selected.
+ checkScrollOffset(win, { top: 0, left: 0 }, "details view, non-default tab");
+
+ // Switch back from the details view to the disco pane.
+
+ await historyGo(win, -2, "addons://discover/");
+ checkScrollOffset(win, discoOffsets, "after switching back to disco pane");
+
+ // Switch from disco pane to theme list.
+
+ // Verifies that the extension list and theme lists are independent.
+ await switchToView(win, "list", "theme");
+ checkScrollOffset(win, { top: 0, left: 0 }, "initial theme list");
+
+ await closeView(win);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_sitepermission_addons.js b/toolkit/mozapps/extensions/test/browser/browser_html_sitepermission_addons.js
new file mode 100644
index 0000000000..3c8ce5f447
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_sitepermission_addons.js
@@ -0,0 +1,178 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { SITEPERMS_ADDON_PROVIDER_PREF, SITEPERMS_ADDON_TYPE } =
+ ChromeUtils.importESModule(
+ "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs"
+ );
+
+const html = `<!DOCTYPE html><h1>Test midi permission with synthetic site permission addon</h1>`;
+const EXAMPLE_COM_URL = `https://example.com/document-builder.sjs?html=${html}`;
+const EXAMPLE_ORG_URL = `https://example.org/document-builder.sjs?html=${html}`;
+
+async function uninstallAllSitePermissionAddons() {
+ const sitepermAddons = await AddonManager.getAddonsByTypes([
+ SITEPERMS_ADDON_TYPE,
+ ]);
+ for (const addon of sitepermAddons) {
+ await addon.uninstall();
+ }
+}
+
+add_setup(async () => {
+ registerCleanupFunction(uninstallAllSitePermissionAddons);
+});
+
+add_task(async function testAboutAddonUninstall() {
+ if (!AddonManager.hasProvider("SitePermsAddonProvider")) {
+ ok(
+ !Services.prefs.getBoolPref(SITEPERMS_ADDON_PROVIDER_PREF),
+ "Expect SitePermsAddonProvider to be disabled by prefs"
+ );
+ ok(true, "Skip test on SitePermsAddonProvider disabled");
+ return;
+ }
+
+ // Grant midi permission on example.com so about:addons does have a Site Permissions section
+ await SpecialPowers.addPermission("midi-sysex", true, EXAMPLE_COM_URL);
+
+ info("Open an about:addon tab so AMO event listeners are registered");
+ const aboutAddonWin = await loadInitialView("sitepermission");
+ // loadInitialView sets the about:addon as the active one, so we can grab it here.
+ const aboutAddonTab = gBrowser.selectedTab;
+
+ const addonList = aboutAddonWin.document.querySelector("addon-list");
+ let addonCards = aboutAddonWin.document.querySelectorAll("addon-card");
+ is(
+ addonCards.length,
+ 1,
+ "There's a card displayed for the example.com addon"
+ );
+
+ info("Open an example.org tab and install the midi site permission addon");
+ const exampleOrgTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ EXAMPLE_ORG_URL,
+ true /* waitForLoad */
+ );
+
+ let promiseAddonCardAdded = BrowserTestUtils.waitForEvent(addonList, "add");
+
+ info("Install midi");
+ await testInstallGatedPermission(
+ exampleOrgTab,
+ () => {
+ content.navigator.requestMIDIAccess();
+ },
+ "midi"
+ );
+
+ info("Install midi-sysex as well");
+ const newAddon = await testInstallGatedPermission(
+ exampleOrgTab,
+ () => {
+ content.navigator.requestMIDIAccess({ sysex: true });
+ },
+ "midi-sysex"
+ );
+
+ const newAddonId = newAddon.id;
+ ok(
+ newAddonId,
+ "Got the addon id for the newly installed sitepermission add-on"
+ );
+
+ info("Switch back to about:addon");
+ gBrowser.selectedTab = aboutAddonTab;
+
+ await promiseAddonCardAdded;
+
+ is(
+ aboutAddonWin.document.querySelectorAll("addon-card").length,
+ 2,
+ "A new addon card has been added as expected"
+ );
+
+ const exampleOrgAddonCard = getAddonCard(aboutAddonWin, newAddonId);
+
+ info("Remove the example.org addon");
+ const promptService = mockPromptService();
+ promptService._response = 0;
+
+ let promiseRemoved = BrowserTestUtils.waitForEvent(addonList, "remove");
+ exampleOrgAddonCard.querySelector("[action=remove]").click();
+ await promiseRemoved;
+
+ is(
+ aboutAddonWin.document.querySelectorAll("addon-card").length,
+ 1,
+ "addon card has been removed as expected"
+ );
+
+ ok(
+ await SpecialPowers.testPermission(
+ "midi",
+ SpecialPowers.Services.perms.UNKNOWN_ACTION,
+ { url: EXAMPLE_ORG_URL }
+ ),
+ "midi permission was revoked"
+ );
+ ok(
+ await SpecialPowers.testPermission(
+ "midi-sysex",
+ SpecialPowers.Services.perms.UNKNOWN_ACTION,
+ { url: EXAMPLE_ORG_URL }
+ ),
+ "midi-sysex permission was revoked as well"
+ );
+
+ await BrowserTestUtils.removeTab(exampleOrgTab);
+ await close_manager(aboutAddonWin);
+ await uninstallAllSitePermissionAddons();
+});
+
+/**
+ *
+ * Execute a function in the tab content page and check that the expected gated permission
+ * is set
+ *
+ * @param {Tab} tab: The tab in which we want to install the gated permission
+ * @param {Function} spawnCallback: function used in `SpecialPowers.spawn` that will trigger
+ * the install
+ * @param {String} expectedPermType: The name of the permission that should be granted
+ * @returns {Promise<Addon>} The installed addon instance
+ */
+async function testInstallGatedPermission(
+ tab,
+ spawnCallback,
+ expectedPermType
+) {
+ let onInstallEnded = AddonTestUtils.promiseInstallEvent("onInstallEnded");
+ let onAddonInstallBlockedNotification = promisePopupNotificationShown(
+ "addon-install-blocked"
+ );
+ await SpecialPowers.spawn(tab.linkedBrowser, [], spawnCallback);
+
+ let addonInstallPanel = await onAddonInstallBlockedNotification;
+ let dialogPromise = promisePopupNotificationShown("addon-webext-permissions");
+ addonInstallPanel.button.click();
+ let installPermsDialog = await dialogPromise;
+ installPermsDialog.button.click();
+
+ const addon = await onInstallEnded.then(install => install[0].addon);
+ // Close the addon-installed dialog to avoid interfering with other tests
+ await acceptAppMenuNotificationWhenShown("addon-installed", addon.id);
+
+ ok(
+ await SpecialPowers.testPermission(
+ expectedPermType,
+ SpecialPowers.Services.perms.ALLOW_ACTION,
+ { url: EXAMPLE_ORG_URL }
+ ),
+ `"${expectedPermType}" permission was granted`
+ );
+ return addon;
+}
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_updates.js b/toolkit/mozapps/extensions/test/browser/browser_html_updates.js
new file mode 100644
index 0000000000..33dba886cd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_updates.js
@@ -0,0 +1,743 @@
+/* eslint max-len: ["error", 80] */
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const server = AddonTestUtils.createHttpServer();
+
+const initialAutoUpdate = AddonManager.autoUpdateDefault;
+registerCleanupFunction(() => {
+ AddonManager.autoUpdateDefault = initialAutoUpdate;
+});
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.checkUpdateSecurity", false]],
+ });
+
+ Services.telemetry.clearEvents();
+ registerCleanupFunction(() => {
+ cleanupPendingNotifications();
+ });
+});
+
+function loadDetailView(win, id) {
+ let doc = win.document;
+ let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+ let loaded = waitForViewLoad(win);
+ EventUtils.synthesizeMouseAtCenter(card, { clickCount: 1 }, win);
+ return loaded;
+}
+
+add_task(async function testChangeAutoUpdates() {
+ let id = "test@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test",
+ browser_specific_settings: { gecko: { id } },
+ },
+ // Use permanent so the add-on can be updated.
+ useAddonManager: "permanent",
+ });
+
+ await extension.startup();
+ let addon = await AddonManager.getAddonByID(id);
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let getInputs = updateRow => ({
+ default: updatesRow.querySelector('input[value="1"]'),
+ on: updatesRow.querySelector('input[value="2"]'),
+ off: updatesRow.querySelector('input[value="0"]'),
+ checkForUpdate: updatesRow.querySelector('[action="update-check"]'),
+ });
+
+ await loadDetailView(win, id);
+
+ let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+ ok(card.querySelector("addon-details"), "The card now has details");
+
+ let updatesRow = card.querySelector(".addon-detail-row-updates");
+ let inputs = getInputs(updatesRow);
+ is(addon.applyBackgroundUpdates, 1, "Default is set");
+ ok(inputs.default.checked, "The default option is selected");
+ ok(inputs.checkForUpdate.hidden, "Update check is hidden");
+
+ inputs.on.click();
+ is(addon.applyBackgroundUpdates, "2", "Updates are now enabled");
+ ok(inputs.on.checked, "The on option is selected");
+ ok(inputs.checkForUpdate.hidden, "Update check is hidden");
+
+ inputs.off.click();
+ is(addon.applyBackgroundUpdates, "0", "Updates are now disabled");
+ ok(inputs.off.checked, "The off option is selected");
+ ok(!inputs.checkForUpdate.hidden, "Update check is visible");
+
+ // Go back to the list view and check the details view again.
+ let loaded = waitForViewLoad(win);
+ doc.querySelector(".back-button").click();
+ await loaded;
+
+ // Load the detail view again.
+ await loadDetailView(win, id);
+
+ card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+ updatesRow = card.querySelector(".addon-detail-row-updates");
+ inputs = getInputs(updatesRow);
+
+ ok(inputs.off.checked, "Off is still selected");
+
+ // Disable global updates.
+ let updated = BrowserTestUtils.waitForEvent(card, "update");
+ AddonManager.autoUpdateDefault = false;
+ await updated;
+
+ // Updates are still the same.
+ is(addon.applyBackgroundUpdates, "0", "Updates are now disabled");
+ ok(inputs.off.checked, "The off option is selected");
+ ok(!inputs.checkForUpdate.hidden, "Update check is visible");
+
+ // Check default.
+ inputs.default.click();
+ is(addon.applyBackgroundUpdates, "1", "Default is set");
+ ok(inputs.default.checked, "The default option is selected");
+ ok(!inputs.checkForUpdate.hidden, "Update check is visible");
+
+ inputs.on.click();
+ is(addon.applyBackgroundUpdates, "2", "Updates are now enabled");
+ ok(inputs.on.checked, "The on option is selected");
+ ok(inputs.checkForUpdate.hidden, "Update check is hidden");
+
+ // Enable updates again.
+ updated = BrowserTestUtils.waitForEvent(card, "update");
+ AddonManager.autoUpdateDefault = true;
+ await updated;
+
+ await closeView(win);
+ await extension.unload();
+});
+
+async function setupExtensionWithUpdate(
+ id,
+ { releaseNotes, cancelUpdate } = {}
+) {
+ let serverHost = `http://localhost:${server.identity.primaryPort}`;
+ let updatesPath = `/ext-updates-${id}.json`;
+
+ let baseManifest = {
+ name: "Updates",
+ icons: { 48: "an-icon.png" },
+ browser_specific_settings: {
+ gecko: {
+ id,
+ update_url: serverHost + updatesPath,
+ },
+ },
+ };
+
+ let updateXpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ ...baseManifest,
+ version: "2",
+ // Include a permission in the updated extension, to make
+ // sure that we trigger the permission prompt as expected
+ // (and that we can accept or cancel the update by observing
+ // the underlying observerService notification).
+ permissions: ["http://*.example.com/*"],
+ },
+ });
+
+ let releaseNotesExtra = {};
+ if (releaseNotes) {
+ let notesPath = "/notes.txt";
+ server.registerPathHandler(notesPath, (request, response) => {
+ if (releaseNotes == "ERROR") {
+ response.setStatusLine(null, 404, "Not Found");
+ } else {
+ response.setStatusLine(null, 200, "OK");
+ response.write(releaseNotes);
+ }
+ response.processAsync();
+ response.finish();
+ });
+ releaseNotesExtra.update_info_url = serverHost + notesPath;
+ }
+
+ let xpiFilename = `/update-${id}.xpi`;
+ server.registerFile(xpiFilename, updateXpi);
+ AddonTestUtils.registerJSON(server, updatesPath, {
+ addons: {
+ [id]: {
+ updates: [
+ {
+ version: "2",
+ update_link: serverHost + xpiFilename,
+ ...releaseNotesExtra,
+ },
+ ],
+ },
+ },
+ });
+
+ handlePermissionPrompt({ addonId: id, reject: cancelUpdate });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ ...baseManifest,
+ version: "1",
+ },
+ // Use permanent so the add-on can be updated.
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+ return extension;
+}
+
+function disableAutoUpdates(card) {
+ // Check button should be hidden.
+ let updateCheckButton = card.querySelector('button[action="update-check"]');
+ ok(updateCheckButton.hidden, "The button is initially hidden");
+
+ // Disable updates, update check button is now visible.
+ card.querySelector('input[name="autoupdate"][value="0"]').click();
+ ok(!updateCheckButton.hidden, "The button is now visible");
+
+ // There shouldn't be an update shown to the user.
+ assertUpdateState({ card, shown: false });
+}
+
+function checkForUpdate(card, expected) {
+ let updateCheckButton = card.querySelector('button[action="update-check"]');
+ let updateFound = BrowserTestUtils.waitForEvent(card, expected);
+ updateCheckButton.click();
+ return updateFound;
+}
+
+function installUpdate(card, expected) {
+ // Install the update.
+ let updateInstalled = BrowserTestUtils.waitForEvent(card, expected);
+ let updated = BrowserTestUtils.waitForEvent(card, "update");
+ card.querySelector('panel-item[action="install-update"]').click();
+ return Promise.all([updateInstalled, updated]);
+}
+
+async function findUpdatesForAddonId(id) {
+ let addon = await AddonManager.getAddonByID(id);
+ await new Promise(resolve => {
+ addon.findUpdates(
+ { onUpdateAvailable: resolve },
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ });
+}
+
+function assertUpdateState({
+ card,
+ shown,
+ expanded = true,
+ releaseNotes = false,
+}) {
+ let menuButton = card.querySelector(".more-options-button");
+ ok(
+ menuButton.classList.contains("more-options-button-badged") == shown,
+ "The menu button is badged"
+ );
+ let installButton = card.querySelector('panel-item[action="install-update"]');
+ ok(
+ installButton.hidden != shown,
+ `The install button is ${shown ? "hidden" : "shown"}`
+ );
+ if (expanded) {
+ let updateCheckButton = card.querySelector('button[action="update-check"]');
+ ok(
+ updateCheckButton.hidden == shown,
+ `The update check button is ${shown ? "hidden" : "shown"}`
+ );
+
+ let { tabGroup } = card.details;
+ is(tabGroup.hidden, false, "The tab group is shown");
+ let notesBtn = tabGroup.querySelector('[name="release-notes"]');
+ is(
+ notesBtn.hidden,
+ !releaseNotes,
+ `The release notes button is ${releaseNotes ? "shown" : "hidden"}`
+ );
+ }
+}
+
+add_task(async function testUpdateAvailable() {
+ let id = "update@mochi.test";
+ let extension = await setupExtensionWithUpdate(id);
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ await loadDetailView(win, id);
+
+ let card = doc.querySelector("addon-card");
+
+ // Disable updates and then check.
+ disableAutoUpdates(card);
+ await checkForUpdate(card, "update-found");
+
+ // There should now be an update.
+ assertUpdateState({ card, shown: true });
+
+ // The version was 1.
+ let versionRow = card.querySelector(".addon-detail-row-version");
+ is(versionRow.lastChild.textContent, "1", "The version started as 1");
+
+ await installUpdate(card, "update-installed");
+
+ // The version is now 2.
+ versionRow = card.querySelector(".addon-detail-row-version");
+ is(versionRow.lastChild.textContent, "2", "The version has updated");
+
+ // No update is shown again.
+ assertUpdateState({ card, shown: false });
+
+ // Check for updates again, there shouldn't be an update.
+ await checkForUpdate(card, "no-update");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testReleaseNotesLoad() {
+ Services.telemetry.clearEvents();
+ let id = "update-with-notes@mochi.test";
+ let extension = await setupExtensionWithUpdate(id, {
+ releaseNotes: `
+ <html xmlns="http://www.w3.org/1999/xhtml">
+ <head><link rel="stylesheet" href="remove-me.css"/></head>
+ <body>
+ <script src="no-scripts.js"></script>
+ <h1>My release notes</h1>
+ <img src="http://example.com/tracker.png"/>
+ <ul>
+ <li onclick="alert('hi')">A thing</li>
+ </ul>
+ <a href="http://example.com/">Go somewhere</a>
+ </body>
+ </html>
+ `,
+ });
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ await loadDetailView(win, id);
+
+ let card = doc.querySelector("addon-card");
+ let { deck, tabGroup } = card.details;
+
+ // Disable updates and then check.
+ disableAutoUpdates(card);
+ await checkForUpdate(card, "update-found");
+
+ // There should now be an update.
+ assertUpdateState({ card, shown: true, releaseNotes: true });
+
+ info("Check release notes");
+ let notesBtn = tabGroup.querySelector('[name="release-notes"]');
+ let notes = card.querySelector("update-release-notes");
+ let loading = BrowserTestUtils.waitForEvent(notes, "release-notes-loading");
+ let loaded = BrowserTestUtils.waitForEvent(notes, "release-notes-loaded");
+ // Don't use notesBtn.click() since it causes an assertion to fail.
+ // See bug 1551621 for more info.
+ EventUtils.synthesizeMouseAtCenter(notesBtn, {}, win);
+ await loading;
+ is(
+ doc.l10n.getAttributes(notes.firstElementChild).id,
+ "release-notes-loading",
+ "The loading message is shown"
+ );
+ await loaded;
+ info("Checking HTML release notes");
+ let [h1, ul, a] = notes.children;
+ is(h1.tagName, "H1", "There's a heading");
+ is(h1.textContent, "My release notes", "The heading has content");
+ is(ul.tagName, "UL", "There's a list");
+ is(ul.children.length, 1, "There's one item in the list");
+ let [li] = ul.children;
+ is(li.tagName, "LI", "There's a list item");
+ is(li.textContent, "A thing", "The text is set");
+ ok(!li.hasAttribute("onclick"), "The onclick was removed");
+ ok(!notes.querySelector("link"), "The link tag was removed");
+ ok(!notes.querySelector("script"), "The script tag was removed");
+ is(a.textContent, "Go somewhere", "The link text is preserved");
+ is(a.href, "http://example.com/", "The link href is preserved");
+
+ info("Verify the link opened in a new tab");
+ let tabOpened = BrowserTestUtils.waitForNewTab(gBrowser, a.href);
+ a.click();
+ let tab = await tabOpened;
+ BrowserTestUtils.removeTab(tab);
+
+ let originalContent = notes.innerHTML;
+
+ info("Switch away and back to release notes");
+ // Load details view.
+ let detailsBtn = tabGroup.querySelector('.tab-button[name="details"]');
+ let viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
+ detailsBtn.click();
+ await viewChanged;
+
+ // Load release notes again, verify they weren't loaded.
+ viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
+ let notesCached = BrowserTestUtils.waitForEvent(
+ notes,
+ "release-notes-cached"
+ );
+ notesBtn.click();
+ await viewChanged;
+ await notesCached;
+ is(notes.innerHTML, originalContent, "The content didn't change");
+
+ info("Install the update to clean it up");
+ await installUpdate(card, "update-installed");
+
+ // There's no more update but release notes are still shown.
+ assertUpdateState({ card, shown: false, releaseNotes: true });
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testReleaseNotesError() {
+ let id = "update-with-notes-error@mochi.test";
+ let extension = await setupExtensionWithUpdate(id, { releaseNotes: "ERROR" });
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ await loadDetailView(win, id);
+
+ let card = doc.querySelector("addon-card");
+ let { deck, tabGroup } = card.details;
+
+ // Disable updates and then check.
+ disableAutoUpdates(card);
+ await checkForUpdate(card, "update-found");
+
+ // There should now be an update.
+ assertUpdateState({ card, shown: true, releaseNotes: true });
+
+ info("Check release notes");
+ let notesBtn = tabGroup.querySelector('[name="release-notes"]');
+ let notes = card.querySelector("update-release-notes");
+ let loading = BrowserTestUtils.waitForEvent(notes, "release-notes-loading");
+ let errored = BrowserTestUtils.waitForEvent(notes, "release-notes-error");
+ // Don't use notesBtn.click() since it causes an assertion to fail.
+ // See bug 1551621 for more info.
+ EventUtils.synthesizeMouseAtCenter(notesBtn, {}, win);
+ await loading;
+ is(
+ doc.l10n.getAttributes(notes.firstElementChild).id,
+ "release-notes-loading",
+ "The loading message is shown"
+ );
+ await errored;
+ is(
+ doc.l10n.getAttributes(notes.firstElementChild).id,
+ "release-notes-error",
+ "The error message is shown"
+ );
+
+ info("Switch away and back to release notes");
+ // Load details view.
+ let detailsBtn = tabGroup.querySelector('.tab-button[name="details"]');
+ let viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
+ detailsBtn.click();
+ await viewChanged;
+
+ // Load release notes again, verify they weren't loaded.
+ viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
+ let notesCached = BrowserTestUtils.waitForEvent(
+ notes,
+ "release-notes-cached"
+ );
+ notesBtn.click();
+ await viewChanged;
+ await notesCached;
+
+ info("Install the update to clean it up");
+ await installUpdate(card, "update-installed");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testUpdateCancelled() {
+ let id = "update@mochi.test";
+ let extension = await setupExtensionWithUpdate(id, { cancelUpdate: true });
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ await loadDetailView(win, "update@mochi.test");
+ let card = doc.querySelector("addon-card");
+
+ // Disable updates and then check.
+ disableAutoUpdates(card);
+ await checkForUpdate(card, "update-found");
+
+ // There should now be an update.
+ assertUpdateState({ card, shown: true });
+
+ // The add-on starts as version 1.
+ let versionRow = card.querySelector(".addon-detail-row-version");
+ is(versionRow.lastChild.textContent, "1", "The version started as 1");
+
+ // Force the install to be cancelled.
+ let install = card.updateInstall;
+ ok(install, "There was an install found");
+
+ await installUpdate(card, "update-cancelled");
+
+ // The add-on is still version 1.
+ versionRow = card.querySelector(".addon-detail-row-version");
+ is(versionRow.lastChild.textContent, "1", "The version hasn't changed");
+
+ // The update has been removed.
+ assertUpdateState({ card, shown: false });
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testAvailableUpdates() {
+ let ids = ["update1@mochi.test", "update2@mochi.test", "update3@mochi.test"];
+ let addons = await Promise.all(ids.map(id => setupExtensionWithUpdate(id)));
+
+ // Disable global add-on updates.
+ AddonManager.autoUpdateDefault = false;
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+ let updatesMessage = doc.getElementById("updates-message");
+ let categoryUtils = new CategoryUtilities(win);
+
+ let availableCat = categoryUtils.get("available-updates");
+
+ ok(availableCat.hidden, "Available updates is hidden");
+ is(availableCat.badgeCount, 0, "There are no updates");
+ ok(updatesMessage, "There is an updates message");
+ is_element_hidden(updatesMessage, "The message is hidden");
+ ok(!updatesMessage.message.textContent, "The message is empty");
+ ok(!updatesMessage.button.textContent, "The button is empty");
+
+ // Check for all updates.
+ let updatesFound = TestUtils.topicObserved("EM-update-check-finished");
+ doc.querySelector('#page-options [action="check-for-updates"]').click();
+
+ is_element_visible(updatesMessage, "The message is visible");
+ ok(!updatesMessage.message.textContent, "The message is empty");
+ ok(updatesMessage.button.hidden, "The view updates button is hidden");
+
+ // Make sure the message gets populated by fluent.
+ await TestUtils.waitForCondition(
+ () => updatesMessage.message.textContent,
+ "wait for message text"
+ );
+
+ await updatesFound;
+
+ // The button should be visible, and should get some text from fluent.
+ ok(!updatesMessage.button.hidden, "The view updates button is visible");
+ await TestUtils.waitForCondition(
+ () => updatesMessage.button.textContent,
+ "wait for button text"
+ );
+
+ // Wait for the available updates count to finalize, it's async.
+ await BrowserTestUtils.waitForCondition(() => availableCat.badgeCount == 3);
+
+ // The category shows the correct update count.
+ ok(!availableCat.hidden, "Available updates is visible");
+ is(availableCat.badgeCount, 3, "There are 3 updates");
+
+ // Go to the available updates page.
+ let loaded = waitForViewLoad(win);
+ availableCat.click();
+ await loaded;
+
+ // Check the updates are shown.
+ let cards = doc.querySelectorAll("addon-card");
+ is(cards.length, 3, "There are 3 cards");
+
+ // Each card should have an update.
+ for (let card of cards) {
+ assertUpdateState({ card, shown: true, expanded: false });
+ }
+
+ // Check the detail page for the first add-on.
+ await loadDetailView(win, ids[0]);
+ is(
+ categoryUtils.getSelectedViewId(),
+ "addons://list/extension",
+ "The extensions category is selected"
+ );
+
+ // Go back to the last view.
+ loaded = waitForViewLoad(win);
+ doc.querySelector(".back-button").click();
+ await loaded;
+
+ // We're back on the updates view.
+ is(
+ categoryUtils.getSelectedViewId(),
+ "addons://updates/available",
+ "The available updates category is selected"
+ );
+
+ // Find the cards again.
+ cards = doc.querySelectorAll("addon-card");
+ is(cards.length, 3, "There are 3 cards");
+
+ // Install the first update.
+ await installUpdate(cards[0], "update-installed");
+ assertUpdateState({ card: cards[0], shown: false, expanded: false });
+
+ // The count goes down but the card stays.
+ is(availableCat.badgeCount, 2, "There are only 2 updates now");
+ is(
+ doc.querySelectorAll("addon-card").length,
+ 3,
+ "All 3 cards are still visible on the updates page"
+ );
+
+ // Install the other two updates.
+ await installUpdate(cards[1], "update-installed");
+ assertUpdateState({ card: cards[1], shown: false, expanded: false });
+ await installUpdate(cards[2], "update-installed");
+ assertUpdateState({ card: cards[2], shown: false, expanded: false });
+
+ // The count goes down but the card stays.
+ is(availableCat.badgeCount, 0, "There are no more updates");
+ is(
+ doc.querySelectorAll("addon-card").length,
+ 3,
+ "All 3 cards are still visible on the updates page"
+ );
+
+ // Enable global add-on updates again.
+ AddonManager.autoUpdateDefault = true;
+
+ await closeView(win);
+ await Promise.all(addons.map(addon => addon.unload()));
+});
+
+add_task(async function testUpdatesShownOnLoad() {
+ let id = "has-update@mochi.test";
+ let addon = await setupExtensionWithUpdate(id);
+
+ // Find the update for our addon.
+ AddonManager.autoUpdateDefault = false;
+ await findUpdatesForAddonId(id);
+
+ let win = await loadInitialView("extension");
+ let categoryUtils = new CategoryUtilities(win);
+ let updatesButton = categoryUtils.get("available-updates");
+
+ ok(!updatesButton.hidden, "The updates button is shown");
+ is(updatesButton.badgeCount, 1, "There is an update");
+
+ let loaded = waitForViewLoad(win);
+ updatesButton.click();
+ await loaded;
+
+ let cards = win.document.querySelectorAll("addon-card");
+
+ is(cards.length, 1, "There is one update card");
+
+ let card = cards[0];
+ is(card.addon.id, id, "The update is for the expected add-on");
+
+ await installUpdate(card, "update-installed");
+
+ ok(!updatesButton.hidden, "The updates button is still shown");
+ is(updatesButton.badgeCount, 0, "There are no more updates");
+
+ info("Check that the updates section is hidden when re-opened");
+ await closeView(win);
+ win = await loadInitialView("extension");
+ categoryUtils = new CategoryUtilities(win);
+ updatesButton = categoryUtils.get("available-updates");
+
+ ok(updatesButton.hidden, "Available updates is hidden");
+ is(updatesButton.badgeCount, 0, "There are no updates");
+
+ AddonManager.autoUpdateDefault = true;
+ await closeView(win);
+ await addon.unload();
+});
+
+add_task(async function testPromptOnBackgroundUpdateCheck() {
+ const id = "test-prompt-on-background-check@mochi.test";
+ const extension = await setupExtensionWithUpdate(id);
+
+ AddonManager.autoUpdateDefault = false;
+
+ const addon = await AddonManager.getAddonByID(id);
+ await AddonTestUtils.promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ let win = await loadInitialView("extension");
+
+ let card = getAddonCard(win, id);
+
+ const promisePromptInfo = promisePermissionPrompt(id);
+ await installUpdate(card, "update-installed");
+ const promptInfo = await promisePromptInfo;
+ ok(promptInfo, "Got a permission prompt as expected");
+
+ AddonManager.autoUpdateDefault = true;
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testNoUpdateAvailableOnUnrelatedAddonCards() {
+ let idNoUpdate = "no-update@mochi.test";
+
+ let extensionNoUpdate = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ name: "TestAddonNoUpdate",
+ browser_specific_settings: { gecko: { id: idNoUpdate } },
+ },
+ });
+ await extensionNoUpdate.startup();
+
+ let win = await loadInitialView("extension");
+
+ let cardNoUpdate = getAddonCard(win, idNoUpdate);
+ ok(cardNoUpdate, `Got AddonCard for ${idNoUpdate}`);
+
+ // Assert that there is not an update badge
+ assertUpdateState({ card: cardNoUpdate, shown: false, expanded: false });
+
+ // Trigger a onNewInstall event by install another unrelated addon.
+ const XPI_URL = `${SECURE_TESTROOT}../xpinstall/amosigned.xpi`;
+ let install = await AddonManager.getInstallForURL(XPI_URL);
+ await AddonManager.installAddonFromAOM(
+ gBrowser.selectedBrowser,
+ win.document.documentURIObject,
+ install
+ );
+
+ // Cancel the install used to trigger the onNewInstall install event.
+ await install.cancel();
+ // Assert that the previously installed addon isn't marked with the
+ // update available badge after installing an unrelated addon.
+ assertUpdateState({ card: cardNoUpdate, shown: false, expanded: false });
+
+ await closeView(win);
+ await extensionNoUpdate.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_warning_messages.js b/toolkit/mozapps/extensions/test/browser/browser_html_warning_messages.js
new file mode 100644
index 0000000000..ca1d59766d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_warning_messages.js
@@ -0,0 +1,291 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint max-len: ["error", 80] */
+
+"use strict";
+
+let gProvider;
+const { STATE_BLOCKED, STATE_SOFTBLOCKED } = Ci.nsIBlocklistService;
+
+const appVersion = Services.appinfo.version;
+const SUPPORT_URL = Services.urlFormatter.formatURL(
+ Services.prefs.getStringPref("app.support.baseURL")
+);
+
+add_setup(async function () {
+ gProvider = new MockProvider();
+});
+
+async function checkMessageState(id, addonType, expected) {
+ async function checkAddonCard() {
+ let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+ let messageBar = card.querySelector(".addon-card-message");
+
+ if (!expected) {
+ ok(messageBar.hidden, "message is hidden");
+ } else {
+ const { linkUrl, text, type } = expected;
+
+ await BrowserTestUtils.waitForMutationCondition(
+ messageBar,
+ { attributes: true },
+ () => !messageBar.hidden
+ );
+ ok(!messageBar.hidden, "message is visible");
+
+ is(messageBar.getAttribute("type"), type, "message has the right type");
+ const textSpan = messageBar.querySelector("span");
+ Assert.deepEqual(
+ document.l10n.getAttributes(textSpan),
+ text,
+ "message l10n data is set correctly"
+ );
+
+ const link = messageBar.querySelector("button");
+ if (linkUrl) {
+ ok(!link.hidden, "link is visible");
+ is(
+ link.getAttribute("data-l10n-id"),
+ `${text.id}-link`,
+ "link l10n id is correct"
+ );
+ const newTab = BrowserTestUtils.waitForNewTab(gBrowser, linkUrl);
+ link.click();
+ BrowserTestUtils.removeTab(await newTab);
+ } else {
+ ok(link.hidden, "link is hidden");
+ }
+ }
+
+ return card;
+ }
+
+ let win = await loadInitialView(addonType);
+ let doc = win.document;
+
+ // Check the list view.
+ ok(doc.querySelector("addon-list"), "this is a list view");
+ let card = await checkAddonCard();
+
+ // Load the detail view.
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ // Check the detail view.
+ ok(!doc.querySelector("addon-list"), "this isn't a list view");
+ await checkAddonCard();
+
+ await closeView(win);
+}
+
+add_task(async function testNoMessageExtension() {
+ let id = "no-message@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { browser_specific_settings: { gecko: { id } } },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ await checkMessageState(id, "extension", null);
+
+ await extension.unload();
+});
+
+add_task(async function testNoMessageLangpack() {
+ let id = "no-message@mochi.test";
+ gProvider.createAddons([
+ {
+ appDisabled: true,
+ id,
+ name: "Signed Langpack",
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+ type: "locale",
+ },
+ ]);
+
+ await checkMessageState(id, "locale", null);
+});
+
+add_task(async function testBlocked() {
+ const id = "blocked@mochi.test";
+ const linkUrl = "https://example.com/addon-blocked";
+ const name = "Blocked";
+ gProvider.createAddons([
+ {
+ appDisabled: true,
+ blocklistState: STATE_BLOCKED,
+ blocklistURL: linkUrl,
+ id,
+ isActive: false,
+ name,
+ },
+ ]);
+ await checkMessageState(id, "extension", {
+ linkUrl,
+ text: { id: "details-notification-blocked", args: { name } },
+ type: "error",
+ });
+});
+
+add_task(async function testUnsignedDisabled() {
+ // This pref being disabled will cause the `specialpowers` addon to be
+ // uninstalled, which can cause a number of test failures due to features no
+ // longer working correctly.
+ // In order to avoid those issues, this code manually disables the pref, and
+ // ensures that `SpecialPowers` is fully re-enabled at the end of the test.
+ const sigPref = "xpinstall.signatures.required";
+ Services.prefs.setBoolPref(sigPref, true);
+
+ const id = "unsigned@mochi.test";
+ const name = "Unsigned";
+ gProvider.createAddons([
+ {
+ appDisabled: true,
+ id,
+ name,
+ signedState: AddonManager.SIGNEDSTATE_MISSING,
+ },
+ ]);
+ await checkMessageState(id, "extension", {
+ linkUrl: SUPPORT_URL + "unsigned-addons",
+ text: { id: "details-notification-unsigned-and-disabled", args: { name } },
+ type: "error",
+ });
+
+ // Ensure that `SpecialPowers` is fully re-initialized at the end of this
+ // test. This requires removing the existing binding so that it's
+ // re-registered, re-enabling unsigned extensions, and then waiting for the
+ // actor to be registered and ready.
+ delete window.SpecialPowers;
+ Services.prefs.setBoolPref(sigPref, false);
+ await TestUtils.waitForCondition(() => {
+ try {
+ return !!windowGlobalChild.getActor("SpecialPowers");
+ } catch (e) {
+ return false;
+ }
+ }, "wait for SpecialPowers to be reloaded");
+ ok(window.SpecialPowers, "SpecialPowers should be re-defined");
+});
+
+add_task(async function testUnsignedLangpackDisabled() {
+ const id = "unsigned-langpack@mochi.test";
+ const name = "Unsigned";
+ gProvider.createAddons([
+ {
+ appDisabled: true,
+ id,
+ name,
+ signedState: AddonManager.SIGNEDSTATE_MISSING,
+ type: "locale",
+ },
+ ]);
+ await checkMessageState(id, "locale", {
+ linkUrl: SUPPORT_URL + "unsigned-addons",
+ text: { id: "details-notification-unsigned-and-disabled", args: { name } },
+ type: "error",
+ });
+});
+
+add_task(async function testIncompatible() {
+ const id = "incompatible@mochi.test";
+ const name = "Incompatible";
+ gProvider.createAddons([
+ {
+ appDisabled: true,
+ id,
+ isActive: false,
+ isCompatible: false,
+ name,
+ },
+ ]);
+ await checkMessageState(id, "extension", {
+ text: {
+ id: "details-notification-incompatible",
+ args: { name, version: appVersion },
+ },
+ type: "warning",
+ });
+});
+
+add_task(async function testUnsignedEnabled() {
+ const id = "unsigned-allowed@mochi.test";
+ const name = "Unsigned";
+ gProvider.createAddons([
+ {
+ id,
+ name,
+ signedState: AddonManager.SIGNEDSTATE_MISSING,
+ },
+ ]);
+ await checkMessageState(id, "extension", {
+ linkUrl: SUPPORT_URL + "unsigned-addons",
+ text: { id: "details-notification-unsigned", args: { name } },
+ type: "warning",
+ });
+});
+
+add_task(async function testUnsignedLangpackEnabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.langpacks.signatures.required", false]],
+ });
+
+ const id = "unsigned-allowed-langpack@mochi.test";
+ const name = "Unsigned Langpack";
+ gProvider.createAddons([
+ {
+ id,
+ name,
+ signedState: AddonManager.SIGNEDSTATE_MISSING,
+ type: "locale",
+ },
+ ]);
+ await checkMessageState(id, "locale", {
+ linkUrl: SUPPORT_URL + "unsigned-addons",
+ text: { id: "details-notification-unsigned", args: { name } },
+ type: "warning",
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function testSoftBlocked() {
+ const id = "softblocked@mochi.test";
+ const linkUrl = "https://example.com/addon-blocked";
+ const name = "Soft Blocked";
+ gProvider.createAddons([
+ {
+ appDisabled: true,
+ blocklistState: STATE_SOFTBLOCKED,
+ blocklistURL: linkUrl,
+ id,
+ isActive: false,
+ name,
+ },
+ ]);
+ await checkMessageState(id, "extension", {
+ linkUrl,
+ text: { id: "details-notification-softblocked", args: { name } },
+ type: "warning",
+ });
+});
+
+add_task(async function testPluginInstalling() {
+ const id = "plugin-installing@mochi.test";
+ const name = "Plugin Installing";
+ gProvider.createAddons([
+ {
+ id,
+ isActive: true,
+ isGMPlugin: true,
+ isInstalled: false,
+ name,
+ type: "plugin",
+ },
+ ]);
+ await checkMessageState(id, "plugin", {
+ text: { id: "details-notification-gmp-pending", args: { name } },
+ type: "warning",
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_installssl.js b/toolkit/mozapps/extensions/test/browser/browser_installssl.js
new file mode 100644
index 0000000000..4469b846bf
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_installssl.js
@@ -0,0 +1,378 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const xpi = RELATIVE_DIR + "addons/browser_installssl.xpi";
+const redirect = RELATIVE_DIR + "redirect.sjs?";
+const SUCCESS = 0;
+const NETWORK_FAILURE = AddonManager.ERROR_NETWORK_FAILURE;
+
+const HTTP = "http://example.com/";
+const HTTPS = "https://example.com/";
+const NOCERT = "https://nocert.example.com/";
+const SELFSIGNED = "https://self-signed.example.com/";
+const UNTRUSTED = "https://untrusted.example.com/";
+const EXPIRED = "https://expired.example.com/";
+
+const PREF_INSTALL_REQUIREBUILTINCERTS =
+ "extensions.install.requireBuiltInCerts";
+
+var gTests = [];
+var gStart = 0;
+var gLast = 0;
+var gPendingInstall = null;
+
+function test() {
+ gStart = Date.now();
+ requestLongerTimeout(4);
+ waitForExplicitFinish();
+
+ registerCleanupFunction(function () {
+ var cos = Cc["@mozilla.org/security/certoverride;1"].getService(
+ Ci.nsICertOverrideService
+ );
+ cos.clearValidityOverride("nocert.example.com", -1, {});
+ cos.clearValidityOverride("self-signed.example.com", -1, {});
+ cos.clearValidityOverride("untrusted.example.com", -1, {});
+ cos.clearValidityOverride("expired.example.com", -1, {});
+
+ if (gPendingInstall) {
+ gTests = [];
+ ok(
+ false,
+ "Timed out in the middle of downloading " +
+ gPendingInstall.sourceURI.spec
+ );
+ try {
+ gPendingInstall.cancel();
+ } catch (e) {}
+ }
+ });
+
+ run_next_test();
+}
+
+function end_test() {
+ info("All tests completed in " + (Date.now() - gStart) + "ms");
+ finish();
+}
+
+function add_install_test(mainURL, redirectURL, expectedStatus) {
+ gTests.push([mainURL, redirectURL, expectedStatus]);
+}
+
+function run_install_tests(callback) {
+ async function run_next_install_test() {
+ if (!gTests.length) {
+ callback();
+ return;
+ }
+ gLast = Date.now();
+
+ let [mainURL, redirectURL, expectedStatus] = gTests.shift();
+ if (redirectURL) {
+ var url = mainURL + redirect + redirectURL + xpi;
+ var message =
+ "Should have seen the right result for an install redirected from " +
+ mainURL +
+ " to " +
+ redirectURL;
+ } else {
+ url = mainURL + xpi;
+ message =
+ "Should have seen the right result for an install from " + mainURL;
+ }
+
+ let install = await AddonManager.getInstallForURL(url);
+ gPendingInstall = install;
+ install.addListener({
+ onDownloadEnded(install) {
+ is(SUCCESS, expectedStatus, message);
+ info("Install test ran in " + (Date.now() - gLast) + "ms");
+ // Don't proceed with the install
+ install.cancel();
+ gPendingInstall = null;
+ run_next_install_test();
+ return false;
+ },
+
+ onDownloadFailed(install) {
+ is(install.error, expectedStatus, message);
+ info("Install test ran in " + (Date.now() - gLast) + "ms");
+ gPendingInstall = null;
+ run_next_install_test();
+ },
+ });
+ install.install();
+ }
+
+ run_next_install_test();
+}
+
+// Runs tests with built-in certificates required, no certificate exceptions
+// and no hashes
+add_test(async function test_builtin_required() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_INSTALL_REQUIREBUILTINCERTS, true]],
+ });
+ // Tests that a simple install works as expected.
+ add_install_test(HTTP, null, SUCCESS);
+ add_install_test(HTTPS, null, NETWORK_FAILURE);
+ add_install_test(NOCERT, null, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, null, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, null, NETWORK_FAILURE);
+ add_install_test(EXPIRED, null, NETWORK_FAILURE);
+
+ // Tests that redirecting from http to other servers works as expected
+ add_install_test(HTTP, HTTP, SUCCESS);
+ add_install_test(HTTP, HTTPS, SUCCESS);
+ add_install_test(HTTP, NOCERT, NETWORK_FAILURE);
+ add_install_test(HTTP, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(HTTP, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(HTTP, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from valid https to other servers works as expected
+ add_install_test(HTTPS, HTTP, NETWORK_FAILURE);
+ add_install_test(HTTPS, HTTPS, NETWORK_FAILURE);
+ add_install_test(HTTPS, NOCERT, NETWORK_FAILURE);
+ add_install_test(HTTPS, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(HTTPS, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(HTTPS, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from nocert https to other servers works as expected
+ add_install_test(NOCERT, HTTP, NETWORK_FAILURE);
+ add_install_test(NOCERT, HTTPS, NETWORK_FAILURE);
+ add_install_test(NOCERT, NOCERT, NETWORK_FAILURE);
+ add_install_test(NOCERT, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(NOCERT, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(NOCERT, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from self-signed https to other servers works as expected
+ add_install_test(SELFSIGNED, HTTP, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, HTTPS, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, NOCERT, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from untrusted https to other servers works as expected
+ add_install_test(UNTRUSTED, HTTP, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, HTTPS, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, NOCERT, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from expired https to other servers works as expected
+ add_install_test(EXPIRED, HTTP, NETWORK_FAILURE);
+ add_install_test(EXPIRED, HTTPS, NETWORK_FAILURE);
+ add_install_test(EXPIRED, NOCERT, NETWORK_FAILURE);
+ add_install_test(EXPIRED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(EXPIRED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(EXPIRED, EXPIRED, NETWORK_FAILURE);
+
+ run_install_tests(run_next_test);
+});
+
+// Runs tests without requiring built-in certificates, no certificate
+// exceptions and no hashes
+add_test(async function test_builtin_not_required() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_INSTALL_REQUIREBUILTINCERTS, false]],
+ });
+
+ // Tests that a simple install works as expected.
+ add_install_test(HTTP, null, SUCCESS);
+ add_install_test(HTTPS, null, SUCCESS);
+ add_install_test(NOCERT, null, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, null, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, null, NETWORK_FAILURE);
+ add_install_test(EXPIRED, null, NETWORK_FAILURE);
+
+ // Tests that redirecting from http to other servers works as expected
+ add_install_test(HTTP, HTTP, SUCCESS);
+ add_install_test(HTTP, HTTPS, SUCCESS);
+ add_install_test(HTTP, NOCERT, NETWORK_FAILURE);
+ add_install_test(HTTP, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(HTTP, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(HTTP, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from valid https to other servers works as expected
+ add_install_test(HTTPS, HTTP, NETWORK_FAILURE);
+ add_install_test(HTTPS, HTTPS, SUCCESS);
+ add_install_test(HTTPS, NOCERT, NETWORK_FAILURE);
+ add_install_test(HTTPS, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(HTTPS, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(HTTPS, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from nocert https to other servers works as expected
+ add_install_test(NOCERT, HTTP, NETWORK_FAILURE);
+ add_install_test(NOCERT, HTTPS, NETWORK_FAILURE);
+ add_install_test(NOCERT, NOCERT, NETWORK_FAILURE);
+ add_install_test(NOCERT, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(NOCERT, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(NOCERT, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from self-signed https to other servers works as expected
+ add_install_test(SELFSIGNED, HTTP, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, HTTPS, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, NOCERT, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from untrusted https to other servers works as expected
+ add_install_test(UNTRUSTED, HTTP, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, HTTPS, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, NOCERT, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from expired https to other servers works as expected
+ add_install_test(EXPIRED, HTTP, NETWORK_FAILURE);
+ add_install_test(EXPIRED, HTTPS, NETWORK_FAILURE);
+ add_install_test(EXPIRED, NOCERT, NETWORK_FAILURE);
+ add_install_test(EXPIRED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(EXPIRED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(EXPIRED, EXPIRED, NETWORK_FAILURE);
+
+ run_install_tests(run_next_test);
+});
+
+// Set up overrides for the next test.
+add_test(() => {
+ addCertOverrides().then(run_next_test);
+});
+
+// Runs tests with built-in certificates required, all certificate exceptions
+// and no hashes
+add_test(async function test_builtin_required_overrides() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_INSTALL_REQUIREBUILTINCERTS, true]],
+ });
+
+ // Tests that a simple install works as expected.
+ add_install_test(HTTP, null, SUCCESS);
+ add_install_test(HTTPS, null, NETWORK_FAILURE);
+ add_install_test(NOCERT, null, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, null, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, null, NETWORK_FAILURE);
+ add_install_test(EXPIRED, null, NETWORK_FAILURE);
+
+ // Tests that redirecting from http to other servers works as expected
+ add_install_test(HTTP, HTTP, SUCCESS);
+ add_install_test(HTTP, HTTPS, SUCCESS);
+ add_install_test(HTTP, NOCERT, SUCCESS);
+ add_install_test(HTTP, SELFSIGNED, SUCCESS);
+ add_install_test(HTTP, UNTRUSTED, SUCCESS);
+ add_install_test(HTTP, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from valid https to other servers works as expected
+ add_install_test(HTTPS, HTTP, NETWORK_FAILURE);
+ add_install_test(HTTPS, HTTPS, NETWORK_FAILURE);
+ add_install_test(HTTPS, NOCERT, NETWORK_FAILURE);
+ add_install_test(HTTPS, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(HTTPS, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(HTTPS, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from nocert https to other servers works as expected
+ add_install_test(NOCERT, HTTP, NETWORK_FAILURE);
+ add_install_test(NOCERT, HTTPS, NETWORK_FAILURE);
+ add_install_test(NOCERT, NOCERT, NETWORK_FAILURE);
+ add_install_test(NOCERT, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(NOCERT, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(NOCERT, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from self-signed https to other servers works as expected
+ add_install_test(SELFSIGNED, HTTP, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, HTTPS, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, NOCERT, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from untrusted https to other servers works as expected
+ add_install_test(UNTRUSTED, HTTP, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, HTTPS, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, NOCERT, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from expired https to other servers works as expected
+ add_install_test(EXPIRED, HTTP, NETWORK_FAILURE);
+ add_install_test(EXPIRED, HTTPS, NETWORK_FAILURE);
+ add_install_test(EXPIRED, NOCERT, NETWORK_FAILURE);
+ add_install_test(EXPIRED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(EXPIRED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(EXPIRED, EXPIRED, NETWORK_FAILURE);
+
+ run_install_tests(run_next_test);
+});
+
+// Runs tests without requiring built-in certificates, all certificate
+// exceptions and no hashes
+add_test(async function test_builtin_not_required_overrides() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_INSTALL_REQUIREBUILTINCERTS, false]],
+ });
+
+ // Tests that a simple install works as expected.
+ add_install_test(HTTP, null, SUCCESS);
+ add_install_test(HTTPS, null, SUCCESS);
+ add_install_test(NOCERT, null, SUCCESS);
+ add_install_test(SELFSIGNED, null, SUCCESS);
+ add_install_test(UNTRUSTED, null, SUCCESS);
+ add_install_test(EXPIRED, null, SUCCESS);
+
+ // Tests that redirecting from http to other servers works as expected
+ add_install_test(HTTP, HTTP, SUCCESS);
+ add_install_test(HTTP, HTTPS, SUCCESS);
+ add_install_test(HTTP, NOCERT, SUCCESS);
+ add_install_test(HTTP, SELFSIGNED, SUCCESS);
+ add_install_test(HTTP, UNTRUSTED, SUCCESS);
+ add_install_test(HTTP, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from valid https to other servers works as expected
+ add_install_test(HTTPS, HTTP, NETWORK_FAILURE);
+ add_install_test(HTTPS, HTTPS, SUCCESS);
+ add_install_test(HTTPS, NOCERT, SUCCESS);
+ add_install_test(HTTPS, SELFSIGNED, SUCCESS);
+ add_install_test(HTTPS, UNTRUSTED, SUCCESS);
+ add_install_test(HTTPS, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from nocert https to other servers works as expected
+ add_install_test(NOCERT, HTTP, NETWORK_FAILURE);
+ add_install_test(NOCERT, HTTPS, SUCCESS);
+ add_install_test(NOCERT, NOCERT, SUCCESS);
+ add_install_test(NOCERT, SELFSIGNED, SUCCESS);
+ add_install_test(NOCERT, UNTRUSTED, SUCCESS);
+ add_install_test(NOCERT, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from self-signed https to other servers works as expected
+ add_install_test(SELFSIGNED, HTTP, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, HTTPS, SUCCESS);
+ add_install_test(SELFSIGNED, NOCERT, SUCCESS);
+ add_install_test(SELFSIGNED, SELFSIGNED, SUCCESS);
+ add_install_test(SELFSIGNED, UNTRUSTED, SUCCESS);
+ add_install_test(SELFSIGNED, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from untrusted https to other servers works as expected
+ add_install_test(UNTRUSTED, HTTP, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, HTTPS, SUCCESS);
+ add_install_test(UNTRUSTED, NOCERT, SUCCESS);
+ add_install_test(UNTRUSTED, SELFSIGNED, SUCCESS);
+ add_install_test(UNTRUSTED, UNTRUSTED, SUCCESS);
+ add_install_test(UNTRUSTED, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from expired https to other servers works as expected
+ add_install_test(EXPIRED, HTTP, NETWORK_FAILURE);
+ add_install_test(EXPIRED, HTTPS, SUCCESS);
+ add_install_test(EXPIRED, NOCERT, SUCCESS);
+ add_install_test(EXPIRED, SELFSIGNED, SUCCESS);
+ add_install_test(EXPIRED, UNTRUSTED, SUCCESS);
+ add_install_test(EXPIRED, EXPIRED, SUCCESS);
+
+ run_install_tests(run_next_test);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_installtrigger_install.js b/toolkit/mozapps/extensions/test/browser/browser_installtrigger_install.js
new file mode 100644
index 0000000000..cd2c30be7e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_installtrigger_install.js
@@ -0,0 +1,350 @@
+/* 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 { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const XPI_URL = `${SECURE_TESTROOT}../xpinstall/amosigned.xpi`;
+const XPI_ADDON_ID = "amosigned-xpi@tests.mozilla.org";
+
+AddonTestUtils.initMochitest(this);
+
+AddonTestUtils.hookAMTelemetryEvents();
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webapi.testing", true],
+ ["extensions.install.requireBuiltInCerts", false],
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+
+ PermissionTestUtils.add(
+ "https://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ registerCleanupFunction(async () => {
+ PermissionTestUtils.remove("https://example.com", "install");
+ await SpecialPowers.popPrefEnv();
+ });
+});
+
+async function testInstallTrigger(
+ msg,
+ tabURL,
+ contentFnArgs,
+ contentFn,
+ expectedTelemetryInfo,
+ expectBlockedOrigin
+) {
+ await BrowserTestUtils.withNewTab(tabURL, async browser => {
+ if (expectBlockedOrigin) {
+ const promiseOriginBlocked = TestUtils.topicObserved(
+ "addon-install-origin-blocked"
+ );
+ await SpecialPowers.spawn(browser, contentFnArgs, contentFn);
+ const [subject] = await promiseOriginBlocked;
+ const installId = subject.wrappedJSObject.installs[0].installId;
+
+ // Select all telemetry events related to the installId.
+ const telemetryEvents = AddonTestUtils.getAMTelemetryEvents().filter(
+ ev => {
+ return (
+ ev.method === "install" &&
+ ev.value === `${installId}` &&
+ ev.extra.step === "site_blocked"
+ );
+ }
+ );
+ ok(
+ !!telemetryEvents.length,
+ "Found telemetry events for the blocked install"
+ );
+
+ if (typeof expectedTelemetryInfo === "function") {
+ expectedTelemetryInfo(telemetryEvents);
+ } else {
+ const source = telemetryEvents[0]?.extra.source;
+ Assert.deepEqual(
+ { source },
+ expectedTelemetryInfo,
+ `Got expected telemetry on test case "${msg}"`
+ );
+ }
+ return;
+ }
+
+ let installPromptPromise = promisePopupNotificationShown(
+ "addon-webext-permissions"
+ ).then(panel => {
+ panel.button.click();
+ });
+
+ let promptPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ XPI_ADDON_ID
+ );
+
+ await SpecialPowers.spawn(browser, contentFnArgs, contentFn);
+
+ await Promise.all([installPromptPromise, promptPromise]);
+
+ let addon = await promiseAddonByID(XPI_ADDON_ID);
+
+ registerCleanupFunction(async () => {
+ await addon.uninstall();
+ });
+
+ // Check that the expected installTelemetryInfo has been stored in the
+ // addon details.
+ AddonTestUtils.checkInstallInfo(
+ addon,
+ { method: "installTrigger", ...expectedTelemetryInfo },
+ `on "${msg}"`
+ );
+
+ await addon.uninstall();
+ });
+}
+
+add_task(function testInstallAfterHistoryPushState() {
+ return testInstallTrigger(
+ "InstallTrigger after history.pushState",
+ SECURE_TESTROOT,
+ [SECURE_TESTROOT, XPI_URL],
+ (secureTestRoot, xpiURL) => {
+ // `sourceURL` should match the exact location, even after a location
+ // update using the history API. In this case, we update the URL with
+ // query parameters and expect `sourceURL` to contain those parameters.
+ content.history.pushState(
+ {}, // state
+ "", // title
+ `${secureTestRoot}?some=query&par=am`
+ );
+ content.InstallTrigger.install({ URL: xpiURL });
+ },
+ {
+ source: "test-host",
+ sourceURL:
+ "https://example.com/browser/toolkit/mozapps/extensions/test/browser/?some=query&par=am",
+ }
+ );
+});
+
+add_task(async function testInstallTriggerFromSubframe() {
+ function runTestCase(msg, tabURL, testFrameAttrs, expected) {
+ info(
+ `InstallTrigger from iframe test: ${msg} - frame attributes ${JSON.stringify(
+ testFrameAttrs
+ )}`
+ );
+ return testInstallTrigger(
+ msg,
+ tabURL,
+ [XPI_URL, testFrameAttrs],
+ async (xpiURL, frameAttrs) => {
+ const frame = content.document.createElement("iframe");
+ if (frameAttrs) {
+ for (const attr of Object.keys(frameAttrs)) {
+ let value = frameAttrs[attr];
+ if (value === "blob:") {
+ const blob = new content.Blob(["blob-testpage"]);
+ value = content.URL.createObjectURL(blob, "text/html");
+ }
+ frame[attr] = value;
+ }
+ }
+ const promiseLoaded = new Promise(resolve =>
+ frame.addEventListener("load", resolve, { once: true })
+ );
+ content.document.body.appendChild(frame);
+ await promiseLoaded;
+ frame.contentWindow.InstallTrigger.install({ URL: xpiURL });
+ },
+ expected.telemetryInfo,
+ expected.blockedOrigin
+ );
+ }
+
+ // On Windows "file:///" does not load the default files index html page
+ // and the test would get stuck.
+ const fileURL = AppConstants.platform === "win" ? "file:///C:/" : "file:///";
+
+ const expected = {
+ http: {
+ telemetryInfo: {
+ source: "test-host",
+ sourceURL:
+ "https://example.com/browser/toolkit/mozapps/extensions/test/browser/",
+ },
+ blockedOrigin: false,
+ },
+ httpBlob: {
+ telemetryInfo: {
+ source: "test-host",
+ // Example: "blob:https://example.com/BLOB_URL_UUID"
+ sourceURL: /^blob:https:\/\/example\.com\//,
+ },
+ blockedOrigin: false,
+ },
+ file: {
+ telemetryInfo: {
+ source: "unknown",
+ sourceURL: fileURL,
+ },
+ blockedOrigin: false,
+ },
+ fileBlob: {
+ telemetryInfo: {
+ source: "unknown",
+ // Example: "blob:null/BLOB_URL_UUID"
+ sourceURL: /^blob:null\//,
+ },
+ blockedOrigin: false,
+ },
+ httpBlockedOnOrigin: {
+ telemetryInfo: {
+ source: "test-host",
+ },
+ blockedOrigin: true,
+ },
+ otherBlockedOnOrigin: {
+ telemetryInfo: {
+ source: "unknown",
+ },
+ blockedOrigin: true,
+ },
+ };
+
+ const testCases = [
+ ["blank iframe with no attributes", SECURE_TESTROOT, {}, expected.http],
+
+ // These are blocked by a Firefox doorhanger and the user can't allow it neither.
+ [
+ "http page iframe src='blob:...'",
+ SECURE_TESTROOT,
+ { src: "blob:" },
+ expected.httpBlockedOnOrigin,
+ ],
+ [
+ "file page iframe src='blob:...'",
+ fileURL,
+ { src: "blob:" },
+ expected.otherBlockedOnOrigin,
+ ],
+ [
+ "iframe srcdoc=''",
+ SECURE_TESTROOT,
+ { srcdoc: "" },
+ expected.httpBlockedOnOrigin,
+ ],
+ [
+ "blank iframe embedded into a top-level sandbox page",
+ `${SECURE_TESTROOT}sandboxed.html`,
+ {},
+ expected.httpBlockedOnOrigin,
+ ],
+ [
+ "blank iframe with sandbox='allow-scripts'",
+ SECURE_TESTROOT,
+ { sandbox: "allow-scripts" },
+ expected.httpBlockedOnOrigin,
+ ],
+ [
+ "iframe srcdoc='' sandbox='allow-scripts'",
+ SECURE_TESTROOT,
+ { srcdoc: "", sandbox: "allow-scripts" },
+ expected.httpBlockedOnOrigin,
+ ],
+ [
+ "http page iframe src='blob:...' sandbox='allow-scripts'",
+ SECURE_TESTROOT,
+ { src: "blob:", sandbox: "allow-scripts" },
+ expected.httpBlockedOnOrigin,
+ ],
+ [
+ "iframe src='data:...'",
+ SECURE_TESTROOT,
+ { src: "data:text/html,data-testpage" },
+ expected.httpBlockedOnOrigin,
+ ],
+ [
+ "blank frame embedded in a data url",
+ "data:text/html,data-testpage",
+ {},
+ expected.otherBlockedOnOrigin,
+ ],
+ [
+ "blank frame embedded into a about:blank page",
+ "about:blank",
+ {},
+ expected.otherBlockedOnOrigin,
+ ],
+ ];
+
+ for (const testCase of testCases) {
+ await runTestCase(...testCase);
+ }
+});
+
+add_task(function testInstallBlankFrameNestedIntoBlobURLPage() {
+ return testInstallTrigger(
+ "Blank frame nested into a blob url page",
+ SECURE_TESTROOT,
+ [XPI_URL],
+ async xpiURL => {
+ const url = content.URL.createObjectURL(
+ new content.Blob(["blob-testpage"]),
+ "text/html"
+ );
+ const topframe = content.document.createElement("iframe");
+ topframe.src = url;
+ const topframeLoaded = new Promise(resolve => {
+ topframe.addEventListener("load", resolve, { once: true });
+ });
+ content.document.body.appendChild(topframe);
+ await topframeLoaded;
+ const subframe = topframe.contentDocument.createElement("iframe");
+ topframe.contentDocument.body.appendChild(subframe);
+ subframe.contentWindow.InstallTrigger.install({ URL: xpiURL });
+ },
+ {
+ source: "test-host",
+ },
+ /* expectBlockedOrigin */ true
+ );
+});
+
+add_task(function testInstallTriggerTopLevelDataURL() {
+ return testInstallTrigger(
+ "Blank frame nested into a blob url page",
+ "data:text/html,testpage",
+ [XPI_URL],
+ async xpiURL => {
+ this.content.InstallTrigger.install({ URL: xpiURL });
+ },
+ {
+ source: "unknown",
+ },
+ /* expectBlockedOrigin */ true
+ );
+});
+
+add_task(function teardown_clearUnexamitedTelemetry() {
+ // Clear collected telemetry events when we are not going to run any assertion on them.
+ // (otherwise the test will fail because of unexamined telemetry events).
+ AddonTestUtils.getAMTelemetryEvents();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts.js b/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts.js
new file mode 100644
index 0000000000..87ee451706
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts.js
@@ -0,0 +1,322 @@
+"use strict";
+
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Message manager disconnected/
+);
+
+function extensionShortcutsReady(id) {
+ let extension = WebExtensionPolicy.getByID(id).extension;
+ return BrowserTestUtils.waitForCondition(() => {
+ return extension.shortcuts.keysetsMap.has(window);
+ }, "Wait for add-on keyset to be registered");
+}
+
+async function loadShortcutsView() {
+ // Load the theme view initially so we can verify that the category is switched
+ // to "extension" when the shortcuts view is loaded.
+ let win = await loadInitialView("theme");
+ let categoryUtils = new CategoryUtilities(win);
+
+ is(
+ categoryUtils.getSelectedViewId(),
+ "addons://list/theme",
+ "The theme category is selected"
+ );
+
+ let shortcutsLink = win.document.querySelector(
+ '#page-options [action="manage-shortcuts"]'
+ );
+ ok(!shortcutsLink.hidden, "The shortcuts link is visible");
+
+ let loaded = waitForViewLoad(win);
+ shortcutsLink.click();
+ await loaded;
+
+ is(
+ categoryUtils.getSelectedViewId(),
+ "addons://list/extension",
+ "The extension category is now selected"
+ );
+
+ return win;
+}
+
+add_task(async function testUpdatingCommands() {
+ let commands = {
+ commandZero: {},
+ commandOne: {
+ suggested_key: { default: "Shift+Alt+7" },
+ },
+ commandTwo: {
+ description: "Command Two!",
+ suggested_key: { default: "Alt+4" },
+ },
+ _execute_browser_action: {
+ suggested_key: { default: "Shift+Alt+9" },
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ commands,
+ browser_action: { default_popup: "popup.html" },
+ },
+ background() {
+ browser.commands.onCommand.addListener(commandName => {
+ browser.test.sendMessage("oncommand", commandName);
+ });
+ browser.test.sendMessage("ready");
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ await extensionShortcutsReady(extension.id);
+
+ async function checkShortcut(name, key, modifiers) {
+ EventUtils.synthesizeKey(key, modifiers);
+ let message = await extension.awaitMessage("oncommand");
+ is(
+ message,
+ name,
+ `Expected onCommand listener to fire with the correct name: ${name}`
+ );
+ }
+
+ // Load the about:addons shortcut view before verify that emitting
+ // the key events does trigger the expected extension commands.
+ // There is apparently a race (more likely to be triggered on an
+ // optimized build) between:
+ // - the new opened browser window to be ready to listen for the
+ // keyboard events that are expected to triggered one of the key
+ // in the extension keyset
+ // - and the test calling EventUtils.syntesizeKey to test that
+ // the expected extension command listener is notified.
+ //
+ // Loading the shortcut view before calling checkShortcut seems to be
+ // enough to consistently avoid that race condition.
+ let win = await loadShortcutsView();
+
+ // Check that the original shortcuts work.
+ await checkShortcut("commandOne", "7", { shiftKey: true, altKey: true });
+ await checkShortcut("commandTwo", "4", { altKey: true });
+
+ let doc = win.document;
+
+ let card = doc.querySelector(`.card[addon-id="${extension.id}"]`);
+ ok(card, `There is a card for the extension`);
+
+ let inputs = card.querySelectorAll(".shortcut-input");
+ is(
+ inputs.length,
+ Object.keys(commands).length,
+ "There is an input for each command"
+ );
+
+ let nameOrder = Array.from(inputs).map(input => input.getAttribute("name"));
+ Assert.deepEqual(
+ nameOrder,
+ ["commandOne", "commandTwo", "_execute_browser_action", "commandZero"],
+ "commandZero should be last since it is unset"
+ );
+
+ let count = 1;
+ for (let input of inputs) {
+ // Change the shortcut.
+ input.focus();
+ EventUtils.synthesizeKey("8", { shiftKey: true, altKey: true });
+ count++;
+
+ // Wait for the shortcut attribute to change.
+ await BrowserTestUtils.waitForCondition(
+ () => input.getAttribute("shortcut") == "Alt+Shift+8",
+ "Wait for shortcut to update to Alt+Shift+8"
+ );
+
+ // Check that the change worked (but skip if browserAction).
+ if (input.getAttribute("name") != "_execute_browser_action") {
+ await checkShortcut(input.getAttribute("name"), "8", {
+ shiftKey: true,
+ altKey: true,
+ });
+ }
+
+ // Change it again so it doesn't conflict with the next command.
+ input.focus();
+ EventUtils.synthesizeKey(count.toString(), {
+ shiftKey: true,
+ altKey: true,
+ });
+ await BrowserTestUtils.waitForCondition(
+ () => input.getAttribute("shortcut") == `Alt+Shift+${count}`,
+ `Wait for shortcut to update to Alt+Shift+${count}`
+ );
+ }
+
+ // Check that errors can be shown.
+ let input = inputs[0];
+ let error = doc.querySelector(".error-message");
+ let label = error.querySelector(".error-message-label");
+ is(error.style.visibility, "hidden", "The error is initially hidden");
+
+ // Try a shortcut with only shift for a modifier.
+ input.focus();
+ EventUtils.synthesizeKey("J", { shiftKey: true });
+ let possibleErrors = ["shortcuts-modifier-mac", "shortcuts-modifier-other"];
+ ok(possibleErrors.includes(label.dataset.l10nId), `The message is set`);
+ is(error.style.visibility, "visible", "The error is shown");
+
+ // Escape should clear the focus and hide the error.
+ is(doc.activeElement, input, "The input is focused");
+ EventUtils.synthesizeKey("Escape", {});
+ ok(doc.activeElement != input, "The input is no longer focused");
+ is(error.style.visibility, "hidden", "The error is hidden");
+
+ // Check if assigning already assigned shortcut is prevented.
+ input.focus();
+ EventUtils.synthesizeKey("2", { shiftKey: true, altKey: true });
+ is(label.dataset.l10nId, "shortcuts-exists", `The message is set`);
+ is(error.style.visibility, "visible", "The error is shown");
+
+ // Check the label uses the description first, and has a default for the special cases.
+ function checkLabel(name, value) {
+ let input = doc.querySelector(`.shortcut-input[name="${name}"]`);
+ let label = input.previousElementSibling;
+ if (label.dataset.l10nId) {
+ is(label.dataset.l10nId, value, "The l10n-id is set");
+ } else {
+ is(label.textContent, value, "The textContent is set");
+ }
+ }
+ checkLabel("commandOne", "commandOne");
+ checkLabel("commandTwo", "Command Two!");
+ checkLabel("_execute_browser_action", "shortcuts-browserAction2");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+async function startExtensionWithCommands(numCommands) {
+ let commands = {};
+
+ for (let i = 0; i < numCommands; i++) {
+ commands[`command-${i}`] = {};
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ commands,
+ },
+ background() {
+ browser.test.sendMessage("ready");
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ await extensionShortcutsReady(extension.id);
+
+ return extension;
+}
+
+add_task(async function testExpanding() {
+ const numCommands = 7;
+ const visibleCommands = 5;
+
+ let extension = await startExtensionWithCommands(numCommands);
+
+ let win = await loadShortcutsView();
+ let doc = win.document;
+
+ let card = doc.querySelector(`.card[addon-id="${extension.id}"]`);
+ ok(!card.hasAttribute("expanded"), "The card is not expanded");
+
+ let shortcutRows = card.querySelectorAll(".shortcut-row");
+ is(shortcutRows.length, numCommands, `There are ${numCommands} shortcuts`);
+
+ function assertCollapsedVisibility() {
+ for (let i = 0; i < shortcutRows.length; i++) {
+ let row = shortcutRows[i];
+ if (i < visibleCommands) {
+ ok(
+ getComputedStyle(row).display != "none",
+ `The first ${visibleCommands} rows are visible`
+ );
+ } else {
+ is(getComputedStyle(row).display, "none", "The other rows are hidden");
+ }
+ }
+ }
+
+ // Check the visibility of the rows.
+ assertCollapsedVisibility();
+
+ let expandButton = card.querySelector(".expand-button");
+ ok(expandButton, "There is an expand button");
+ let l10nAttrs = doc.l10n.getAttributes(expandButton);
+ is(l10nAttrs.id, "shortcuts-card-expand-button", "The expand text is shown");
+ is(
+ l10nAttrs.args.numberToShow,
+ numCommands - visibleCommands,
+ "The number to be shown is set on the expand button"
+ );
+
+ // Expand the card.
+ expandButton.click();
+
+ is(card.getAttribute("expanded"), "true", "The card is now expanded");
+
+ for (let row of shortcutRows) {
+ ok(getComputedStyle(row).display != "none", "All the rows are visible");
+ }
+
+ // The collapse text is now shown.
+ l10nAttrs = doc.l10n.getAttributes(expandButton);
+ is(
+ l10nAttrs.id,
+ "shortcuts-card-collapse-button",
+ "The colapse text is shown"
+ );
+
+ // Collapse the card.
+ expandButton.click();
+
+ ok(!card.hasAttribute("expanded"), "The card is now collapsed again");
+
+ assertCollapsedVisibility({ collapsed: true });
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testOneExtraCommandIsNotCollapsed() {
+ const numCommands = 6;
+ let extension = await startExtensionWithCommands(numCommands);
+
+ let win = await loadShortcutsView();
+ let doc = win.document;
+
+ // The card is not expanded, since it doesn't collapse.
+ let card = doc.querySelector(`.card[addon-id="${extension.id}"]`);
+ ok(!card.hasAttribute("expanded"), "The card is not expanded");
+
+ // Each shortcut has a row.
+ let shortcutRows = card.querySelectorAll(".shortcut-row");
+ is(shortcutRows.length, numCommands, `There are ${numCommands} shortcuts`);
+
+ // There's no expand button, since it can't be expanded.
+ let expandButton = card.querySelector(".expand-button");
+ ok(!expandButton, "There is no expand button");
+
+ // All of the rows are visible, to avoid a "Show 1 More" button.
+ for (let row of shortcutRows) {
+ ok(getComputedStyle(row).display != "none", "All the rows are visible");
+ }
+
+ await closeView(win);
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts_hidden.js b/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts_hidden.js
new file mode 100644
index 0000000000..327a99af9e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts_hidden.js
@@ -0,0 +1,198 @@
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+async function loadShortcutsView() {
+ let managerWin = await open_manager(null);
+ managerWin.gViewController.loadView("addons://shortcuts/shortcuts");
+ await wait_for_view_load(managerWin);
+ return managerWin.document;
+}
+
+async function closeShortcutsView(doc) {
+ let managerWin = doc.defaultView.parent;
+ await close_manager(managerWin);
+}
+
+async function registerAndStartExtension(mockProvider, ext) {
+ // Shortcuts are registered when an extension is started, so we need to load
+ // and start an extension.
+ let extension = ExtensionTestUtils.loadExtension(ext);
+ await extension.startup();
+
+ // Extensions only appear in the add-on manager when they are registered with
+ // the add-on manager, e.g. by passing "useAddonManager" to `loadExtension`.
+ // "useAddonManager" can however not be used, because the resulting add-ons
+ // are unsigned, and only add-ons with privileged signatures can be hidden.
+ mockProvider.createAddons([
+ {
+ id: extension.id,
+ name: ext.manifest.name,
+ type: "extension",
+ version: "1",
+ // We use MockProvider because the "hidden" property cannot
+ // be set when "useAddonManager" is passed to loadExtension.
+ hidden: ext.manifest.hidden,
+ isSystem: ext.isSystem,
+ },
+ ]);
+ return extension;
+}
+
+function getShortcutCard(doc, extension) {
+ return doc.querySelector(`.shortcut[addon-id="${extension.id}"]`);
+}
+
+function getShortcutByName(doc, extension, name) {
+ let card = getShortcutCard(doc, extension);
+ return card && card.querySelector(`.shortcut-input[name="${name}"]`);
+}
+
+function getNoShortcutListItem(doc, extension) {
+ let { id } = extension;
+ let li = doc.querySelector(`.shortcuts-no-commands-list [addon-id="${id}"]`);
+ return li && li.textContent;
+}
+
+add_task(async function extension_with_shortcuts() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "shortcut addon",
+ commands: {
+ theShortcut: {},
+ },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let doc = await loadShortcutsView();
+
+ ok(
+ getShortcutByName(doc, extension, "theShortcut"),
+ "Extension with shortcuts should have a card"
+ );
+ is(
+ getNoShortcutListItem(doc, extension),
+ null,
+ "Extension with shortcuts should not be listed"
+ );
+
+ await closeShortcutsView(doc);
+ await extension.unload();
+});
+
+add_task(async function extension_without_shortcuts() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "no shortcut addon",
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let doc = await loadShortcutsView();
+
+ is(
+ getShortcutCard(doc, extension),
+ null,
+ "Extension without shortcuts should not have a card"
+ );
+ is(
+ getNoShortcutListItem(doc, extension),
+ "no shortcut addon",
+ "The add-on's name is set in the list"
+ );
+
+ await closeShortcutsView(doc);
+ await extension.unload();
+});
+
+// Hidden add-ons without shortcuts should be hidden,
+// but their card should be shown if there is a shortcut.
+add_task(async function hidden_extension() {
+ let mockProvider = new MockProvider();
+ let hiddenExt1 = await registerAndStartExtension(mockProvider, {
+ manifest: {
+ name: "hidden with shortcuts",
+ hidden: true,
+ commands: {
+ hiddenShortcut: {},
+ },
+ },
+ });
+ let hiddenExt2 = await registerAndStartExtension(mockProvider, {
+ manifest: {
+ name: "hidden without shortcuts",
+ hidden: true,
+ },
+ });
+
+ let doc = await loadShortcutsView();
+
+ ok(
+ getShortcutByName(doc, hiddenExt1, "hiddenShortcut"),
+ "Hidden extension with shortcuts should have a card"
+ );
+
+ is(
+ getShortcutCard(doc, hiddenExt2),
+ null,
+ "Hidden extension without shortcuts should not have a card"
+ );
+ is(
+ getNoShortcutListItem(doc, hiddenExt2),
+ null,
+ "Hidden extension without shortcuts should not be listed"
+ );
+
+ await closeShortcutsView(doc);
+ await hiddenExt1.unload();
+ await hiddenExt2.unload();
+
+ mockProvider.unregister();
+});
+
+add_task(async function system_addons_and_shortcuts() {
+ let mockProvider = new MockProvider();
+ let systemExt1 = await registerAndStartExtension(mockProvider, {
+ isSystem: true,
+ manifest: {
+ name: "system with shortcuts",
+ // In practice, all XPIStateLocations with isSystem=true also have
+ // isBuiltin=true, which implies that hidden=true as well.
+ hidden: true,
+ commands: {
+ systemShortcut: {},
+ },
+ },
+ });
+ let systemExt2 = await registerAndStartExtension(mockProvider, {
+ isSystem: true,
+ manifest: {
+ name: "system without shortcuts",
+ hidden: true,
+ },
+ });
+
+ let doc = await loadShortcutsView();
+
+ ok(
+ getShortcutByName(doc, systemExt1, "systemShortcut"),
+ "System add-on with shortcut should have a card"
+ );
+
+ is(
+ getShortcutCard(doc, systemExt2),
+ null,
+ "System add-on without shortcut should not have a card"
+ );
+ is(
+ getNoShortcutListItem(doc, systemExt2),
+ null,
+ "System add-on without shortcuts should not be listed"
+ );
+
+ await closeShortcutsView(doc);
+ await systemExt1.unload();
+ await systemExt2.unload();
+
+ mockProvider.unregister();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts_remove.js b/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts_remove.js
new file mode 100644
index 0000000000..1aa25acd5c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts_remove.js
@@ -0,0 +1,172 @@
+"use strict";
+
+async function loadShortcutsView() {
+ let managerWin = await open_manager(null);
+ managerWin.gViewController.loadView("addons://shortcuts/shortcuts");
+ await wait_for_view_load(managerWin);
+ return managerWin.document;
+}
+
+async function closeShortcutsView(doc) {
+ let managerWin = doc.defaultView.parent;
+ await close_manager(managerWin);
+}
+
+function getShortcutCard(doc, extension) {
+ return doc.querySelector(`.shortcut[addon-id="${extension.id}"]`);
+}
+
+function getShortcutByName(doc, extension, name) {
+ let card = getShortcutCard(doc, extension);
+ return card && card.querySelector(`.shortcut-input[name="${name}"]`);
+}
+
+async function waitForShortcutSet(input, expected) {
+ let doc = input.ownerDocument;
+ await BrowserTestUtils.waitForCondition(
+ () => input.getAttribute("shortcut") == expected,
+ `Shortcut should be set to ${JSON.stringify(expected)}`
+ );
+ ok(doc.activeElement != input, "The input is no longer focused");
+ checkHasRemoveButton(input, expected !== "");
+}
+
+function removeButtonForInput(input) {
+ let removeButton = input.parentNode.querySelector(".shortcut-remove-button");
+ ok(removeButton, "has remove button");
+ return removeButton;
+}
+
+function checkHasRemoveButton(input, expected) {
+ let removeButton = removeButtonForInput(input);
+ let visibility = input.ownerGlobal.getComputedStyle(removeButton).visibility;
+ if (expected) {
+ is(visibility, "visible", "Remove button should be visible");
+ } else {
+ is(visibility, "hidden", "Remove button should be hidden");
+ }
+}
+
+add_task(async function test_remove_shortcut() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ commands: {
+ commandEmpty: {},
+ commandOne: {
+ suggested_key: { default: "Shift+Alt+1" },
+ },
+ commandTwo: {
+ suggested_key: { default: "Shift+Alt+2" },
+ },
+ },
+ },
+ background() {
+ browser.commands.onCommand.addListener(commandName => {
+ browser.test.sendMessage("oncommand", commandName);
+ });
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+
+ let doc = await loadShortcutsView();
+
+ let input = getShortcutByName(doc, extension, "commandOne");
+
+ checkHasRemoveButton(input, true);
+
+ // First: Verify that Shift-Del is not valid, but doesn't do anything.
+ input.focus();
+ EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true });
+ let errorElem = doc.querySelector(".error-message");
+ is(errorElem.style.visibility, "visible", "Expected error message");
+ let errorId = doc.l10n.getAttributes(
+ errorElem.querySelector(".error-message-label")
+ ).id;
+ if (AppConstants.platform == "macosx") {
+ is(errorId, "shortcuts-modifier-mac", "Shift-Del is not a valid shortcut");
+ } else {
+ is(errorId, "shortcuts-modifier-other", "Shift-Del isn't a valid shortcut");
+ }
+ checkHasRemoveButton(input, true);
+
+ // Now, verify that the original shortcut still works.
+ EventUtils.synthesizeKey("KEY_Escape");
+ ok(doc.activeElement != input, "The input is no longer focused");
+ is(errorElem.style.visibility, "hidden", "The error is hidden");
+
+ EventUtils.synthesizeKey("1", { altKey: true, shiftKey: true });
+ await extension.awaitMessage("oncommand");
+
+ // Alt-Shift-Del is a valid shortcut.
+ input.focus();
+ EventUtils.synthesizeKey("KEY_Delete", { altKey: true, shiftKey: true });
+ await waitForShortcutSet(input, "Alt+Shift+Delete");
+ EventUtils.synthesizeKey("KEY_Delete", { altKey: true, shiftKey: true });
+ await extension.awaitMessage("oncommand");
+
+ // Del without modifiers should clear the shortcut.
+ input.focus();
+ EventUtils.synthesizeKey("KEY_Delete");
+ await waitForShortcutSet(input, "");
+ // Trigger the shortcuts that were originally associated with commandOne,
+ // and then trigger commandTwo. The extension should only see commandTwo.
+ EventUtils.synthesizeKey("1", { altKey: true, shiftKey: true });
+ EventUtils.synthesizeKey("KEY_Delete", { altKey: true, shiftKey: true });
+ EventUtils.synthesizeKey("2", { altKey: true, shiftKey: true });
+ is(
+ await extension.awaitMessage("oncommand"),
+ "commandTwo",
+ "commandOne should be disabled, commandTwo should still be enabled"
+ );
+
+ // Set a shortcut where the default was not set.
+ let inputEmpty = getShortcutByName(doc, extension, "commandEmpty");
+ is(inputEmpty.getAttribute("shortcut"), "", "Empty shortcut by default");
+ checkHasRemoveButton(input, false);
+ inputEmpty.focus();
+ EventUtils.synthesizeKey("3", { altKey: true, shiftKey: true });
+ await waitForShortcutSet(inputEmpty, "Alt+Shift+3");
+ EventUtils.synthesizeKey("3", { altKey: true, shiftKey: true });
+ await extension.awaitMessage("oncommand");
+ // Clear shortcut.
+ inputEmpty.focus();
+ EventUtils.synthesizeKey("KEY_Delete");
+ await waitForShortcutSet(inputEmpty, "");
+ EventUtils.synthesizeKey("3", { altKey: true, shiftKey: true });
+ EventUtils.synthesizeKey("2", { altKey: true, shiftKey: true });
+ is(
+ await extension.awaitMessage("oncommand"),
+ "commandTwo",
+ "commandEmpty should be disabled, commandTwo should still be enabled"
+ );
+
+ // Now verify that the Backspace button does the same thing as Delete.
+ inputEmpty.focus();
+ EventUtils.synthesizeKey("3", { altKey: true, shiftKey: true });
+ await waitForShortcutSet(inputEmpty, "Alt+Shift+3");
+ inputEmpty.focus();
+ EventUtils.synthesizeKey("KEY_Backspace");
+ await waitForShortcutSet(input, "");
+ EventUtils.synthesizeKey("3", { altKey: true, shiftKey: true });
+ EventUtils.synthesizeKey("2", { altKey: true, shiftKey: true });
+ is(
+ await extension.awaitMessage("oncommand"),
+ "commandTwo",
+ "commandEmpty should be disabled again by Backspace"
+ );
+
+ // Check that the remove button works as expected.
+ let inputTwo = getShortcutByName(doc, extension, "commandTwo");
+ is(inputTwo.getAttribute("shortcut"), "Shift+Alt+2", "initial shortcut");
+ checkHasRemoveButton(inputTwo, true);
+ removeButtonForInput(inputTwo).click();
+ is(inputTwo.getAttribute("shortcut"), "", "cleared shortcut");
+ checkHasRemoveButton(inputTwo, false);
+ ok(doc.activeElement != inputTwo, "input of removed shortcut is not focused");
+
+ await closeShortcutsView(doc);
+
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_menu_button_accessibility.js b/toolkit/mozapps/extensions/test/browser/browser_menu_button_accessibility.js
new file mode 100644
index 0000000000..eff89cc47c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_menu_button_accessibility.js
@@ -0,0 +1,81 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function testOpenMenu(btn, method) {
+ let shown = BrowserTestUtils.waitForEvent(btn.ownerGlobal, "shown", true);
+ await method();
+ await shown;
+ is(btn.getAttribute("aria-expanded"), "true", "expanded when open");
+}
+
+async function testCloseMenu(btn, method) {
+ let hidden = BrowserTestUtils.waitForEvent(btn.ownerGlobal, "hidden", true);
+ await method();
+ await hidden;
+ is(btn.getAttribute("aria-expanded"), "false", "not expanded when closed");
+}
+
+async function testButton(btn) {
+ let win = btn.ownerGlobal;
+
+ is(btn.getAttribute("aria-haspopup"), "menu", "it has a menu");
+ is(btn.getAttribute("aria-expanded"), "false", "not expanded");
+
+ info("Test open/close with mouse");
+ await testOpenMenu(btn, () => {
+ EventUtils.synthesizeMouseAtCenter(btn, {}, win);
+ });
+ await testCloseMenu(btn, () => {
+ let spacer = win.document.querySelector(".main-heading .spacer");
+ EventUtils.synthesizeMouseAtCenter(spacer, {}, win);
+ });
+
+ info("Test open/close with keyboard");
+ await testOpenMenu(btn, async () => {
+ btn.focus();
+ EventUtils.synthesizeKey(" ", {}, win);
+ });
+ await testCloseMenu(btn, () => {
+ EventUtils.synthesizeKey("Escape", {}, win);
+ });
+}
+
+add_task(async function testPageOptionsMenuButton() {
+ let win = await loadInitialView("extension");
+
+ await testButton(
+ win.document.querySelector(".page-options-menu .more-options-button")
+ );
+
+ await closeView(win);
+});
+
+add_task(async function testCardMoreOptionsButton() {
+ let id = "more-options-button@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let card = getAddonCard(win, id);
+
+ info("Check list page");
+ await testButton(card.querySelector(".more-options-button"));
+
+ let viewLoaded = waitForViewLoad(win);
+ EventUtils.synthesizeMouseAtCenter(card, {}, win);
+ await viewLoaded;
+
+ info("Check detail page");
+ card = getAddonCard(win, id);
+ await testButton(card.querySelector(".more-options-button"));
+
+ await closeView(win);
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_page_accessibility.js b/toolkit/mozapps/extensions/test/browser/browser_page_accessibility.js
new file mode 100644
index 0000000000..e049cbd618
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_page_accessibility.js
@@ -0,0 +1,15 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function testPageTitle() {
+ let win = await loadInitialView("extension");
+ let title = win.document.querySelector("title");
+ is(
+ win.document.l10n.getAttributes(title).id,
+ "addons-page-title",
+ "The page title is set"
+ );
+ await closeView(win);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_page_options_install_addon.js b/toolkit/mozapps/extensions/test/browser/browser_page_options_install_addon.js
new file mode 100644
index 0000000000..f2c6d372a5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_page_options_install_addon.js
@@ -0,0 +1,128 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests bug 567127 - Add install button to the add-ons manager
+
+var MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+async function checkInstallConfirmation(...names) {
+ let notificationCount = 0;
+ let observer = {
+ observe(aSubject, aTopic, aData) {
+ var installInfo = aSubject.wrappedJSObject;
+ isnot(
+ installInfo.browser,
+ null,
+ "Notification should have non-null browser"
+ );
+ Assert.deepEqual(
+ installInfo.installs[0].installTelemetryInfo,
+ {
+ source: "about:addons",
+ method: "install-from-file",
+ },
+ "Got the expected installTelemetryInfo"
+ );
+ notificationCount++;
+ },
+ };
+ Services.obs.addObserver(observer, "addon-install-started");
+
+ let results = [];
+
+ let promise = promisePopupNotificationShown("addon-webext-permissions");
+ for (let i = 0; i < names.length; i++) {
+ let panel = await promise;
+ let name = panel.getAttribute("name");
+ results.push(name);
+
+ info(`Saw install for ${name}`);
+ if (results.length < names.length) {
+ info(
+ `Waiting for installs for ${names.filter(n => !results.includes(n))}`
+ );
+
+ promise = promisePopupNotificationShown("addon-webext-permissions");
+ }
+ panel.secondaryButton.click();
+ }
+
+ Assert.deepEqual(results.sort(), names.sort(), "Got expected installs");
+
+ is(
+ notificationCount,
+ names.length,
+ `Saw ${names.length} addon-install-started notification`
+ );
+ Services.obs.removeObserver(observer, "addon-install-started");
+}
+
+add_task(async function test_install_from_file() {
+ let win = await loadInitialView("extension");
+
+ var filePaths = [
+ get_addon_file_url("browser_dragdrop1.xpi"),
+ get_addon_file_url("browser_dragdrop2.xpi"),
+ ];
+ for (let uri of filePaths) {
+ ok(uri.file != null, `Should have file for ${uri.spec}`);
+ ok(uri.file instanceof Ci.nsIFile, `Should have nsIFile for ${uri.spec}`);
+ }
+ MockFilePicker.setFiles(filePaths.map(aPath => aPath.file));
+
+ // Set handler that executes the core test after the window opens,
+ // and resolves the promise when the window closes
+ let pInstallURIClosed = checkInstallConfirmation(
+ "Drag Drop test 1",
+ "Drag Drop test 2"
+ );
+
+ win.document
+ .querySelector('#page-options [action="install-from-file"]')
+ .click();
+
+ await pInstallURIClosed;
+
+ MockFilePicker.cleanup();
+ await closeView(win);
+});
+
+add_task(async function test_install_disabled() {
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let pageOptionsMenu = doc.querySelector("addon-page-options panel-list");
+
+ function openPageOptions() {
+ let opened = BrowserTestUtils.waitForEvent(pageOptionsMenu, "shown");
+ pageOptionsMenu.open = true;
+ return opened;
+ }
+
+ function closePageOptions() {
+ let closed = BrowserTestUtils.waitForEvent(pageOptionsMenu, "hidden");
+ pageOptionsMenu.open = false;
+ return closed;
+ }
+
+ await openPageOptions();
+ let installButton = doc.querySelector('[action="install-from-file"]');
+ ok(!installButton.hidden, "The install button is shown");
+ await closePageOptions();
+
+ await SpecialPowers.pushPrefEnv({ set: [[PREF_XPI_ENABLED, false]] });
+
+ await openPageOptions();
+ ok(installButton.hidden, "The install button is now hidden");
+ await closePageOptions();
+
+ await SpecialPowers.popPrefEnv();
+
+ await openPageOptions();
+ ok(!installButton.hidden, "The install button is shown again");
+ await closePageOptions();
+
+ await closeView(win);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_page_options_updates.js b/toolkit/mozapps/extensions/test/browser/browser_page_options_updates.js
new file mode 100644
index 0000000000..bd7572a061
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_page_options_updates.js
@@ -0,0 +1,124 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Make sure we don't accidentally start a background update while the prefs
+// are enabled.
+disableBackgroundUpdateTimer();
+registerCleanupFunction(() => {
+ enableBackgroundUpdateTimer();
+});
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+const PREF_UPDATE_ENABLED = "extensions.update.enabled";
+const PREF_AUTOUPDATE_DEFAULT = "extensions.update.autoUpdateDefault";
+
+add_task(async function testUpdateAutomaticallyButton() {
+ SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_UPDATE_ENABLED, true],
+ [PREF_AUTOUPDATE_DEFAULT, true],
+ ],
+ });
+
+ let win = await loadInitialView("extension");
+
+ let toggleAutomaticButton = win.document.querySelector(
+ '#page-options [action="set-update-automatically"]'
+ );
+
+ info("Verify the checked state reflects the update state");
+ ok(toggleAutomaticButton.checked, "Automatic updates button is checked");
+
+ AddonManager.autoUpdateDefault = false;
+ ok(!toggleAutomaticButton.checked, "Automatic updates button is unchecked");
+
+ AddonManager.autoUpdateDefault = true;
+ ok(toggleAutomaticButton.checked, "Automatic updates button is re-checked");
+
+ info("Verify that clicking the button changes the update state");
+ ok(AddonManager.autoUpdateDefault, "Auto updates are default");
+ ok(AddonManager.updateEnabled, "Updates are enabled");
+
+ toggleAutomaticButton.click();
+ ok(!AddonManager.autoUpdateDefault, "Auto updates are disabled");
+ ok(AddonManager.updateEnabled, "Updates are enabled");
+
+ toggleAutomaticButton.click();
+ ok(AddonManager.autoUpdateDefault, "Auto updates are enabled again");
+ ok(AddonManager.updateEnabled, "Updates are enabled");
+
+ await closeView(win);
+});
+
+add_task(async function testResetUpdateStates() {
+ let id = "update-state@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let resetStateButton = win.document.querySelector(
+ '#page-options [action="reset-update-states"]'
+ );
+
+ info("Changing add-on update state");
+ let addon = await AddonManager.getAddonByID(id);
+
+ let setAddonUpdateState = async updateState => {
+ let changed = AddonTestUtils.promiseAddonEvent("onPropertyChanged");
+ addon.applyBackgroundUpdates = updateState;
+ await changed;
+ let addonState = addon.applyBackgroundUpdates;
+ is(addonState, updateState, `Add-on updates are ${updateState}`);
+ };
+
+ await setAddonUpdateState(AddonManager.AUTOUPDATE_DISABLE);
+
+ let propertyChanged = AddonTestUtils.promiseAddonEvent("onPropertyChanged");
+ resetStateButton.click();
+ await propertyChanged;
+ is(
+ addon.applyBackgroundUpdates,
+ AddonManager.AUTOUPDATE_DEFAULT,
+ "Add-on is reset to default updates"
+ );
+
+ await setAddonUpdateState(AddonManager.AUTOUPDATE_ENABLE);
+
+ propertyChanged = AddonTestUtils.promiseAddonEvent("onPropertyChanged");
+ resetStateButton.click();
+ await propertyChanged;
+ is(
+ addon.applyBackgroundUpdates,
+ AddonManager.AUTOUPDATE_DEFAULT,
+ "Add-on is reset to default updates again"
+ );
+
+ info("Check the label on the button as the global state changes");
+ is(
+ win.document.l10n.getAttributes(resetStateButton).id,
+ "addon-updates-reset-updates-to-automatic",
+ "The reset button label says it resets to automatic"
+ );
+
+ info("Disable auto updating globally");
+ AddonManager.autoUpdateDefault = false;
+
+ is(
+ win.document.l10n.getAttributes(resetStateButton).id,
+ "addon-updates-reset-updates-to-manual",
+ "The reset button label says it resets to manual"
+ );
+
+ await closeView(win);
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_permission_prompt.js b/toolkit/mozapps/extensions/test/browser/browser_permission_prompt.js
new file mode 100644
index 0000000000..d58eb8c027
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_permission_prompt.js
@@ -0,0 +1,178 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/*
+ * Test Permission Popup for Sideloaded Extensions.
+ */
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const ADDON_ID = "addon1@test.mozilla.org";
+const CUSTOM_THEME_ID = "theme1@test.mozilla.org";
+const DEFAULT_THEME_ID = "default-theme@mozilla.org";
+
+AddonTestUtils.initMochitest(this);
+
+function assertDisabledSideloadedExtensionElement(managerWindow, addonElement) {
+ const doc = addonElement.ownerDocument;
+ const toggleDisabled = addonElement.querySelector(
+ '[action="toggle-disabled"]'
+ );
+ is(
+ doc.l10n.getAttributes(toggleDisabled).id,
+ "extension-enable-addon-button-label",
+ "Addon toggle-disabled action has the enable label"
+ );
+ ok(!toggleDisabled.checked, "toggle-disable isn't checked");
+}
+
+function assertEnabledSideloadedExtensionElement(managerWindow, addonElement) {
+ const doc = addonElement.ownerDocument;
+ const toggleDisabled = addonElement.querySelector(
+ '[action="toggle-disabled"]'
+ );
+ is(
+ doc.l10n.getAttributes(toggleDisabled).id,
+ "extension-enable-addon-button-label",
+ "Addon toggle-disabled action has the enable label"
+ );
+ ok(!toggleDisabled.checked, "toggle-disable isn't checked");
+}
+
+function clickEnableExtension(addonElement) {
+ addonElement.querySelector('[action="toggle-disabled"]').click();
+}
+
+// Test for bug 1647931
+// Install a theme, enable it and then enable the default theme again
+add_task(async function test_theme_enable() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["xpinstall.signatures.required", false],
+ ["extensions.autoDisableScopes", 15],
+ ["extensions.ui.ignoreUnsigned", true],
+ ],
+ });
+
+ let theme = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: CUSTOM_THEME_ID } },
+ name: "Theme 1",
+ theme: {
+ colors: {
+ frame: "#000000",
+ tab_background_text: "#ffffff",
+ },
+ },
+ },
+ };
+
+ let xpi = AddonTestUtils.createTempWebExtensionFile(theme);
+ await AddonTestUtils.manuallyInstall(xpi);
+
+ let changePromise = new Promise(resolve =>
+ ExtensionsUI.once("change", resolve)
+ );
+ ExtensionsUI._checkForSideloaded();
+ await changePromise;
+
+ // enable fresh installed theme
+ let manager = await open_manager("addons://list/theme");
+ let customTheme = getAddonCard(manager, CUSTOM_THEME_ID);
+ clickEnableExtension(customTheme);
+
+ // enable default theme again
+ let defaultTheme = getAddonCard(manager, DEFAULT_THEME_ID);
+ clickEnableExtension(defaultTheme);
+
+ let addon = await AddonManager.getAddonByID(CUSTOM_THEME_ID);
+ await close_manager(manager);
+ await addon.uninstall();
+});
+
+// Loading extension by sideloading method
+add_task(async function test_sideloaded_extension_permissions_prompt() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["xpinstall.signatures.required", false],
+ ["extensions.autoDisableScopes", 15],
+ ["extensions.ui.ignoreUnsigned", true],
+ ],
+ });
+
+ let options = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: ADDON_ID } },
+ name: "Test 1",
+ permissions: ["history", "https://*/*"],
+ icons: { 64: "foo-icon.png" },
+ },
+ };
+
+ let xpi = AddonTestUtils.createTempWebExtensionFile(options);
+ await AddonTestUtils.manuallyInstall(xpi);
+
+ let changePromise = new Promise(resolve =>
+ ExtensionsUI.once("change", resolve)
+ );
+ ExtensionsUI._checkForSideloaded();
+ await changePromise;
+
+ // Test click event on permission cancel option.
+ let manager = await open_manager("addons://list/extension");
+ let addon = getAddonCard(manager, ADDON_ID);
+
+ Assert.notEqual(addon, null, "Found sideloaded addon in about:addons");
+
+ assertDisabledSideloadedExtensionElement(manager, addon);
+
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ clickEnableExtension(addon);
+ let panel = await popupPromise;
+
+ ok(PopupNotifications.isPanelOpen, "Permission popup should be visible");
+ panel.secondaryButton.click();
+ ok(
+ !PopupNotifications.isPanelOpen,
+ "Permission popup should be closed / closing"
+ );
+
+ addon = await AddonManager.getAddonByID(ADDON_ID);
+ ok(
+ !addon.seen,
+ "Seen flag should remain false after permissions are refused"
+ );
+
+ // Test click event on permission accept option.
+ addon = getAddonCard(manager, ADDON_ID);
+ Assert.notEqual(addon, null, "Found sideloaded addon in about:addons");
+
+ assertEnabledSideloadedExtensionElement(manager, addon);
+
+ popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ clickEnableExtension(addon);
+ panel = await popupPromise;
+
+ ok(PopupNotifications.isPanelOpen, "Permission popup should be visible");
+
+ let notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ ADDON_ID
+ );
+
+ panel.button.click();
+ ok(
+ !PopupNotifications.isPanelOpen,
+ "Permission popup should be closed / closing"
+ );
+ await notificationPromise;
+
+ addon = await AddonManager.getAddonByID(ADDON_ID);
+ ok(addon.seen, "Seen flag should be true after permissions are accepted");
+
+ ok(!PopupNotifications.isPanelOpen, "Permission popup should not be visible");
+
+ await close_manager(manager);
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_reinstall.js b/toolkit/mozapps/extensions/test/browser/browser_reinstall.js
new file mode 100644
index 0000000000..fa5d896745
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_reinstall.js
@@ -0,0 +1,277 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that upgrading bootstrapped add-ons behaves correctly while the
+// manager is open
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+const ID = "reinstall@tests.mozilla.org";
+const testIdSuffix = "@tests.mozilla.org";
+
+let gManagerWindow, xpi1, xpi2;
+
+function htmlDoc() {
+ return gManagerWindow.document;
+}
+
+function get_list_item_count() {
+ return htmlDoc().querySelectorAll(`addon-card[addon-id$="${testIdSuffix}"]`)
+ .length;
+}
+
+function removeItem(item) {
+ let button = item.querySelector('[action="remove"]');
+ button.click();
+}
+
+function hasPendingMessage(item, msg) {
+ let messageBar = htmlDoc().querySelector(
+ `message-bar[addon-id="${item.addon.id}"`
+ );
+ is_element_visible(messageBar, msg);
+}
+
+async function install_addon(xpi) {
+ let install = await AddonManager.getInstallForFile(
+ xpi,
+ "application/x-xpinstall"
+ );
+ return install.install();
+}
+
+async function check_addon(aAddon, aVersion) {
+ is(get_list_item_count(), 1, "Should be one item in the list");
+ is(aAddon.version, aVersion, "Add-on should have the right version");
+
+ let item = getAddonCard(gManagerWindow, ID);
+ ok(!!item, "Should see the add-on in the list");
+
+ // Force XBL to apply
+ item.clientTop;
+
+ let { version } = await get_tooltip_info(item, gManagerWindow);
+ is(version, aVersion, "Version should be correct");
+
+ const l10nAttrs = item.ownerDocument.l10n.getAttributes(item.addonNameEl);
+ if (aAddon.userDisabled) {
+ Assert.deepEqual(
+ l10nAttrs,
+ { id: "addon-name-disabled", args: { name: aAddon.name } },
+ "localized addon name is marked as disabled"
+ );
+ } else {
+ Assert.deepEqual(
+ l10nAttrs,
+ { id: null, args: null },
+ "localized addon name is not marked as disabled"
+ );
+ }
+}
+
+async function wait_for_addon_item_added(addonId) {
+ await BrowserTestUtils.waitForEvent(
+ htmlDoc().querySelector("addon-list"),
+ "add"
+ );
+ const item = getAddonCard(gManagerWindow, addonId);
+ ok(item, `Found addon card for ${addonId}`);
+}
+
+async function wait_for_addon_item_removed(addonId) {
+ await BrowserTestUtils.waitForEvent(
+ htmlDoc().querySelector("addon-list"),
+ "remove"
+ );
+ const item = getAddonCard(gManagerWindow, addonId);
+ ok(!item, `There shouldn't be an addon card for ${addonId}`);
+}
+
+function wait_for_addon_item_updated(addonId) {
+ return BrowserTestUtils.waitForEvent(
+ getAddonCard(gManagerWindow, addonId),
+ "update"
+ );
+}
+
+// Install version 1 then upgrade to version 2 with the manager open
+async function test_upgrade_v1_to_v2() {
+ let promiseItemAdded = wait_for_addon_item_added(ID);
+ await install_addon(xpi1);
+ await promiseItemAdded;
+
+ let addon = await promiseAddonByID(ID);
+ await check_addon(addon, "1.0");
+ ok(!addon.userDisabled, "Add-on should not be disabled");
+
+ let promiseItemUpdated = wait_for_addon_item_updated(ID);
+ await install_addon(xpi2);
+ await promiseItemUpdated;
+
+ addon = await promiseAddonByID(ID);
+ await check_addon(addon, "2.0");
+ ok(!addon.userDisabled, "Add-on should not be disabled");
+
+ let promiseItemRemoved = wait_for_addon_item_removed(ID);
+ await addon.uninstall();
+ await promiseItemRemoved;
+
+ is(get_list_item_count(), 0, "Should be no items in the list");
+}
+
+// Install version 1 mark it as disabled then upgrade to version 2 with the
+// manager open
+async function test_upgrade_disabled_v1_to_v2() {
+ let promiseItemAdded = wait_for_addon_item_added(ID);
+ await install_addon(xpi1);
+ await promiseItemAdded;
+
+ let promiseItemUpdated = wait_for_addon_item_updated(ID);
+ let addon = await promiseAddonByID(ID);
+ await addon.disable();
+ await promiseItemUpdated;
+
+ await check_addon(addon, "1.0");
+ ok(addon.userDisabled, "Add-on should be disabled");
+
+ promiseItemUpdated = wait_for_addon_item_updated(ID);
+ await install_addon(xpi2);
+ await promiseItemUpdated;
+
+ addon = await promiseAddonByID(ID);
+ await check_addon(addon, "2.0");
+ ok(addon.userDisabled, "Add-on should be disabled");
+
+ let promiseItemRemoved = wait_for_addon_item_removed(ID);
+ await addon.uninstall();
+ await promiseItemRemoved;
+
+ is(get_list_item_count(), 0, "Should be no items in the list");
+}
+
+// Install version 1 click the remove button and then upgrade to version 2 with
+// the manager open
+async function test_upgrade_pending_uninstall_v1_to_v2() {
+ let promiseItemAdded = wait_for_addon_item_added(ID);
+ await install_addon(xpi1);
+ await promiseItemAdded;
+
+ let addon = await promiseAddonByID(ID);
+ await check_addon(addon, "1.0");
+ ok(!addon.userDisabled, "Add-on should not be disabled");
+
+ let item = getAddonCard(gManagerWindow, ID);
+
+ let promiseItemRemoved = wait_for_addon_item_removed(ID);
+ removeItem(item);
+
+ // Force XBL to apply
+ item.clientTop;
+
+ await promiseItemRemoved;
+
+ ok(
+ !!(addon.pendingOperations & AddonManager.PENDING_UNINSTALL),
+ "Add-on should be pending uninstall"
+ );
+ hasPendingMessage(item, "Pending message should be visible");
+
+ promiseItemAdded = wait_for_addon_item_added(ID);
+ await install_addon(xpi2);
+ await promiseItemAdded;
+
+ addon = await promiseAddonByID(ID);
+ await check_addon(addon, "2.0");
+ ok(!addon.userDisabled, "Add-on should not be disabled");
+
+ promiseItemRemoved = wait_for_addon_item_removed(ID);
+ await addon.uninstall();
+ await promiseItemRemoved;
+
+ is(get_list_item_count(), 0, "Should be no items in the list");
+}
+
+// Install version 1, disable it, click the remove button and then upgrade to
+// version 2 with the manager open
+async function test_upgrade_pending_uninstall_disabled_v1_to_v2() {
+ let promiseItemAdded = wait_for_addon_item_added(ID);
+ await install_addon(xpi1);
+ await promiseItemAdded;
+
+ let promiseItemUpdated = wait_for_addon_item_updated(ID);
+ let addon = await promiseAddonByID(ID);
+ await addon.disable();
+ await promiseItemUpdated;
+
+ await check_addon(addon, "1.0");
+ ok(addon.userDisabled, "Add-on should be disabled");
+
+ let item = getAddonCard(gManagerWindow, ID);
+
+ let promiseItemRemoved = wait_for_addon_item_removed(ID);
+ removeItem(item);
+
+ // Force XBL to apply
+ item.clientTop;
+
+ await promiseItemRemoved;
+ ok(
+ !!(addon.pendingOperations & AddonManager.PENDING_UNINSTALL),
+ "Add-on should be pending uninstall"
+ );
+ hasPendingMessage(item, "Pending message should be visible");
+
+ promiseItemAdded = wait_for_addon_item_added(ID);
+ await install_addon(xpi2);
+ addon = await promiseAddonByID(ID);
+
+ await promiseItemAdded;
+ await check_addon(addon, "2.0");
+ ok(addon.userDisabled, "Add-on should be disabled");
+
+ promiseItemRemoved = wait_for_addon_item_removed(ID);
+ await addon.uninstall();
+ await promiseItemRemoved;
+
+ is(get_list_item_count(), 0, "Should be no items in the list");
+}
+
+add_setup(async function () {
+ xpi1 = await AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ xpi2 = await AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ // Accept all prompts.
+ mockPromptService()._response = 0;
+});
+
+add_task(async function test_upgrades() {
+ // Close existing about:addons tab if a test failure has
+ // prevented it from being closed.
+ if (gManagerWindow) {
+ await close_manager(gManagerWindow);
+ }
+
+ gManagerWindow = await open_manager("addons://list/extension");
+
+ await test_upgrade_v1_to_v2();
+ await test_upgrade_disabled_v1_to_v2();
+ await test_upgrade_pending_uninstall_v1_to_v2();
+ await test_upgrade_pending_uninstall_disabled_v1_to_v2();
+
+ await close_manager(gManagerWindow);
+ gManagerWindow = null;
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_shortcuts_duplicate_check.js b/toolkit/mozapps/extensions/test/browser/browser_shortcuts_duplicate_check.js
new file mode 100644
index 0000000000..17842ba848
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_shortcuts_duplicate_check.js
@@ -0,0 +1,253 @@
+"use strict";
+
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Message manager disconnected/
+);
+
+async function loadShortcutsView() {
+ let win = await loadInitialView("extension");
+
+ // There should be a manage shortcuts link.
+ let shortcutsLink = win.document.querySelector('[action="manage-shortcuts"]');
+
+ // Open the shortcuts view.
+ let loaded = waitForViewLoad(win);
+ shortcutsLink.click();
+ await loaded;
+
+ return win;
+}
+
+add_task(async function testDuplicateShortcutsWarnings() {
+ let duplicateCommands = {
+ commandOne: {
+ suggested_key: { default: "Shift+Alt+1" },
+ },
+ commandTwo: {
+ description: "Command Two!",
+ suggested_key: { default: "Shift+Alt+2" },
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ commands: duplicateCommands,
+ name: "Extension 1",
+ },
+ background() {
+ browser.test.sendMessage("ready");
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ let extension2 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ commands: {
+ ...duplicateCommands,
+ commandThree: {
+ description: "Command Three!",
+ suggested_key: { default: "Shift+Alt+3" },
+ },
+ },
+ name: "Extension 2",
+ },
+ background() {
+ browser.test.sendMessage("ready");
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension2.startup();
+ await extension2.awaitMessage("ready");
+
+ let win = await loadShortcutsView();
+ let doc = win.document;
+
+ let warningBars = doc.querySelectorAll("message-bar");
+ // Ensure warning messages are shown for each duplicate shorctut.
+ is(
+ warningBars.length,
+ Object.keys(duplicateCommands).length,
+ "There is a warning message bar for each duplicate shortcut"
+ );
+
+ // Ensure warning messages are correct with correct shortcuts.
+ let count = 1;
+ for (let warning of warningBars) {
+ let warningMsg = warning.querySelector("span");
+ let l10nAttrs = doc.l10n.getAttributes(warningMsg);
+ is(
+ l10nAttrs.id,
+ "shortcuts-duplicate-warning-message",
+ "Warning message is shown"
+ );
+ Assert.deepEqual(
+ l10nAttrs.args,
+ { shortcut: `Shift+Alt+${count}` },
+ "Warning message shortcut is correct"
+ );
+ count++;
+ }
+
+ ["Shift+Alt+1", "Shift+Alt+2"].forEach((shortcut, index) => {
+ // Ensure warning messages are correct with correct shortcuts.
+ let warning = warningBars[index];
+ let warningMsg = warning.querySelector("span");
+ let l10nAttrs = doc.l10n.getAttributes(warningMsg);
+ is(
+ l10nAttrs.id,
+ "shortcuts-duplicate-warning-message",
+ "Warning message is shown"
+ );
+ Assert.deepEqual(
+ l10nAttrs.args,
+ { shortcut },
+ "Warning message shortcut is correct"
+ );
+
+ // Check if all inputs have warning style.
+ let inputs = doc.querySelectorAll(`input[shortcut="${shortcut}"]`);
+ for (let input of inputs) {
+ // Check if warning error message is shown on focus.
+ input.focus();
+ let error = doc.querySelector(".error-message");
+ let label = error.querySelector(".error-message-label");
+ is(error.style.visibility, "visible", "The error element is shown");
+ is(
+ error.getAttribute("type"),
+ "warning",
+ "Duplicate shortcut has warning class"
+ );
+ is(
+ label.dataset.l10nId,
+ "shortcuts-duplicate",
+ "Correct error message is shown"
+ );
+
+ // On keypress events wrning class should be removed.
+ EventUtils.synthesizeKey("A");
+ ok(
+ !error.classList.contains("warning"),
+ "Error element should not have warning class"
+ );
+
+ input.blur();
+ is(
+ error.style.visibility,
+ "hidden",
+ "The error element is hidden on blur"
+ );
+ }
+ });
+
+ await closeView(win);
+ await extension.unload();
+ await extension2.unload();
+});
+
+add_task(async function testDuplicateShortcutOnMacOSCtrlKey() {
+ if (AppConstants.platform !== "macosx") {
+ ok(
+ true,
+ `Skipping macos specific test on platform ${AppConstants.platform}`
+ );
+ return;
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ name: "Extension 1",
+ browser_specific_settings: {
+ gecko: { id: "extension1@mochi.test" },
+ },
+ commands: {
+ commandOne: {
+ // Cover expected mac normalized shortcut on default shortcut.
+ suggested_key: { default: "Ctrl+Shift+1" },
+ },
+ commandTwo: {
+ suggested_key: {
+ default: "Alt+Shift+2",
+ // Cover expected mac normalized shortcut on mac-specific shortcut.
+ mac: "Ctrl+Shift+2",
+ },
+ },
+ },
+ },
+ });
+
+ const extension2 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ name: "Extension 2",
+ browser_specific_settings: {
+ gecko: { id: "extension2@mochi.test" },
+ },
+ commands: {
+ anotherCommand: {},
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension2.startup();
+
+ const win = await loadShortcutsView();
+ const doc = win.document;
+ const errorEl = doc.querySelector("addon-shortcuts .error-message");
+ const errorLabel = errorEl.querySelector(".error-message-label");
+
+ ok(
+ BrowserTestUtils.is_hidden(errorEl),
+ "Expect shortcut error element to be initially hidden"
+ );
+
+ const getShortcutInput = commandName =>
+ doc.querySelector(`input.shortcut-input[name="${commandName}"]`);
+
+ const assertDuplicateShortcutWarning = async msg => {
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(errorEl),
+ `Wait for the shortcut-duplicate error to be visible on ${msg}`
+ );
+ Assert.deepEqual(
+ document.l10n.getAttributes(errorLabel),
+ {
+ id: "shortcuts-exists",
+ args: { addon: "Extension 1" },
+ },
+ `Got the expected warning message on duplicate shortcut on ${msg}`
+ );
+ };
+
+ const clearWarning = async inputEl => {
+ anotherCommandInput.blur();
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_hidden(errorEl),
+ "Wait for the shortcut-duplicate error to be hidden"
+ );
+ };
+
+ const anotherCommandInput = getShortcutInput("anotherCommand");
+ anotherCommandInput.focus();
+ EventUtils.synthesizeKey("1", { metaKey: true, shiftKey: true });
+
+ await assertDuplicateShortcutWarning("shortcut conflict with commandOne");
+ await clearWarning(anotherCommandInput);
+
+ anotherCommandInput.focus();
+ EventUtils.synthesizeKey("2", { metaKey: true, shiftKey: true });
+
+ await assertDuplicateShortcutWarning("shortcut conflict with commandTwo");
+ await clearWarning(anotherCommandInput);
+
+ await closeView(win);
+ await extension.unload();
+ await extension2.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_sidebar_categories.js b/toolkit/mozapps/extensions/test/browser/browser_sidebar_categories.js
new file mode 100644
index 0000000000..6723f204ad
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_sidebar_categories.js
@@ -0,0 +1,160 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const THEME_ID = "default-theme@mozilla.org";
+
+function assertViewHas(win, selector, msg) {
+ ok(win.document.querySelector(selector), msg);
+}
+function assertListView(win, type) {
+ assertViewHas(win, `addon-list[type="${type}"]`, `On ${type} list`);
+}
+
+add_task(async function testClickingSidebarEntriesChangesView() {
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+ let themeCategory = doc.querySelector("#categories > [name=theme]");
+ let extensionCategory = doc.querySelector("#categories > [name=extension]");
+
+ assertListView(win, "extension");
+
+ let loaded = waitForViewLoad(win);
+ themeCategory.click();
+ await loaded;
+
+ assertListView(win, "theme");
+
+ loaded = waitForViewLoad(win);
+ getAddonCard(win, THEME_ID).click();
+ await loaded;
+
+ ok(!doc.querySelector("addon-list"), "No more addon-list");
+ assertViewHas(
+ win,
+ `addon-card[addon-id="${THEME_ID}"][expanded]`,
+ "Detail view now"
+ );
+
+ loaded = waitForViewLoad(win);
+ EventUtils.synthesizeMouseAtCenter(themeCategory, {}, win);
+ await loaded;
+
+ assertListView(win, "theme");
+
+ loaded = waitForViewLoad(win);
+ EventUtils.synthesizeMouseAtCenter(extensionCategory, {}, win);
+ await loaded;
+
+ assertListView(win, "extension");
+
+ await closeView(win);
+});
+
+add_task(async function testClickingSidebarPaddingNoChange() {
+ let win = await loadInitialView("theme");
+ let categoryUtils = new CategoryUtilities(win);
+ let themeCategory = categoryUtils.get("theme");
+
+ let loadDetailView = async () => {
+ let loaded = waitForViewLoad(win);
+ getAddonCard(win, THEME_ID).click();
+ await loaded;
+
+ is(
+ win.gViewController.currentViewId,
+ `addons://detail/${THEME_ID}`,
+ "The detail view loaded"
+ );
+ };
+
+ // Confirm that clicking the button directly works.
+ await loadDetailView();
+ let loaded = waitForViewLoad(win);
+ EventUtils.synthesizeMouseAtCenter(themeCategory, {}, win);
+ await loaded;
+ is(
+ win.gViewController.currentViewId,
+ `addons://list/theme`,
+ "The detail view loaded"
+ );
+
+ // Confirm that clicking on the padding beside it does nothing.
+ await loadDetailView();
+ EventUtils.synthesizeMouse(themeCategory, -5, -5, {}, win);
+ ok(!win.gViewController.isLoading, "No view is loading");
+
+ await closeView(win);
+});
+
+add_task(async function testKeyboardUsage() {
+ let win = await loadInitialView("extension");
+ let categories = win.document.getElementById("categories");
+ let extensionCategory = categories.getButtonByName("extension");
+ let themeCategory = categories.getButtonByName("theme");
+ let pluginCategory = categories.getButtonByName("plugin");
+
+ let waitForAnimationFrame = () =>
+ new Promise(resolve => win.requestAnimationFrame(resolve));
+ let sendKey = (key, e = {}) => {
+ EventUtils.synthesizeKey(key, e, win);
+ return waitForAnimationFrame();
+ };
+ let sendTabKey = e => sendKey("VK_TAB", e);
+ let isFocusInCategories = () =>
+ categories.contains(win.document.activeElement);
+
+ ok(!isFocusInCategories(), "Focus is not in the category list");
+
+ // Tab to the first focusable element.
+ await sendTabKey();
+
+ ok(isFocusInCategories(), "Focus is in the category list");
+ is(
+ win.document.activeElement,
+ extensionCategory,
+ "The extension button is focused"
+ );
+
+ // Tab out of the categories list.
+ await sendTabKey();
+ ok(!isFocusInCategories(), "Focus is out of the category list");
+
+ // Tab back into the list.
+ await sendTabKey({ shiftKey: true });
+ is(win.document.activeElement, extensionCategory, "Back on Extensions");
+
+ // We're on the extension list.
+ assertListView(win, "extension");
+
+ // Switch to theme list.
+ let loaded = waitForViewLoad(win);
+ await sendKey("VK_DOWN");
+ is(win.document.activeElement, themeCategory, "Themes is focused");
+ await loaded;
+
+ assertListView(win, "theme");
+
+ loaded = waitForViewLoad(win);
+ await sendKey("VK_DOWN");
+ is(win.document.activeElement, pluginCategory, "Plugins is focused");
+ await loaded;
+
+ assertListView(win, "plugin");
+
+ await sendKey("VK_DOWN");
+ is(win.document.activeElement, pluginCategory, "Plugins is still focused");
+ ok(!win.gViewController.isLoading, "No view is loading");
+
+ loaded = waitForViewLoad(win);
+ await sendKey("VK_UP");
+ await loaded;
+ loaded = waitForViewLoad(win);
+ await sendKey("VK_UP");
+ await loaded;
+ is(win.document.activeElement, extensionCategory, "Extensions is focused");
+ assertListView(win, "extension");
+
+ await closeView(win);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_sidebar_hidden_categories.js b/toolkit/mozapps/extensions/test/browser/browser_sidebar_hidden_categories.js
new file mode 100644
index 0000000000..4cb641c2a0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_sidebar_hidden_categories.js
@@ -0,0 +1,214 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that the visible delay in showing the "Language" category occurs
+// very minimally
+
+let gProvider;
+let gInstall;
+let gInstallProperties = [
+ {
+ name: "Locale Category Test",
+ type: "locale",
+ },
+];
+
+function installLocale() {
+ return new Promise(resolve => {
+ gInstall = gProvider.createInstalls(gInstallProperties)[0];
+ gInstall.addTestListener({
+ onInstallEnded(aInstall) {
+ gInstall.removeTestListener(this);
+ resolve();
+ },
+ });
+ gInstall.install();
+ });
+}
+
+async function checkCategory(win, category, { expectHidden, expectSelected }) {
+ await win.customElements.whenDefined("categories-box");
+
+ let categoriesBox = win.document.getElementById("categories");
+ await categoriesBox.promiseRendered;
+
+ let button = categoriesBox.getButtonByName(category);
+ is(
+ button.hidden,
+ expectHidden,
+ `${category} button is ${expectHidden ? "" : "not "}hidden`
+ );
+ if (expectSelected !== undefined) {
+ is(
+ button.selected,
+ expectSelected,
+ `${category} button is ${expectSelected ? "" : "not "}selected`
+ );
+ }
+}
+
+add_setup(async function () {
+ gProvider = new MockProvider();
+});
+
+add_task(async function testLocalesHiddenByDefault() {
+ gProvider.blockQueryResponses();
+
+ let viewLoaded = loadInitialView("extension", {
+ async loadCallback(win) {
+ await checkCategory(win, "locale", { expectHidden: true });
+ gProvider.unblockQueryResponses();
+ },
+ });
+ let win = await viewLoaded;
+
+ await checkCategory(win, "locale", { expectHidden: true });
+
+ await installLocale();
+
+ await checkCategory(win, "locale", {
+ expectHidden: false,
+ expectSelected: false,
+ });
+
+ await closeView(win);
+});
+
+add_task(async function testLocalesShownWhenInstalled() {
+ gProvider.blockQueryResponses();
+
+ let viewLoaded = loadInitialView("extension", {
+ async loadCallback(win) {
+ await checkCategory(win, "locale", {
+ expectHidden: false,
+ expectSelected: false,
+ });
+ gProvider.unblockQueryResponses();
+ },
+ });
+ let win = await viewLoaded;
+
+ await checkCategory(win, "locale", {
+ expectHidden: false,
+ expectSelected: false,
+ });
+
+ await closeView(win);
+});
+
+add_task(async function testLocalesHiddenWhenUninstalled() {
+ gInstall.cancel();
+ gProvider.blockQueryResponses();
+
+ let viewLoaded = loadInitialView("extension", {
+ async loadCallback(win) {
+ await checkCategory(win, "locale", {
+ expectHidden: false,
+ expectSelected: false,
+ });
+ gProvider.unblockQueryResponses();
+ },
+ });
+ let win = await viewLoaded;
+
+ await checkCategory(win, "locale", { expectHidden: true });
+
+ await closeView(win);
+});
+
+add_task(async function testLocalesHiddenWithoutDelay() {
+ gProvider.blockQueryResponses();
+
+ let viewLoaded = loadInitialView("extension", {
+ async loadCallback(win) {
+ await checkCategory(win, "locale", { expectHidden: true });
+ gProvider.unblockQueryResponses();
+ },
+ });
+ let win = await viewLoaded;
+
+ await checkCategory(win, "locale", { expectHidden: true });
+
+ await closeView(win);
+});
+
+add_task(async function testLocalesShownAfterDelay() {
+ await installLocale();
+
+ gProvider.blockQueryResponses();
+
+ let viewLoaded = loadInitialView("extension", {
+ async loadCallback(win) {
+ await checkCategory(win, "locale", { expectHidden: true });
+ gProvider.unblockQueryResponses();
+ },
+ });
+ let win = await viewLoaded;
+
+ await checkCategory(win, "locale", {
+ expectHidden: false,
+ expectSelected: false,
+ });
+
+ await closeView(win);
+});
+
+add_task(async function testLocalesShownIfPreviousView() {
+ gProvider.blockQueryResponses();
+
+ // Passing "locale" will set the last view to locales and open the view.
+ let viewLoaded = loadInitialView("locale", {
+ async loadCallback(win) {
+ await checkCategory(win, "locale", {
+ expectHidden: false,
+ expectSelected: true,
+ });
+ gProvider.unblockQueryResponses();
+ },
+ });
+ let win = await viewLoaded;
+
+ await checkCategory(win, "locale", {
+ expectHidden: false,
+ expectSelected: true,
+ });
+
+ await closeView(win);
+});
+
+add_task(async function testLocalesHiddenIfPreviousViewAndNoLocales() {
+ gInstall.cancel();
+ gProvider.blockQueryResponses();
+
+ // Passing "locale" will set the last view to locales and open the view.
+ let viewLoaded = loadInitialView("locale", {
+ async loadCallback(win) {
+ await checkCategory(win, "locale", {
+ expectHidden: false,
+ expectSelected: true,
+ });
+ gProvider.unblockQueryResponses();
+ },
+ });
+ let win = await viewLoaded;
+
+ let categoryUtils = new CategoryUtilities(win);
+
+ await TestUtils.waitForCondition(
+ () => categoryUtils.selectedCategory != "locale"
+ );
+
+ await checkCategory(win, "locale", {
+ expectHidden: true,
+ expectSelected: false,
+ });
+
+ is(
+ categoryUtils.getSelectedViewId(),
+ win.gViewController.defaultViewId,
+ "default view is selected"
+ );
+
+ await closeView(win);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_sidebar_restore_category.js b/toolkit/mozapps/extensions/test/browser/browser_sidebar_restore_category.js
new file mode 100644
index 0000000000..4c5b1e25f0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_sidebar_restore_category.js
@@ -0,0 +1,76 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test that the selected category is persisted across loads of the manager
+
+add_task(async function testCategoryRestore() {
+ let win = await loadInitialView("extension");
+ let utils = new CategoryUtilities(win);
+
+ // Open the plugins category
+ await utils.openType("plugin");
+
+ // Re-open the manager
+ await closeView(win);
+ win = await loadInitialView();
+ utils = new CategoryUtilities(win);
+
+ is(
+ utils.selectedCategory,
+ "plugin",
+ "Should have shown the plugins category"
+ );
+
+ // Open the extensions category
+ await utils.openType("extension");
+
+ // Re-open the manager
+ await closeView(win);
+ win = await loadInitialView();
+ utils = new CategoryUtilities(win);
+
+ is(
+ utils.selectedCategory,
+ "extension",
+ "Should have shown the extensions category"
+ );
+
+ await closeView(win);
+});
+
+add_task(async function testInvalidAddonType() {
+ let win = await loadInitialView("invalid");
+
+ let categoryUtils = new CategoryUtilities(win);
+ is(
+ categoryUtils.getSelectedViewId(),
+ win.gViewController.defaultViewId,
+ "default view is selected"
+ );
+ is(
+ win.gViewController.currentViewId,
+ win.gViewController.defaultViewId,
+ "default view is shown"
+ );
+
+ await closeView(win);
+});
+
+add_task(async function testInvalidViewId() {
+ let win = await loadInitialView("addons://invalid/view");
+
+ let categoryUtils = new CategoryUtilities(win);
+ is(
+ categoryUtils.getSelectedViewId(),
+ win.gViewController.defaultViewId,
+ "default view is selected"
+ );
+ is(
+ win.gViewController.currentViewId,
+ win.gViewController.defaultViewId,
+ "default view is shown"
+ );
+
+ await closeView(win);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_subframe_install.js b/toolkit/mozapps/extensions/test/browser/browser_subframe_install.js
new file mode 100644
index 0000000000..e9e8c73728
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_subframe_install.js
@@ -0,0 +1,234 @@
+/* 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 XPI_URL = `${SECURE_TESTROOT}../xpinstall/amosigned.xpi`;
+
+AddonTestUtils.initMochitest(this);
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.install.requireBuiltInCerts", false]],
+ });
+
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ });
+});
+
+function testSubframeInstallOnNavigation({
+ topFrameURL,
+ midFrameURL,
+ bottomFrameURL,
+ xpiURL,
+ assertFn,
+}) {
+ return BrowserTestUtils.withNewTab(topFrameURL, async browser => {
+ await SpecialPowers.pushPrefEnv({
+ // Relax the user input requirements while running this test.
+ set: [["xpinstall.userActivation.required", false]],
+ });
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: [`${midFrameURL}*`],
+ js: ["createFrame.js"],
+ all_frames: true,
+ },
+ {
+ matches: [`${bottomFrameURL}*`],
+ js: ["installByNavigatingToXPIURL.js"],
+ all_frames: true,
+ },
+ ],
+ },
+ files: {
+ "createFrame.js": `(function(frameURL) {
+ browser.test.log("Executing createFrame.js on " + window.location.href);
+ const frame = document.createElement("iframe");
+ frame.src = frameURL;
+ document.body.appendChild(frame);
+ })("${bottomFrameURL}")`,
+
+ "installByNavigatingToXPIURL.js": `
+ browser.test.log("Navigating to XPI url from " + window.location.href);
+ const link = document.createElement("a");
+ link.id = "xpi-link";
+ link.href = "${xpiURL}";
+ link.textContent = "Link to XPI file";
+ document.body.appendChild(link);
+ link.click();
+ `,
+ },
+ });
+
+ await extension.startup();
+
+ await SpecialPowers.spawn(browser, [midFrameURL], async frameURL => {
+ const frame = content.document.createElement("iframe");
+ frame.src = frameURL;
+ content.document.body.appendChild(frame);
+ });
+
+ await assertFn({ browser });
+
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+ });
+}
+
+add_task(async function testInstallBlockedOnNavigationFromCrossOriginFrame() {
+ const promiseOriginBlocked = TestUtils.topicObserved(
+ "addon-install-origin-blocked"
+ );
+
+ await testSubframeInstallOnNavigation({
+ topFrameURL: "https://test1.example.com/",
+ midFrameURL: "https://example.org/",
+ bottomFrameURL: "https://test1.example.com/installTrigger",
+ xpiURL: XPI_URL,
+ assertFn: async () => {
+ await promiseOriginBlocked;
+ Assert.deepEqual(
+ await AddonManager.getAllInstalls(),
+ [],
+ "Expects no pending addon install"
+ );
+ },
+ });
+});
+
+add_task(async function testInstallPromptedOnNavigationFromSameOriginFrame() {
+ const promisePromptedInstallFromThirdParty = TestUtils.topicObserved(
+ "addon-install-blocked"
+ );
+
+ await testSubframeInstallOnNavigation({
+ topFrameURL: "https://test2.example.com/",
+ midFrameURL: "https://test1.example.com/",
+ bottomFrameURL: "https://test2.example.com/installTrigger",
+ xpiURL: XPI_URL,
+ assertFn: async () => {
+ const [subject] = await promisePromptedInstallFromThirdParty;
+ let installInfo = subject.wrappedJSObject;
+ ok(installInfo, "Got a blocked addon install pending");
+ installInfo.cancel();
+ },
+ });
+});
+
+add_task(async function testInstallTriggerBlockedFromCrossOriginFrame() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+
+ const promiseOriginBlocked = TestUtils.topicObserved(
+ "addon-install-origin-blocked"
+ );
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["https://example.org/*"],
+ js: ["createFrame.js"],
+ all_frames: true,
+ },
+ {
+ matches: ["https://test1.example.com/installTrigger*"],
+ js: ["installTrigger.js"],
+ all_frames: true,
+ },
+ ],
+ },
+ files: {
+ "createFrame.js": function () {
+ const frame = document.createElement("iframe");
+ frame.src = "https://test1.example.com/installTrigger/";
+ document.body.appendChild(frame);
+ },
+ "installTrigger.js": `
+ window.InstallTrigger.install({extension: "${XPI_URL}"});
+ `,
+ },
+ });
+
+ await extension.startup();
+
+ await BrowserTestUtils.withNewTab(
+ "https://test1.example.com",
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ const frame = content.document.createElement("iframe");
+ frame.src = "https://example.org";
+ content.document.body.appendChild(frame);
+ });
+
+ await promiseOriginBlocked;
+ Assert.deepEqual(
+ await AddonManager.getAllInstalls(),
+ [],
+ "Expects no pending addon install"
+ );
+ }
+ );
+
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function testInstallTriggerPromptedFromSameOriginFrame() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+
+ const promisePromptedInstallFromThirdParty = TestUtils.topicObserved(
+ "addon-install-blocked"
+ );
+
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ await SpecialPowers.spawn(browser, [XPI_URL], async xpiURL => {
+ const frame = content.document.createElement("iframe");
+ frame.src = "https://example.com";
+ const frameLoaded = new Promise(resolve => {
+ frame.addEventListener("load", resolve, { once: true });
+ });
+ content.document.body.appendChild(frame);
+ await frameLoaded;
+ frame.contentWindow.InstallTrigger.install({ URL: xpiURL });
+ });
+
+ const [subject] = await promisePromptedInstallFromThirdParty;
+ let installInfo = subject.wrappedJSObject;
+ ok(installInfo, "Got a blocked addon install pending");
+ is(
+ installInfo?.installs?.[0]?.state,
+ Services.prefs.getBoolPref(
+ "extensions.postDownloadThirdPartyPrompt",
+ false
+ )
+ ? AddonManager.STATE_DOWNLOADED
+ : AddonManager.STATE_AVAILABLE,
+ "Got a pending addon install"
+ );
+ await installInfo.cancel();
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_task_next_test.js b/toolkit/mozapps/extensions/test/browser/browser_task_next_test.js
new file mode 100644
index 0000000000..f8e3293b82
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_task_next_test.js
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test that we throw if a test created with add_task()
+// calls run_next_test
+
+add_task(async function run_next_throws() {
+ let err = null;
+ try {
+ run_next_test();
+ } catch (e) {
+ err = e;
+ info("run_next_test threw " + err);
+ }
+ ok(err, "run_next_test() should throw an error inside an add_task test");
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_updateid.js b/toolkit/mozapps/extensions/test/browser/browser_updateid.js
new file mode 100644
index 0000000000..c6e6d3030f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_updateid.js
@@ -0,0 +1,87 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that updates that change an add-on's ID show up correctly in the UI
+
+var gProvider;
+var gManagerWindow;
+var gCategoryUtilities;
+
+function getName(item) {
+ return item.addonNameEl.textContent;
+}
+
+async function getUpdateButton(item) {
+ let button = item.querySelector('[action="install-update"]');
+ let panel = button.closest("panel-list");
+ let shown = BrowserTestUtils.waitForEvent(panel, "shown");
+ let moreOptionsButton = item.querySelector('[action="more-options"]');
+ EventUtils.synthesizeMouseAtCenter(moreOptionsButton, {}, item.ownerGlobal);
+ await shown;
+ return button;
+}
+
+add_task(async function test_updateid() {
+ // Close the existing about:addons tab and unrestier the existing MockProvider
+ // instance if a previous failed test has not been able to clear them.
+ if (gManagerWindow) {
+ await close_manager(gManagerWindow);
+ }
+ if (gProvider) {
+ gProvider.unregister();
+ }
+
+ gProvider = new MockProvider();
+
+ gProvider.createAddons([
+ {
+ id: "addon1@tests.mozilla.org",
+ name: "manually updating addon",
+ version: "1.0",
+ applyBackgroundUpdates: AddonManager.AUTOUPDATE_DISABLE,
+ },
+ ]);
+
+ gManagerWindow = await open_manager("addons://list/extension");
+ gCategoryUtilities = new CategoryUtilities(gManagerWindow);
+ await gCategoryUtilities.openType("extension");
+
+ gProvider.createInstalls([
+ {
+ name: "updated add-on",
+ existingAddon: gProvider.addons[0],
+ version: "2.0",
+ },
+ ]);
+ var newAddon = new MockAddon("addon2@tests.mozilla.org");
+ newAddon.name = "updated add-on";
+ newAddon.version = "2.0";
+ newAddon.pendingOperations = AddonManager.PENDING_INSTALL;
+ gProvider.installs[0]._addonToInstall = newAddon;
+
+ var item = getAddonCard(gManagerWindow, "addon1@tests.mozilla.org");
+ is(
+ getName(item),
+ "manually updating addon",
+ "Should show the old name in the list"
+ );
+ const { name, version } = await get_tooltip_info(item, gManagerWindow);
+ is(
+ name,
+ "manually updating addon",
+ "Should show the old name in the tooltip"
+ );
+ is(version, "1.0", "Should still show the old version in the tooltip");
+
+ var update = await getUpdateButton(item);
+ is_element_visible(update, "Update button should be visible");
+
+ item = getAddonCard(gManagerWindow, "addon2@tests.mozilla.org");
+ is(item, null, "Should not show the new version in the list");
+
+ await close_manager(gManagerWindow);
+ gManagerWindow = null;
+ gProvider.unregister();
+ gProvider = null;
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_updatessl.js b/toolkit/mozapps/extensions/test/browser/browser_updatessl.js
new file mode 100644
index 0000000000..9dbeec4a84
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_updatessl.js
@@ -0,0 +1,389 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+let { AddonUpdateChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonUpdateChecker.sys.mjs"
+);
+
+const updatejson = RELATIVE_DIR + "browser_updatessl.json";
+const redirect = RELATIVE_DIR + "redirect.sjs?";
+const SUCCESS = 0;
+const DOWNLOAD_ERROR = AddonManager.ERROR_DOWNLOAD_ERROR;
+
+const HTTP = "http://example.com/";
+const HTTPS = "https://example.com/";
+const NOCERT = "https://nocert.example.com/";
+const SELFSIGNED = "https://self-signed.example.com/";
+const UNTRUSTED = "https://untrusted.example.com/";
+const EXPIRED = "https://expired.example.com/";
+
+const PREF_UPDATE_REQUIREBUILTINCERTS = "extensions.update.requireBuiltInCerts";
+
+var gTests = [];
+var gStart = 0;
+var gLast = 0;
+
+var HTTPObserver = {
+ observeActivity(
+ aChannel,
+ aType,
+ aSubtype,
+ aTimestamp,
+ aSizeData,
+ aStringData
+ ) {
+ aChannel.QueryInterface(Ci.nsIChannel);
+
+ dump(
+ "*** HTTP Activity 0x" +
+ aType.toString(16) +
+ " 0x" +
+ aSubtype.toString(16) +
+ " " +
+ aChannel.URI.spec +
+ "\n"
+ );
+ },
+};
+
+function test() {
+ gStart = Date.now();
+ requestLongerTimeout(4);
+ waitForExplicitFinish();
+
+ let observerService = Cc[
+ "@mozilla.org/network/http-activity-distributor;1"
+ ].getService(Ci.nsIHttpActivityDistributor);
+ observerService.addObserver(HTTPObserver);
+
+ registerCleanupFunction(function () {
+ observerService.removeObserver(HTTPObserver);
+ });
+
+ run_next_test();
+}
+
+function end_test() {
+ var cos = Cc["@mozilla.org/security/certoverride;1"].getService(
+ Ci.nsICertOverrideService
+ );
+ cos.clearValidityOverride("nocert.example.com", -1, {});
+ cos.clearValidityOverride("self-signed.example.com", -1, {});
+ cos.clearValidityOverride("untrusted.example.com", -1, {});
+ cos.clearValidityOverride("expired.example.com", -1, {});
+
+ info("All tests completed in " + (Date.now() - gStart) + "ms");
+ finish();
+}
+
+function add_update_test(mainURL, redirectURL, expectedStatus) {
+ gTests.push([mainURL, redirectURL, expectedStatus]);
+}
+
+function run_update_tests(callback) {
+ function run_next_update_test() {
+ if (!gTests.length) {
+ callback();
+ return;
+ }
+ gLast = Date.now();
+
+ let [mainURL, redirectURL, expectedStatus] = gTests.shift();
+ if (redirectURL) {
+ var url = mainURL + redirect + redirectURL + updatejson;
+ var message =
+ "Should have seen the right result for an update check redirected from " +
+ mainURL +
+ " to " +
+ redirectURL;
+ } else {
+ url = mainURL + updatejson;
+ message =
+ "Should have seen the right result for an update check from " + mainURL;
+ }
+
+ AddonUpdateChecker.checkForUpdates("addon1@tests.mozilla.org", url, {
+ onUpdateCheckComplete(updates) {
+ is(updates.length, 1, "Should be the right number of results");
+ is(SUCCESS, expectedStatus, message);
+ info("Update test ran in " + (Date.now() - gLast) + "ms");
+ run_next_update_test();
+ },
+
+ onUpdateCheckError(status) {
+ is(status, expectedStatus, message);
+ info("Update test ran in " + (Date.now() - gLast) + "ms");
+ run_next_update_test();
+ },
+ });
+ }
+
+ run_next_update_test();
+}
+
+// Runs tests with built-in certificates required and no certificate exceptions.
+add_test(async function test_builtin_required() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_UPDATE_REQUIREBUILTINCERTS, true]],
+ });
+ // Tests that a simple update.json retrieval works as expected.
+ add_update_test(HTTP, null, SUCCESS);
+ add_update_test(HTTPS, null, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, null, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, null, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, null, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, null, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from http to other servers works as expected
+ add_update_test(HTTP, HTTP, SUCCESS);
+ add_update_test(HTTP, HTTPS, SUCCESS);
+ add_update_test(HTTP, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(HTTP, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(HTTP, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(HTTP, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from valid https to other servers works as expected
+ add_update_test(HTTPS, HTTP, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from nocert https to other servers works as expected
+ add_update_test(NOCERT, HTTP, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from self-signed https to other servers works as expected
+ add_update_test(SELFSIGNED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from untrusted https to other servers works as expected
+ add_update_test(UNTRUSTED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from expired https to other servers works as expected
+ add_update_test(EXPIRED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, EXPIRED, DOWNLOAD_ERROR);
+
+ run_update_tests(run_next_test);
+});
+
+// Runs tests without requiring built-in certificates and no certificate
+// exceptions.
+add_test(async function test_builtin_not_required() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_UPDATE_REQUIREBUILTINCERTS, false]],
+ });
+
+ // Tests that a simple update.json retrieval works as expected.
+ add_update_test(HTTP, null, SUCCESS);
+ add_update_test(HTTPS, null, SUCCESS);
+ add_update_test(NOCERT, null, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, null, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, null, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, null, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from http to other servers works as expected
+ add_update_test(HTTP, HTTP, SUCCESS);
+ add_update_test(HTTP, HTTPS, SUCCESS);
+ add_update_test(HTTP, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(HTTP, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(HTTP, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(HTTP, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from valid https to other servers works as expected
+ add_update_test(HTTPS, HTTP, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, HTTPS, SUCCESS);
+ add_update_test(HTTPS, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from nocert https to other servers works as expected
+ add_update_test(NOCERT, HTTP, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from self-signed https to other servers works as expected
+ add_update_test(SELFSIGNED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from untrusted https to other servers works as expected
+ add_update_test(UNTRUSTED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from expired https to other servers works as expected
+ add_update_test(EXPIRED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, EXPIRED, DOWNLOAD_ERROR);
+
+ run_update_tests(run_next_test);
+});
+
+// Set up overrides for the next test.
+add_test(() => {
+ addCertOverrides().then(run_next_test);
+});
+
+// Runs tests with built-in certificates required and all certificate exceptions.
+add_test(async function test_builtin_required_overrides() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_UPDATE_REQUIREBUILTINCERTS, true]],
+ });
+
+ // Tests that a simple update.json retrieval works as expected.
+ add_update_test(HTTP, null, SUCCESS);
+ add_update_test(HTTPS, null, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, null, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, null, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, null, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, null, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from http to other servers works as expected
+ add_update_test(HTTP, HTTP, SUCCESS);
+ add_update_test(HTTP, HTTPS, SUCCESS);
+ add_update_test(HTTP, NOCERT, SUCCESS);
+ add_update_test(HTTP, SELFSIGNED, SUCCESS);
+ add_update_test(HTTP, UNTRUSTED, SUCCESS);
+ add_update_test(HTTP, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from valid https to other servers works as expected
+ add_update_test(HTTPS, HTTP, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from nocert https to other servers works as expected
+ add_update_test(NOCERT, HTTP, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from self-signed https to other servers works as expected
+ add_update_test(SELFSIGNED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from untrusted https to other servers works as expected
+ add_update_test(UNTRUSTED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from expired https to other servers works as expected
+ add_update_test(EXPIRED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, EXPIRED, DOWNLOAD_ERROR);
+
+ run_update_tests(run_next_test);
+});
+
+// Runs tests without requiring built-in certificates and all certificate
+// exceptions.
+add_test(async function test_builtin_not_required_overrides() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_UPDATE_REQUIREBUILTINCERTS, false]],
+ });
+
+ // Tests that a simple update.json retrieval works as expected.
+ add_update_test(HTTP, null, SUCCESS);
+ add_update_test(HTTPS, null, SUCCESS);
+ add_update_test(NOCERT, null, SUCCESS);
+ add_update_test(SELFSIGNED, null, SUCCESS);
+ add_update_test(UNTRUSTED, null, SUCCESS);
+ add_update_test(EXPIRED, null, SUCCESS);
+
+ // Tests that redirecting from http to other servers works as expected
+ add_update_test(HTTP, HTTP, SUCCESS);
+ add_update_test(HTTP, HTTPS, SUCCESS);
+ add_update_test(HTTP, NOCERT, SUCCESS);
+ add_update_test(HTTP, SELFSIGNED, SUCCESS);
+ add_update_test(HTTP, UNTRUSTED, SUCCESS);
+ add_update_test(HTTP, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from valid https to other servers works as expected
+ add_update_test(HTTPS, HTTP, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, HTTPS, SUCCESS);
+ add_update_test(HTTPS, NOCERT, SUCCESS);
+ add_update_test(HTTPS, SELFSIGNED, SUCCESS);
+ add_update_test(HTTPS, UNTRUSTED, SUCCESS);
+ add_update_test(HTTPS, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from nocert https to other servers works as expected
+ add_update_test(NOCERT, HTTP, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, HTTPS, SUCCESS);
+ add_update_test(NOCERT, NOCERT, SUCCESS);
+ add_update_test(NOCERT, SELFSIGNED, SUCCESS);
+ add_update_test(NOCERT, UNTRUSTED, SUCCESS);
+ add_update_test(NOCERT, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from self-signed https to other servers works as expected
+ add_update_test(SELFSIGNED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, HTTPS, SUCCESS);
+ add_update_test(SELFSIGNED, NOCERT, SUCCESS);
+ add_update_test(SELFSIGNED, SELFSIGNED, SUCCESS);
+ add_update_test(SELFSIGNED, UNTRUSTED, SUCCESS);
+ add_update_test(SELFSIGNED, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from untrusted https to other servers works as expected
+ add_update_test(UNTRUSTED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, HTTPS, SUCCESS);
+ add_update_test(UNTRUSTED, NOCERT, SUCCESS);
+ add_update_test(UNTRUSTED, SELFSIGNED, SUCCESS);
+ add_update_test(UNTRUSTED, UNTRUSTED, SUCCESS);
+ add_update_test(UNTRUSTED, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from expired https to other servers works as expected
+ add_update_test(EXPIRED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, HTTPS, SUCCESS);
+ add_update_test(EXPIRED, NOCERT, SUCCESS);
+ add_update_test(EXPIRED, SELFSIGNED, SUCCESS);
+ add_update_test(EXPIRED, UNTRUSTED, SUCCESS);
+ add_update_test(EXPIRED, EXPIRED, SUCCESS);
+
+ run_update_tests(run_next_test);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_updatessl.json b/toolkit/mozapps/extensions/test/browser/browser_updatessl.json
new file mode 100644
index 0000000000..223d1ef2d3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_updatessl.json
@@ -0,0 +1,17 @@
+{
+ "addons": {
+ "addon1@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0",
+ "advisory_max_version": "20"
+ }
+ },
+ "version": "2.0"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/browser/browser_updatessl.json^headers^ b/toolkit/mozapps/extensions/test/browser/browser_updatessl.json^headers^
new file mode 100644
index 0000000000..2e4f8163bb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_updatessl.json^headers^
@@ -0,0 +1 @@
+Connection: close
diff --git a/toolkit/mozapps/extensions/test/browser/browser_verify_l10n_strings.js b/toolkit/mozapps/extensions/test/browser/browser_verify_l10n_strings.js
new file mode 100644
index 0000000000..9442adf310
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_verify_l10n_strings.js
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ChromeUtils.defineESModuleGetters(this, {
+ BuiltInThemes: "resource:///modules/BuiltInThemes.sys.mjs",
+});
+
+// Maps add-on descriptors to updated Fluent IDs. Keep it in sync
+// with the list in XPIDatabase.jsm.
+const updatedAddonFluentIds = new Map([
+ ["extension-default-theme-name", "extension-default-theme-name-auto"],
+]);
+
+add_task(async function test_ensure_bundled_addons_are_localized() {
+ let l10nReg = L10nRegistry.getInstance();
+ let bundles = l10nReg.generateBundlesSync(
+ ["en-US"],
+ ["browser/appExtensionFields.ftl"]
+ );
+ let addons = await AddonManager.getAllAddons();
+ let standardBuiltInThemes = addons.filter(
+ addon =>
+ addon.isBuiltin &&
+ addon.type === "theme" &&
+ !addon.id.endsWith("colorway@mozilla.org")
+ );
+ let bundle = bundles.next().value;
+
+ ok(!!standardBuiltInThemes.length, "Standard built-in themes should exist");
+
+ for (let standardTheme of standardBuiltInThemes) {
+ let l10nId = standardTheme.id.replace("@mozilla.org", "");
+ for (let prop of ["name", "description"]) {
+ let defaultFluentId = `extension-${l10nId}-${prop}`;
+ let fluentId =
+ updatedAddonFluentIds.get(defaultFluentId) || defaultFluentId;
+ ok(
+ bundle.hasMessage(fluentId),
+ `l10n id for ${standardTheme.id} \"${prop}\" attribute should exist`
+ );
+ }
+ }
+
+ let colorwayThemes = Array.from(BuiltInThemes.builtInThemeMap.keys()).filter(
+ id => id.endsWith("colorway@mozilla.org")
+ );
+ ok(!!colorwayThemes.length, "Colorway themes should exist");
+ for (let id of colorwayThemes) {
+ let l10nId = id.replace("@mozilla.org", "");
+ let [, variantName] = l10nId.split("-", 2);
+ if (variantName != "colorway") {
+ let defaultFluentId = `extension-colorways-${variantName}-name`;
+ let fluentId =
+ updatedAddonFluentIds.get(defaultFluentId) || defaultFluentId;
+ ok(
+ bundle.hasMessage(fluentId),
+ `l10n id for ${id} \"name\" attribute should exist`
+ );
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi.js b/toolkit/mozapps/extensions/test/browser/browser_webapi.js
new file mode 100644
index 0000000000..853cd3902a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi.js
@@ -0,0 +1,125 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const TESTPAGE = `${SECURE_TESTROOT}webapi_checkavailable.html`;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.webapi.testing", true]],
+ });
+});
+
+function testWithAPI(task) {
+ return async function () {
+ await BrowserTestUtils.withNewTab(TESTPAGE, task);
+ };
+}
+
+let gProvider = new MockProvider();
+
+let addons = gProvider.createAddons([
+ {
+ id: "addon1@tests.mozilla.org",
+ name: "Test add-on 1",
+ version: "2.1",
+ description: "Short description",
+ type: "extension",
+ userDisabled: false,
+ isActive: true,
+ },
+ {
+ id: "addon2@tests.mozilla.org",
+ name: "Test add-on 2",
+ version: "5.3.7ab",
+ description: null,
+ type: "theme",
+ userDisabled: false,
+ isActive: false,
+ },
+ {
+ id: "addon3@tests.mozilla.org",
+ name: "Test add-on 3",
+ version: "1",
+ description: "Longer description",
+ type: "extension",
+ userDisabled: true,
+ isActive: false,
+ },
+ {
+ id: "addon4@tests.mozilla.org",
+ name: "Test add-on 4",
+ version: "1",
+ description: "Longer description",
+ type: "extension",
+ userDisabled: false,
+ isActive: true,
+ },
+]);
+
+addons[3].permissions &= ~AddonManager.PERM_CAN_UNINSTALL;
+
+function API_getAddonByID(browser, id) {
+ return SpecialPowers.spawn(browser, [id], async function (id) {
+ let addon = await content.navigator.mozAddonManager.getAddonByID(id);
+ let addonDetails = {};
+ for (let prop in addon) {
+ addonDetails[prop] = addon[prop];
+ }
+ // We can't send native objects back so clone its properties.
+ return JSON.parse(JSON.stringify(addonDetails));
+ });
+}
+
+add_task(
+ testWithAPI(async function (browser) {
+ function compareObjects(web, real) {
+ ok(
+ !!Object.keys(web).length,
+ "Got a valid mozAddonManager addon object dump"
+ );
+
+ for (let prop of Object.keys(web)) {
+ let webVal = web[prop];
+ let realVal = real[prop];
+
+ switch (prop) {
+ case "isEnabled":
+ realVal = !real.userDisabled;
+ break;
+
+ case "canUninstall":
+ realVal = Boolean(
+ real.permissions & AddonManager.PERM_CAN_UNINSTALL
+ );
+ break;
+ }
+
+ // null and undefined don't compare well so stringify them first
+ if (realVal === null || realVal === undefined) {
+ realVal = `${realVal}`;
+ webVal = `${webVal}`;
+ }
+
+ is(
+ webVal,
+ realVal,
+ `Property ${prop} should have the right value in add-on ${real.id}`
+ );
+ }
+ }
+
+ let [a1, a2, a3] = await promiseAddonsByIDs([
+ "addon1@tests.mozilla.org",
+ "addon2@tests.mozilla.org",
+ "addon3@tests.mozilla.org",
+ ]);
+ let w1 = await API_getAddonByID(browser, "addon1@tests.mozilla.org");
+ let w2 = await API_getAddonByID(browser, "addon2@tests.mozilla.org");
+ let w3 = await API_getAddonByID(browser, "addon3@tests.mozilla.org");
+
+ compareObjects(w1, a1);
+ compareObjects(w2, a2);
+ compareObjects(w3, a3);
+ })
+);
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi_abuse_report.js b/toolkit/mozapps/extensions/test/browser/browser_webapi_abuse_report.js
new file mode 100644
index 0000000000..25f14ce197
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_abuse_report.js
@@ -0,0 +1,370 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* eslint max-len: ["error", 80] */
+
+loadTestSubscript("head_abuse_report.js");
+
+const TESTPAGE = `${SECURE_TESTROOT}webapi_checkavailable.html`;
+const TELEMETRY_EVENTS_FILTERS = {
+ category: "addonsManager",
+ method: "report",
+};
+const REPORT_PROP_NAMES = [
+ "addon",
+ "addon_signature",
+ "reason",
+ "message",
+ "report_entry_point",
+];
+
+function getObjectProps(obj, propNames) {
+ const res = {};
+ for (const k of propNames) {
+ res[k] = obj[k];
+ }
+ return res;
+}
+
+async function assertSubmittedReport(expectedReportProps) {
+ let reportSubmitted;
+ const onReportSubmitted = AbuseReportTestUtils.promiseReportSubmitHandled(
+ ({ data, request, response }) => {
+ reportSubmitted = JSON.parse(data);
+ handleSubmitRequest({ request, response });
+ }
+ );
+
+ let panelEl = await AbuseReportTestUtils.promiseReportDialogRendered();
+
+ let promiseWinClosed = waitClosedWindow();
+ let promisePanelUpdated = AbuseReportTestUtils.promiseReportUpdated(
+ panelEl,
+ "submit"
+ );
+ panelEl._form.elements.reason.value = expectedReportProps.reason;
+ AbuseReportTestUtils.clickPanelButton(panelEl._btnNext);
+ await promisePanelUpdated;
+
+ panelEl._form.elements.message.value = expectedReportProps.message;
+ // Reset the timestamp of the last report between tests.
+ AbuseReporter._lastReportTimestamp = null;
+ AbuseReportTestUtils.clickPanelButton(panelEl._btnSubmit);
+ await Promise.all([onReportSubmitted, promiseWinClosed]);
+
+ ok(!panelEl.ownerGlobal, "Report dialog window is closed");
+ Assert.deepEqual(
+ getObjectProps(reportSubmitted, REPORT_PROP_NAMES),
+ expectedReportProps,
+ "Got the expected report data submitted"
+ );
+}
+
+add_setup(async function () {
+ await AbuseReportTestUtils.setup();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webapi.testing", true],
+ ["extensions.abuseReport.amWebAPI.enabled", true],
+ ],
+ });
+});
+
+add_task(async function test_report_installed_addon_cancelled() {
+ Services.telemetry.clearEvents();
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
+ const extension = await installTestExtension(ADDON_ID);
+
+ let reportEnabled = await SpecialPowers.spawn(browser, [], () => {
+ return content.navigator.mozAddonManager.abuseReportPanelEnabled;
+ });
+
+ is(reportEnabled, true, "Expect abuseReportPanelEnabled to be true");
+
+ info("Test reportAbuse result on user cancelled report");
+
+ let promiseNewWindow = waitForNewWindow();
+ let promiseWebAPIResult = SpecialPowers.spawn(
+ browser,
+ [ADDON_ID],
+ addonId => content.navigator.mozAddonManager.reportAbuse(addonId)
+ );
+
+ let win = await promiseNewWindow;
+ is(win, AbuseReportTestUtils.getReportDialog(), "Got the report dialog");
+
+ let panelEl = await AbuseReportTestUtils.promiseReportDialogRendered();
+
+ let promiseWinClosed = waitClosedWindow();
+ AbuseReportTestUtils.clickPanelButton(panelEl._btnCancel);
+ let reportResult = await promiseWebAPIResult;
+ is(
+ reportResult,
+ false,
+ "Expect reportAbuse to resolve to false on user cancelled report"
+ );
+ await promiseWinClosed;
+ ok(!panelEl.ownerGlobal, "Report dialog window is closed");
+
+ await extension.unload();
+ });
+
+ // Expect no telemetry events collected for user cancelled reports.
+ TelemetryTestUtils.assertEvents([], TELEMETRY_EVENTS_FILTERS);
+});
+
+add_task(async function test_report_installed_addon_submitted() {
+ Services.telemetry.clearEvents();
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
+ const extension = await installTestExtension(ADDON_ID);
+
+ let promiseNewWindow = waitForNewWindow();
+ let promiseWebAPIResult = SpecialPowers.spawn(browser, [ADDON_ID], id =>
+ content.navigator.mozAddonManager.reportAbuse(id)
+ );
+ let win = await promiseNewWindow;
+ is(win, AbuseReportTestUtils.getReportDialog(), "Got the report dialog");
+
+ await assertSubmittedReport({
+ addon: ADDON_ID,
+ addon_signature: "missing",
+ message: "fake report message",
+ reason: "unwanted",
+ report_entry_point: "amo",
+ });
+
+ let reportResult = await promiseWebAPIResult;
+ is(
+ reportResult,
+ true,
+ "Expect reportAbuse to resolve to false on user cancelled report"
+ );
+
+ await extension.unload();
+ });
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "amo",
+ value: ADDON_ID,
+ extra: { addon_type: "extension" },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+});
+
+add_task(async function test_report_unknown_not_installed_addon() {
+ const addonId = "unknown-addon@mochi.test";
+ Services.telemetry.clearEvents();
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
+ let promiseWebAPIResult = SpecialPowers.spawn(browser, [addonId], id =>
+ content.navigator.mozAddonManager.reportAbuse(id).catch(err => {
+ return { name: err.name, message: err.message };
+ })
+ );
+
+ await Assert.deepEqual(
+ await promiseWebAPIResult,
+ { name: "Error", message: "Error creating abuse report" },
+ "Got the expected rejected error on reporting unknown addon"
+ );
+
+ ok(!AbuseReportTestUtils.getReportDialog(), "No report dialog is open");
+ });
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "amo",
+ value: addonId,
+ extra: { error_type: "ERROR_AMODETAILS_NOTFOUND" },
+ },
+ {
+ object: "amo",
+ value: addonId,
+ extra: { error_type: "ERROR_ADDON_NOTFOUND" },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+});
+
+add_task(async function test_report_not_installed_addon() {
+ const addonId = "not-installed-addon@mochi.test";
+ Services.telemetry.clearEvents();
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
+ const fakeAMODetails = {
+ name: "fake name",
+ current_version: { version: "1.0" },
+ type: "extension",
+ icon_url: "http://test.addons.org/asserts/fake-icon-url.png",
+ homepage: "http://fake.url/homepage",
+ authors: [{ name: "author1", url: "http://fake.url/author1" }],
+ is_recommended: false,
+ };
+
+ AbuseReportTestUtils.amoAddonDetailsMap.set(addonId, fakeAMODetails);
+ registerCleanupFunction(() =>
+ AbuseReportTestUtils.amoAddonDetailsMap.clear()
+ );
+
+ let promiseNewWindow = waitForNewWindow();
+
+ let promiseWebAPIResult = SpecialPowers.spawn(browser, [addonId], id =>
+ content.navigator.mozAddonManager.reportAbuse(id)
+ );
+ let win = await promiseNewWindow;
+ is(win, AbuseReportTestUtils.getReportDialog(), "Got the report dialog");
+
+ await assertSubmittedReport({
+ addon: addonId,
+ addon_signature: "unknown",
+ message: "fake report message",
+ reason: "other",
+ report_entry_point: "amo",
+ });
+
+ let reportResult = await promiseWebAPIResult;
+ is(
+ reportResult,
+ true,
+ "Expect reportAbuse to resolve to true on submitted report"
+ );
+ });
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "amo",
+ value: addonId,
+ extra: { addon_type: "extension" },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+});
+
+add_task(async function test_amo_report_on_report_already_inprogress() {
+ const extension = await installTestExtension(ADDON_ID);
+ const reportDialog = await AbuseReporter.openDialog(
+ ADDON_ID,
+ "menu",
+ gBrowser.selectedBrowser
+ );
+ await AbuseReportTestUtils.promiseReportDialogRendered();
+ ok(reportDialog.window, "Got an open report dialog");
+
+ let promiseWinClosed = waitClosedWindow();
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
+ const promiseAMOResult = SpecialPowers.spawn(browser, [ADDON_ID], id =>
+ content.navigator.mozAddonManager.reportAbuse(id)
+ );
+
+ await promiseWinClosed;
+ ok(reportDialog.window.closed, "previous report dialog should be closed");
+
+ is(
+ await reportDialog.promiseAMOResult,
+ undefined,
+ "old report cancelled after AMO called mozAddonManager.reportAbuse"
+ );
+
+ const panelEl = await AbuseReportTestUtils.promiseReportDialogRendered();
+
+ const { report } = AbuseReportTestUtils.getReportDialogParams();
+ Assert.deepEqual(
+ {
+ reportEntryPoint: report.reportEntryPoint,
+ addonId: report.addon.id,
+ },
+ {
+ reportEntryPoint: "amo",
+ addonId: ADDON_ID,
+ },
+ "Got the expected report from the opened report dialog"
+ );
+
+ promiseWinClosed = waitClosedWindow();
+ AbuseReportTestUtils.clickPanelButton(panelEl._btnCancel);
+ await promiseWinClosed;
+
+ is(
+ await promiseAMOResult,
+ false,
+ "AMO report request resolved to false on cancel button clicked"
+ );
+ });
+
+ await extension.unload();
+});
+
+add_task(async function test_reject_on_unsupported_addon_types() {
+ const addonId = "not-supported-addon-type@mochi.test";
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
+ const fakeAMODetails = {
+ name: "fake name",
+ current_version: { version: "1.0" },
+ type: "fake-unsupported-addon-type",
+ };
+
+ AbuseReportTestUtils.amoAddonDetailsMap.set(addonId, fakeAMODetails);
+ registerCleanupFunction(() =>
+ AbuseReportTestUtils.amoAddonDetailsMap.clear()
+ );
+
+ let webAPIResult = await SpecialPowers.spawn(browser, [addonId], id =>
+ content.navigator.mozAddonManager.reportAbuse(id).then(
+ res => ({ gotRejection: false, result: res }),
+ err => ({ gotRejection: true, message: err.message })
+ )
+ );
+
+ Assert.deepEqual(
+ webAPIResult,
+ { gotRejection: true, message: "Error creating abuse report" },
+ "Got the expected rejection from mozAddonManager.reportAbuse"
+ );
+ });
+});
+
+add_task(async function test_report_on_disabled_webapi() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.abuseReport.amWebAPI.enabled", false]],
+ });
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
+ let reportEnabled = await SpecialPowers.spawn(browser, [], () => {
+ return content.navigator.mozAddonManager.abuseReportPanelEnabled;
+ });
+
+ is(reportEnabled, false, "Expect abuseReportPanelEnabled to be false");
+
+ info("Test reportAbuse result on report webAPI disabled");
+
+ let promiseWebAPIResult = SpecialPowers.spawn(
+ browser,
+ ["an-addon@mochi.test"],
+ addonId =>
+ content.navigator.mozAddonManager.reportAbuse(addonId).catch(err => {
+ return { name: err.name, message: err.message };
+ })
+ );
+
+ Assert.deepEqual(
+ await promiseWebAPIResult,
+ { name: "Error", message: "amWebAPI reportAbuse not supported" },
+ "Got the expected rejected error"
+ );
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi_access.js b/toolkit/mozapps/extensions/test/browser/browser_webapi_access.js
new file mode 100644
index 0000000000..aec6ddedca
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_access.js
@@ -0,0 +1,146 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function check_frame_availability(browser) {
+ return check_availability(browser.browsingContext.children[0]);
+}
+
+function check_availability(browser) {
+ return SpecialPowers.spawn(browser, [], async function () {
+ return content.document.getElementById("result").textContent == "true";
+ });
+}
+
+// Test that initially the API isn't available in the test domain
+add_task(async function test_not_available() {
+ await BrowserTestUtils.withNewTab(
+ `${SECURE_TESTROOT}webapi_checkavailable.html`,
+ async function test_not_available(browser) {
+ let available = await check_availability(browser);
+ ok(!available, "API should not be available.");
+ }
+ );
+});
+
+// Test that with testing on the API is available in the test domain
+add_task(async function test_available() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.webapi.testing", true]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ `${SECURE_TESTROOT}webapi_checkavailable.html`,
+ async function test_not_available(browser) {
+ let available = await check_availability(browser);
+ ok(available, "API should be available.");
+ }
+ );
+});
+
+// Test that the API is not available in a bad domain
+add_task(async function test_bad_domain() {
+ await BrowserTestUtils.withNewTab(
+ `${SECURE_TESTROOT2}webapi_checkavailable.html`,
+ async function test_not_available(browser) {
+ let available = await check_availability(browser);
+ ok(!available, "API should not be available.");
+ }
+ );
+});
+
+// Test that the API is only available in https sites
+add_task(async function test_not_available_http() {
+ await BrowserTestUtils.withNewTab(
+ `${TESTROOT}webapi_checkavailable.html`,
+ async function test_not_available(browser) {
+ let available = await check_availability(browser);
+ ok(!available, "API should not be available.");
+ }
+ );
+});
+
+// Test that the API is available when in a frame of the test domain
+add_task(async function test_available_framed() {
+ await BrowserTestUtils.withNewTab(
+ `${SECURE_TESTROOT}webapi_checkframed.html`,
+ async function test_available(browser) {
+ let available = await check_frame_availability(browser);
+ ok(available, "API should be available.");
+ }
+ );
+});
+
+// Test that if the external frame is http then the inner frame doesn't have
+// the API
+add_task(async function test_not_available_http_framed() {
+ await BrowserTestUtils.withNewTab(
+ `${TESTROOT}webapi_checkframed.html`,
+ async function test_not_available(browser) {
+ let available = await check_frame_availability(browser);
+ ok(!available, "API should not be available.");
+ }
+ );
+});
+
+// Test that if the external frame is a bad domain then the inner frame doesn't
+// have the API
+add_task(async function test_not_available_framed() {
+ await BrowserTestUtils.withNewTab(
+ `${SECURE_TESTROOT2}webapi_checkframed.html`,
+ async function test_not_available(browser) {
+ let available = await check_frame_availability(browser);
+ ok(!available, "API should not be available.");
+ }
+ );
+});
+
+// Test that a window navigated to a bad domain doesn't allow access to the API
+add_task(async function test_navigated_window() {
+ await BrowserTestUtils.withNewTab(
+ `${SECURE_TESTROOT2}webapi_checknavigatedwindow.html`,
+ async function test_available(browser) {
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ await content.wrappedJSObject.openWindow();
+ });
+
+ // Should be a new tab open
+ let tab = await tabPromise;
+ let loadPromise = BrowserTestUtils.browserLoaded(
+ gBrowser.getBrowserForTab(tab)
+ );
+
+ SpecialPowers.spawn(browser, [], async function () {
+ content.wrappedJSObject.navigate();
+ });
+
+ await loadPromise;
+
+ let available = await SpecialPowers.spawn(browser, [], async function () {
+ return content.wrappedJSObject.check();
+ });
+
+ ok(!available, "API should not be available.");
+
+ gBrowser.removeTab(tab);
+ }
+ );
+});
+
+// Check that if a page is embedded in a chrome content UI that it can still
+// access the API.
+add_task(async function test_chrome_frame() {
+ SpecialPowers.pushPrefEnv({
+ set: [["security.allow_unsafe_parent_loads", true]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ `${CHROMEROOT}webapi_checkchromeframe.xhtml`,
+ async function test_available(browser) {
+ let available = await check_frame_availability(browser);
+ ok(available, "API should be available.");
+ }
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi_addon_listener.js b/toolkit/mozapps/extensions/test/browser/browser_webapi_addon_listener.js
new file mode 100644
index 0000000000..3692644714
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_addon_listener.js
@@ -0,0 +1,124 @@
+const TESTPAGE = `${SECURE_TESTROOT}webapi_addon_listener.html`;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.webapi.testing", true]],
+ });
+});
+
+async function getListenerEvents(browser) {
+ let result = await SpecialPowers.spawn(browser, [], async function () {
+ return content.document.getElementById("result").textContent;
+ });
+
+ return result.split("\n").map(JSON.parse);
+}
+
+const RESTARTLESS_ID = "restartless@tests.mozilla.org";
+const INSTALL_ID = "install@tests.mozilla.org";
+const CANCEL_ID = "cancel@tests.mozilla.org";
+
+let provider = new MockProvider();
+provider.createAddons([
+ {
+ id: RESTARTLESS_ID,
+ name: "Restartless add-on",
+ operationsRequiringRestart: AddonManager.OP_NEED_RESTART_NONE,
+ },
+ {
+ id: CANCEL_ID,
+ name: "Add-on for uninstall cancel",
+ },
+]);
+
+// Test enable/disable events for restartless
+add_task(async function test_restartless() {
+ await BrowserTestUtils.withNewTab(TESTPAGE, async function (browser) {
+ let addon = await promiseAddonByID(RESTARTLESS_ID);
+ is(addon.userDisabled, false, "addon is enabled");
+
+ // disable it
+ await addon.disable();
+ is(addon.userDisabled, true, "addon was disabled successfully");
+
+ // re-enable it
+ await addon.enable();
+ is(addon.userDisabled, false, "addon was re-enabled successfuly");
+
+ let events = await getListenerEvents(browser);
+ let expected = [
+ { id: RESTARTLESS_ID, event: "onDisabling" },
+ { id: RESTARTLESS_ID, event: "onDisabled" },
+ { id: RESTARTLESS_ID, event: "onEnabling" },
+ { id: RESTARTLESS_ID, event: "onEnabled" },
+ ];
+ Assert.deepEqual(events, expected, "Got expected disable/enable events");
+ });
+});
+
+// Test install events
+add_task(async function test_restartless() {
+ await BrowserTestUtils.withNewTab(TESTPAGE, async function (browser) {
+ let addon = new MockAddon(
+ INSTALL_ID,
+ "installme",
+ null,
+ AddonManager.OP_NEED_RESTART_NONE
+ );
+ let install = new MockInstall(null, null, addon);
+
+ let installPromise = new Promise(resolve => {
+ install.addTestListener({
+ onInstallEnded: resolve,
+ });
+ });
+
+ provider.addInstall(install);
+ install.install();
+
+ await installPromise;
+
+ let events = await getListenerEvents(browser);
+ let expected = [
+ { id: INSTALL_ID, event: "onInstalling" },
+ { id: INSTALL_ID, event: "onInstalled" },
+ ];
+ Assert.deepEqual(events, expected, "Got expected install events");
+ });
+});
+
+// Test uninstall
+add_task(async function test_uninstall() {
+ await BrowserTestUtils.withNewTab(TESTPAGE, async function (browser) {
+ let addon = await promiseAddonByID(RESTARTLESS_ID);
+ isnot(addon, null, "Found add-on for uninstall");
+
+ addon.uninstall();
+
+ let events = await getListenerEvents(browser);
+ let expected = [
+ { id: RESTARTLESS_ID, event: "onUninstalling" },
+ { id: RESTARTLESS_ID, event: "onUninstalled" },
+ ];
+ Assert.deepEqual(events, expected, "Got expected uninstall events");
+ });
+});
+
+// Test cancel of uninstall.
+add_task(async function test_cancel() {
+ await BrowserTestUtils.withNewTab(TESTPAGE, async function (browser) {
+ let addon = await promiseAddonByID(CANCEL_ID);
+ isnot(addon, null, "Found add-on for cancelling uninstall");
+
+ addon.uninstall();
+
+ let events = await getListenerEvents(browser);
+ let expected = [{ id: CANCEL_ID, event: "onUninstalling" }];
+ Assert.deepEqual(events, expected, "Got expected uninstalling event");
+
+ addon.cancelUninstall();
+ events = await getListenerEvents(browser);
+ expected.push({ id: CANCEL_ID, event: "onOperationCancelled" });
+ Assert.deepEqual(events, expected, "Got expected cancel event");
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi_enable.js b/toolkit/mozapps/extensions/test/browser/browser_webapi_enable.js
new file mode 100644
index 0000000000..25989bf797
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_enable.js
@@ -0,0 +1,63 @@
+const TESTPAGE = `${SECURE_TESTROOT}webapi_addon_listener.html`;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.webapi.testing", true]],
+ });
+});
+
+async function getListenerEvents(browser) {
+ let result = await SpecialPowers.spawn(browser, [], async function () {
+ return content.document.getElementById("result").textContent;
+ });
+
+ return result.split("\n").map(JSON.parse);
+}
+
+const ID = "test@tests.mozilla.org";
+
+let provider = new MockProvider();
+provider.createAddons([
+ {
+ id: ID,
+ name: "Test add-on",
+ operationsRequiringRestart: AddonManager.OP_NEED_RESTART_NONE,
+ },
+]);
+
+// Test disable and enable from content
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(TESTPAGE, async function (browser) {
+ let addon = await promiseAddonByID(ID);
+ isnot(addon, null, "Test addon exists");
+ is(addon.userDisabled, false, "addon is enabled");
+
+ // Disable the addon from content.
+ await SpecialPowers.spawn(browser, [], async function () {
+ return content.navigator.mozAddonManager
+ .getAddonByID("test@tests.mozilla.org")
+ .then(addon => addon.setEnabled(false));
+ });
+
+ let events = await getListenerEvents(browser);
+ let expected = [
+ { id: ID, event: "onDisabling" },
+ { id: ID, event: "onDisabled" },
+ ];
+ Assert.deepEqual(events, expected, "Got expected disable events");
+
+ // Enable the addon from content.
+ await SpecialPowers.spawn(browser, [], async function () {
+ return content.navigator.mozAddonManager
+ .getAddonByID("test@tests.mozilla.org")
+ .then(addon => addon.setEnabled(true));
+ });
+
+ events = await getListenerEvents(browser);
+ expected = expected.concat([
+ { id: ID, event: "onEnabling" },
+ { id: ID, event: "onEnabled" },
+ ]);
+ Assert.deepEqual(events, expected, "Got expected enable events");
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi_install.js b/toolkit/mozapps/extensions/test/browser/browser_webapi_install.js
new file mode 100644
index 0000000000..7a151347cc
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_install.js
@@ -0,0 +1,601 @@
+/* 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 { EnterprisePolicyTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+);
+
+const TESTPATH = "webapi_checkavailable.html";
+const TESTPAGE = `${SECURE_TESTROOT}${TESTPATH}`;
+const XPI_URL = `${SECURE_TESTROOT}../xpinstall/amosigned.xpi`;
+const XPI_ADDON_ID = "amosigned-xpi@tests.mozilla.org";
+
+const XPI_SHA =
+ "sha256:91121ed2c27f670f2307b9aebdd30979f147318c7fb9111c254c14ddbb84e4b0";
+
+const ID = "amosigned-xpi@tests.mozilla.org";
+// eh, would be good to just stat the real file instead of this...
+const XPI_LEN = 4287;
+
+AddonTestUtils.initMochitest(this);
+
+function waitForClear() {
+ const MSG = "WebAPICleanup";
+ return new Promise(resolve => {
+ let listener = {
+ receiveMessage(msg) {
+ if (msg.name == MSG) {
+ Services.mm.removeMessageListener(MSG, listener);
+ resolve();
+ }
+ },
+ };
+
+ Services.mm.addMessageListener(MSG, listener, true);
+ });
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webapi.testing", true],
+ ["extensions.install.requireBuiltInCerts", false],
+ ],
+ });
+ info("added preferences");
+});
+
+// Wrapper around a common task to run in the content process to test
+// the mozAddonManager API. Takes a URL for the XPI to install and an
+// array of steps, each of which can either be an action to take
+// (i.e., start or cancel the install) or an install event to wait for.
+// Steps that look for a specific event may also include a "props" property
+// with properties that the AddonInstall object is expected to have when
+// that event is triggered.
+async function testInstall(browser, args, steps, description) {
+ let success = await SpecialPowers.spawn(
+ browser,
+ [{ args, steps }],
+ async function (opts) {
+ let { args, steps } = opts;
+ let install = await content.navigator.mozAddonManager.createInstall(args);
+ if (!install) {
+ await Promise.reject(
+ "createInstall() did not return an install object"
+ );
+ }
+
+ // Check that the initial state of the AddonInstall is sane.
+ if (install.state != "STATE_AVAILABLE") {
+ await Promise.reject("new install should be in STATE_AVAILABLE");
+ }
+ if (install.error != null) {
+ await Promise.reject("new install should have null error");
+ }
+
+ const events = [
+ "onDownloadStarted",
+ "onDownloadProgress",
+ "onDownloadEnded",
+ "onDownloadCancelled",
+ "onDownloadFailed",
+ "onInstallStarted",
+ "onInstallEnded",
+ "onInstallCancelled",
+ "onInstallFailed",
+ ];
+ let eventWaiter = null;
+ let receivedEvents = [];
+ let prevEvent = null;
+ events.forEach(event => {
+ install.addEventListener(event, e => {
+ receivedEvents.push({
+ event,
+ state: install.state,
+ error: install.error,
+ progress: install.progress,
+ maxProgress: install.maxProgress,
+ });
+ if (eventWaiter) {
+ eventWaiter();
+ }
+ });
+ });
+
+ // Returns a promise that is resolved when the given event occurs
+ // or rejects if a different event comes first or if props is supplied
+ // and properties on the AddonInstall don't match those in props.
+ function expectEvent(event, props) {
+ return new Promise((resolve, reject) => {
+ function check() {
+ let received = receivedEvents.shift();
+ // Skip any repeated onDownloadProgress events.
+ while (
+ received &&
+ received.event == prevEvent &&
+ prevEvent == "onDownloadProgress"
+ ) {
+ received = receivedEvents.shift();
+ }
+ // Wait for more events if we skipped all there were.
+ if (!received) {
+ eventWaiter = () => {
+ eventWaiter = null;
+ check();
+ };
+ return;
+ }
+ prevEvent = received.event;
+ if (received.event != event) {
+ let err = new Error(
+ `expected ${event} but got ${received.event}`
+ );
+ reject(err);
+ }
+ if (props) {
+ for (let key of Object.keys(props)) {
+ if (received[key] != props[key]) {
+ throw new Error(
+ `AddonInstall property ${key} was ${received[key]} but expected ${props[key]}`
+ );
+ }
+ }
+ }
+ resolve();
+ }
+ check();
+ });
+ }
+
+ while (steps.length) {
+ let nextStep = steps.shift();
+ if (nextStep.action) {
+ if (nextStep.action == "install") {
+ try {
+ await install.install();
+ if (nextStep.expectError) {
+ throw new Error("Expected install to fail but it did not");
+ }
+ } catch (err) {
+ if (!nextStep.expectError) {
+ throw new Error("Install failed unexpectedly");
+ }
+ }
+ } else if (nextStep.action == "cancel") {
+ await install.cancel();
+ } else {
+ throw new Error(`unknown action ${nextStep.action}`);
+ }
+ } else {
+ await expectEvent(nextStep.event, nextStep.props);
+ }
+ }
+
+ return true;
+ }
+ );
+
+ is(success, true, description);
+}
+
+function makeInstallTest(task) {
+ return async function () {
+ // withNewTab() will close the test tab before returning, at which point
+ // the cleanup event will come from the content process. We need to see
+ // that event but don't want to race to install a listener for it after
+ // the tab is closed. So set up the listener now but don't yield the
+ // listening promise until below.
+ let clearPromise = waitForClear();
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, task);
+
+ await clearPromise;
+ is(AddonManager.webAPI.installs.size, 0, "AddonInstall was cleaned up");
+ };
+}
+
+function makeRegularTest(options, what) {
+ return makeInstallTest(async function (browser) {
+ let steps = [
+ { action: "install" },
+ {
+ event: "onDownloadStarted",
+ props: { state: "STATE_DOWNLOADING" },
+ },
+ {
+ event: "onDownloadProgress",
+ props: { maxProgress: XPI_LEN },
+ },
+ {
+ event: "onDownloadEnded",
+ props: {
+ state: "STATE_DOWNLOADED",
+ progress: XPI_LEN,
+ maxProgress: XPI_LEN,
+ },
+ },
+ {
+ event: "onInstallStarted",
+ props: { state: "STATE_INSTALLING" },
+ },
+ {
+ event: "onInstallEnded",
+ props: { state: "STATE_INSTALLED" },
+ },
+ ];
+
+ let installPromptPromise = promisePopupNotificationShown(
+ "addon-webext-permissions"
+ ).then(panel => {
+ panel.button.click();
+ });
+
+ let promptPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ options.addonId
+ );
+
+ await testInstall(browser, options, steps, what);
+
+ await installPromptPromise;
+
+ await promptPromise;
+
+ // Sanity check to ensure that the test in makeInstallTest() that
+ // installs.size == 0 means we actually did clean up.
+ ok(
+ AddonManager.webAPI.installs.size > 0,
+ "webAPI is tracking the AddonInstall"
+ );
+
+ let addon = await promiseAddonByID(ID);
+ isnot(addon, null, "Found the addon");
+
+ // Check that the expected installTelemetryInfo has been stored in the addon details.
+ AddonTestUtils.checkInstallInfo(addon, {
+ method: "amWebAPI",
+ source: "test-host",
+ sourceURL: /https:\/\/example.com\/.*\/webapi_checkavailable.html/,
+ });
+
+ await addon.uninstall();
+
+ addon = await promiseAddonByID(ID);
+ is(addon, null, "Addon was uninstalled");
+ });
+}
+
+let addonId = XPI_ADDON_ID;
+add_task(makeRegularTest({ url: XPI_URL, addonId }, "a basic install works"));
+add_task(
+ makeRegularTest(
+ { url: XPI_URL, addonId, hash: null },
+ "install with hash=null works"
+ )
+);
+add_task(
+ makeRegularTest(
+ { url: XPI_URL, addonId, hash: "" },
+ "install with empty string for hash works"
+ )
+);
+add_task(
+ makeRegularTest(
+ { url: XPI_URL, addonId, hash: XPI_SHA },
+ "install with hash works"
+ )
+);
+
+add_task(
+ makeInstallTest(async function (browser) {
+ let steps = [
+ { action: "cancel" },
+ {
+ event: "onDownloadCancelled",
+ props: {
+ state: "STATE_CANCELLED",
+ error: null,
+ },
+ },
+ ];
+
+ await testInstall(
+ browser,
+ { url: XPI_URL },
+ steps,
+ "canceling an install works"
+ );
+
+ let addons = await promiseAddonsByIDs([ID]);
+ is(addons[0], null, "The addon was not installed");
+
+ ok(
+ AddonManager.webAPI.installs.size > 0,
+ "webAPI is tracking the AddonInstall"
+ );
+ })
+);
+
+add_task(
+ makeInstallTest(async function (browser) {
+ let steps = [
+ { action: "install", expectError: true },
+ {
+ event: "onDownloadStarted",
+ props: { state: "STATE_DOWNLOADING" },
+ },
+ { event: "onDownloadProgress" },
+ {
+ event: "onDownloadFailed",
+ props: {
+ state: "STATE_DOWNLOAD_FAILED",
+ error: "ERROR_NETWORK_FAILURE",
+ },
+ },
+ ];
+
+ await testInstall(
+ browser,
+ { url: XPI_URL + "bogus" },
+ steps,
+ "install of a bad url fails"
+ );
+
+ let addons = await promiseAddonsByIDs([ID]);
+ is(addons[0], null, "The addon was not installed");
+
+ ok(
+ AddonManager.webAPI.installs.size > 0,
+ "webAPI is tracking the AddonInstall"
+ );
+ })
+);
+
+add_task(
+ makeInstallTest(async function (browser) {
+ let steps = [
+ { action: "install", expectError: true },
+ {
+ event: "onDownloadStarted",
+ props: { state: "STATE_DOWNLOADING" },
+ },
+ { event: "onDownloadProgress" },
+ {
+ event: "onDownloadFailed",
+ props: {
+ state: "STATE_DOWNLOAD_FAILED",
+ error: "ERROR_INCORRECT_HASH",
+ },
+ },
+ ];
+
+ await testInstall(
+ browser,
+ { url: XPI_URL, hash: "sha256:bogus" },
+ steps,
+ "install with bad hash fails"
+ );
+
+ let addons = await promiseAddonsByIDs([ID]);
+ is(addons[0], null, "The addon was not installed");
+
+ ok(
+ AddonManager.webAPI.installs.size > 0,
+ "webAPI is tracking the AddonInstall"
+ );
+ })
+);
+
+add_task(async function test_permissions_and_policy() {
+ async function testBadUrl(url, pattern, successMessage) {
+ gBrowser.selectedTab = await BrowserTestUtils.addTab(gBrowser, TESTPAGE);
+ let browser = gBrowser.getBrowserForTab(gBrowser.selectedTab);
+ await BrowserTestUtils.browserLoaded(browser);
+ let result = await SpecialPowers.spawn(
+ browser,
+ [{ url, pattern }],
+ function (opts) {
+ return new Promise(resolve => {
+ content.navigator.mozAddonManager
+ .createInstall({ url: opts.url })
+ .then(
+ () => {
+ resolve({
+ success: false,
+ message: "createInstall should not have succeeded",
+ });
+ },
+ err => {
+ if (err.message.match(new RegExp(opts.pattern))) {
+ resolve({ success: true });
+ }
+ resolve({
+ success: false,
+ message: `Wrong error message: ${err.message}`,
+ });
+ }
+ );
+ });
+ }
+ );
+ is(result.success, true, result.message || successMessage);
+ }
+
+ await testBadUrl(
+ "i am not a url",
+ "NS_ERROR_MALFORMED_URI",
+ "Installing from an unparseable URL fails"
+ );
+ gBrowser.removeTab(gBrowser.selectedTab);
+
+ let popupPromise = promisePopupNotificationShown(
+ "addon-install-webapi-blocked"
+ );
+ await Promise.all([
+ testBadUrl(
+ "https://addons.not-really-mozilla.org/impostor.xpi",
+ "not permitted",
+ "Installing from non-approved URL fails"
+ ),
+ popupPromise,
+ ]);
+
+ gBrowser.removeTab(gBrowser.selectedTab);
+
+ const blocked_install_message = "Custom Policy Block Message";
+
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: [],
+ blocked_install_message,
+ },
+ },
+ },
+ });
+
+ popupPromise = promisePopupNotificationShown("addon-install-policy-blocked");
+
+ await testBadUrl(
+ XPI_URL,
+ "not permitted by policy",
+ "Installing from policy blocked origin fails"
+ );
+
+ const panel = await popupPromise;
+ const description = panel.querySelector(
+ ".popup-notification-description"
+ ).textContent;
+ ok(
+ description.startsWith("Your system administrator"),
+ "Policy specific error is shown."
+ );
+ ok(
+ description.endsWith(` ${blocked_install_message}`),
+ `Found the expected custom blocked message in "${description}"`
+ );
+
+ gBrowser.removeTab(gBrowser.selectedTab);
+
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["<all_urls>"],
+ },
+ },
+ },
+ });
+});
+
+add_task(
+ makeInstallTest(async function (browser) {
+ let xpiURL = `${SECURE_TESTROOT}../xpinstall/incompatible.xpi`;
+ let id = "incompatible-xpi@tests.mozilla.org";
+
+ let steps = [
+ { action: "install", expectError: true },
+ {
+ event: "onDownloadStarted",
+ props: { state: "STATE_DOWNLOADING" },
+ },
+ { event: "onDownloadProgress" },
+ { event: "onDownloadEnded" },
+ { event: "onDownloadCancelled" },
+ ];
+
+ await testInstall(
+ browser,
+ { url: xpiURL },
+ steps,
+ "install of an incompatible XPI fails"
+ );
+
+ let addons = await promiseAddonsByIDs([id]);
+ is(addons[0], null, "The addon was not installed");
+ })
+);
+
+add_task(
+ makeInstallTest(async function (browser) {
+ const options = { url: XPI_URL, addonId };
+ let steps = [
+ { action: "install" },
+ {
+ event: "onDownloadStarted",
+ props: { state: "STATE_DOWNLOADING" },
+ },
+ {
+ event: "onDownloadProgress",
+ props: { maxProgress: XPI_LEN },
+ },
+ {
+ event: "onDownloadEnded",
+ props: {
+ state: "STATE_DOWNLOADED",
+ progress: XPI_LEN,
+ maxProgress: XPI_LEN,
+ },
+ },
+ {
+ event: "onInstallStarted",
+ props: { state: "STATE_INSTALLING" },
+ },
+ {
+ event: "onInstallEnded",
+ props: { state: "STATE_INSTALLED" },
+ },
+ ];
+
+ await SpecialPowers.spawn(browser, [TESTPATH], testPath => {
+ // `sourceURL` should match the exact location, even after a location
+ // update using the history API. In this case, we update the URL with
+ // query parameters and expect `sourceURL` to contain those parameters.
+ content.history.pushState(
+ {}, // state
+ "", // title
+ `/${testPath}?some=query&par=am`
+ );
+ });
+
+ let installPromptPromise = promisePopupNotificationShown(
+ "addon-webext-permissions"
+ ).then(panel => {
+ panel.button.click();
+ });
+
+ let promptPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ options.addonId
+ );
+
+ await Promise.all([
+ testInstall(browser, options, steps, "install to check source URL"),
+ installPromptPromise,
+ promptPromise,
+ ]);
+
+ let addon = await promiseAddonByID(ID);
+
+ registerCleanupFunction(async () => {
+ await addon.uninstall();
+ });
+
+ // Check that the expected installTelemetryInfo has been stored in the
+ // addon details.
+ AddonTestUtils.checkInstallInfo(addon, {
+ method: "amWebAPI",
+ source: "test-host",
+ sourceURL:
+ "https://example.com/webapi_checkavailable.html?some=query&par=am",
+ });
+ })
+);
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi_install_disabled.js b/toolkit/mozapps/extensions/test/browser/browser_webapi_install_disabled.js
new file mode 100644
index 0000000000..5bc291fe7a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_install_disabled.js
@@ -0,0 +1,60 @@
+const TESTPAGE = `${SECURE_TESTROOT}webapi_checkavailable.html`;
+const XPI_URL = `${SECURE_TESTROOT}../xpinstall/amosigned.xpi`;
+
+function waitForClear() {
+ const MSG = "WebAPICleanup";
+ return new Promise(resolve => {
+ let listener = {
+ receiveMessage(msg) {
+ if (msg.name == MSG) {
+ Services.mm.removeMessageListener(MSG, listener);
+ resolve();
+ }
+ },
+ };
+
+ Services.mm.addMessageListener(MSG, listener, true);
+ });
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webapi.testing", true],
+ ["xpinstall.enabled", false],
+ ["extensions.install.requireBuiltInCerts", false],
+ ],
+ });
+ info("added preferences");
+});
+
+async function testInstall(browser, args) {
+ let success = await SpecialPowers.spawn(
+ browser,
+ [{ args }],
+ async function (opts) {
+ let { args } = opts;
+ let install;
+ try {
+ install = await content.navigator.mozAddonManager.createInstall(args);
+ } catch (e) {}
+ return !!install;
+ }
+ );
+ is(success, false, "Install was blocked");
+}
+
+add_task(async function () {
+ // withNewTab() will close the test tab before returning, at which point
+ // the cleanup event will come from the content process. We need to see
+ // that event but don't want to race to install a listener for it after
+ // the tab is closed. So set up the listener now but don't yield the
+ // listening promise until below.
+ let clearPromise = waitForClear();
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async function (browser) {
+ await testInstall(browser, { url: XPI_URL });
+ });
+
+ await clearPromise;
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi_theme.js b/toolkit/mozapps/extensions/test/browser/browser_webapi_theme.js
new file mode 100644
index 0000000000..dd1df90907
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_theme.js
@@ -0,0 +1,79 @@
+"use strict";
+
+const TESTPAGE = `${SECURE_TESTROOT}webapi_checkavailable.html`;
+const URL = `${SECURE_TESTROOT}addons/browser_theme.xpi`;
+
+add_task(async function test_theme_install() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webapi.testing", true],
+ ["extensions.install.requireBuiltInCerts", false],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
+ let updates = [];
+ function observer(subject, topic, data) {
+ updates.push(JSON.stringify(subject.wrappedJSObject));
+ }
+ Services.obs.addObserver(observer, "lightweight-theme-styling-update");
+ registerCleanupFunction(() => {
+ Services.obs.removeObserver(observer, "lightweight-theme-styling-update");
+ });
+
+ let sawConfirm = false;
+ promisePopupNotificationShown("addon-install-confirmation").then(panel => {
+ sawConfirm = true;
+ panel.button.click();
+ });
+
+ let prompt1 = waitAppMenuNotificationShown(
+ "addon-installed",
+ "theme@tests.mozilla.org",
+ false
+ );
+ let installPromise = SpecialPowers.spawn(browser, [URL], async url => {
+ let install = await content.navigator.mozAddonManager.createInstall({
+ url,
+ });
+ return install.install();
+ });
+ await prompt1;
+
+ ok(sawConfirm, "Confirm notification was displayed before installation");
+
+ // Open a new window and test the app menu panel from there. This verifies the
+ // incognito checkbox as well as finishing install in this case.
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ await waitAppMenuNotificationShown(
+ "addon-installed",
+ "theme@tests.mozilla.org",
+ true,
+ newWin
+ );
+ await installPromise;
+ ok(true, "Theme install completed");
+
+ await BrowserTestUtils.closeWindow(newWin);
+
+ Assert.equal(updates.length, 1, "Got a single theme update");
+ let parsed = JSON.parse(updates[0]);
+ ok(
+ parsed.theme.headerURL.endsWith("/testImage.png"),
+ "Theme update has the expected headerURL"
+ );
+ is(
+ parsed.theme.id,
+ "theme@tests.mozilla.org",
+ "Theme update includes the theme ID"
+ );
+ is(
+ parsed.theme.version,
+ "1.0",
+ "Theme update includes the theme's version"
+ );
+
+ let addon = await AddonManager.getAddonByID(parsed.theme.id);
+ await addon.uninstall();
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi_uninstall.js b/toolkit/mozapps/extensions/test/browser/browser_webapi_uninstall.js
new file mode 100644
index 0000000000..ad4afe0fa7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_uninstall.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const TESTPAGE = `${SECURE_TESTROOT}webapi_checkavailable.html`;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.webapi.testing", true]],
+ });
+});
+
+function testWithAPI(task) {
+ return async function () {
+ await BrowserTestUtils.withNewTab(TESTPAGE, task);
+ };
+}
+
+function API_uninstallByID(browser, id) {
+ return SpecialPowers.spawn(browser, [id], async function (id) {
+ let addon = await content.navigator.mozAddonManager.getAddonByID(id);
+
+ let result = await addon.uninstall();
+ return result;
+ });
+}
+
+add_task(
+ testWithAPI(async function (browser) {
+ const ID1 = "addon1@tests.mozilla.org";
+ const ID2 = "addon2@tests.mozilla.org";
+ const ID3 = "addon3@tests.mozilla.org";
+
+ let provider = new MockProvider();
+
+ provider.addAddon(new MockAddon(ID1, "Test add-on 1", "extension", 0));
+ provider.addAddon(new MockAddon(ID2, "Test add-on 2", "extension", 0));
+
+ let addon = new MockAddon(ID3, "Test add-on 3", "extension", 0);
+ addon.permissions &= ~AddonManager.PERM_CAN_UNINSTALL;
+ provider.addAddon(addon);
+
+ let [a1, a2, a3] = await promiseAddonsByIDs([ID1, ID2, ID3]);
+ isnot(a1, null, "addon1 is installed");
+ isnot(a2, null, "addon2 is installed");
+ isnot(a3, null, "addon3 is installed");
+
+ let result = await API_uninstallByID(browser, ID1);
+ is(result, true, "uninstall of addon1 succeeded");
+
+ [a1, a2, a3] = await promiseAddonsByIDs([ID1, ID2, ID3]);
+ is(a1, null, "addon1 is uninstalled");
+ isnot(a2, null, "addon2 is still installed");
+
+ result = await API_uninstallByID(browser, ID2);
+ is(result, true, "uninstall of addon2 succeeded");
+ [a2] = await promiseAddonsByIDs([ID2]);
+ is(a2, null, "addon2 is uninstalled");
+
+ await Assert.rejects(
+ API_uninstallByID(browser, ID3),
+ /Addon cannot be uninstalled/,
+ "Unable to uninstall addon"
+ );
+
+ // Cleanup addon3
+ a3.permissions |= AddonManager.PERM_CAN_UNINSTALL;
+ await a3.uninstall();
+ [a3] = await promiseAddonsByIDs([ID3]);
+ is(a3, null, "addon3 is uninstalled");
+ })
+);
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webext_icon.js b/toolkit/mozapps/extensions/test/browser/browser_webext_icon.js
new file mode 100644
index 0000000000..123fe0c665
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webext_icon.js
@@ -0,0 +1,82 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+function pngArrayBuffer(size) {
+ const canvas = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ canvas.height = canvas.width = size;
+ const ctx = canvas.getContext("2d");
+ ctx.fillStyle = "blue";
+ ctx.fillRect(0, 0, size, size);
+ return new Promise(resolve => {
+ canvas.toBlob(blob => {
+ const fileReader = new FileReader();
+ fileReader.onload = () => {
+ resolve(fileReader.result);
+ };
+ fileReader.readAsArrayBuffer(blob);
+ });
+ });
+}
+
+async function checkIconInView(view, name, findIcon) {
+ const manager = await open_manager(view);
+ const icon = findIcon(manager.document);
+ const size = Number(icon.src.match(/icon(\d+)\.png/)[1]);
+ is(
+ icon.clientWidth,
+ icon.clientHeight,
+ `The icon should be square in ${name}`
+ );
+ is(
+ size,
+ icon.clientWidth * window.devicePixelRatio,
+ `The correct icon size should have been chosen in ${name}`
+ );
+ await close_manager(manager);
+}
+
+add_task(async function test_addon_icon() {
+ // This test loads an extension with a variety of icon sizes, and checks that the
+ // fitting one is chosen. If this fails it's because you changed the icon size in
+ // about:addons but didn't update some AddonManager.getPreferredIconURL call.
+ const id = "@test-addon-icon";
+ const icons = {};
+ const files = {};
+ const file = await pngArrayBuffer(256);
+ for (let size = 1; size <= 256; ++size) {
+ let fileName = `icon${size}.png`;
+ icons[size] = fileName;
+ files[fileName] = file;
+ }
+ const extensionDefinition = {
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id },
+ },
+ icons,
+ },
+ files,
+ };
+
+ const extension = ExtensionTestUtils.loadExtension(extensionDefinition);
+ await extension.startup();
+
+ await checkIconInView("addons://list/extension", "list", doc => {
+ return getAddonCard(doc.defaultView, id).querySelector(".addon-icon");
+ });
+
+ await checkIconInView(
+ "addons://detail/" + encodeURIComponent(id),
+ "details",
+ doc => {
+ return getAddonCard(doc.defaultView, id).querySelector(".addon-icon");
+ }
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webext_incognito.js b/toolkit/mozapps/extensions/test/browser/browser_webext_incognito.js
new file mode 100644
index 0000000000..9180bbcf91
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webext_incognito.js
@@ -0,0 +1,593 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+const { Management } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+);
+
+var gManagerWindow;
+
+AddonTestUtils.initMochitest(this);
+
+function get_test_items() {
+ var items = {};
+
+ for (let item of gManagerWindow.document.querySelectorAll("addon-card")) {
+ items[item.getAttribute("addon-id")] = item;
+ }
+
+ return items;
+}
+
+function getHtmlElem(selector) {
+ return gManagerWindow.document.querySelector(selector);
+}
+
+function getPrivateBrowsingBadge(card) {
+ return card.querySelector(".addon-badge-private-browsing-allowed");
+}
+
+function getPreferencesButtonAtListView(card) {
+ return card.querySelector("panel-item[action='preferences']");
+}
+
+function getPreferencesButtonAtDetailsView() {
+ return getHtmlElem("panel-item[action='preferences']");
+}
+
+function isInlineOptionsVisible() {
+ // The following button is used to open the inline options browser.
+ return !getHtmlElem(".tab-button[name='preferences']").hidden;
+}
+
+function getPrivateBrowsingValue() {
+ return getHtmlElem("input[type='radio'][name='private-browsing']:checked")
+ .value;
+}
+
+async function setPrivateBrowsingValue(value, id) {
+ let changePromise = new Promise(resolve => {
+ const listener = (type, { extensionId, added, removed }) => {
+ if (extensionId == id) {
+ // Let's make sure we received the right message
+ let { permissions } = value == "0" ? removed : added;
+ ok(permissions.includes("internal:privateBrowsingAllowed"));
+ Management.off("change-permissions", listener);
+ resolve();
+ }
+ };
+ Management.on("change-permissions", listener);
+ });
+ let radio = getHtmlElem(
+ `input[type="radio"][name="private-browsing"][value="${value}"]`
+ );
+ // NOTE: not using EventUtils.synthesizeMouseAtCenter here because it
+ // does make this test to fail intermittently in some jobs (e.g. TV jobs)
+ radio.click();
+ // Let's make sure we wait until the change has peristed in the database
+ return changePromise;
+}
+
+// Check whether the private browsing inputs are visible in the details view.
+function checkIsModifiable(expected) {
+ if (expected) {
+ is_element_visible(
+ getHtmlElem(".addon-detail-row-private-browsing"),
+ "Private browsing should be visible"
+ );
+ } else {
+ is_element_hidden(
+ getHtmlElem(".addon-detail-row-private-browsing"),
+ "Private browsing should be hidden"
+ );
+ }
+ checkHelpRow(".addon-detail-row-private-browsing", expected);
+}
+
+// Check whether the details view shows that private browsing is forcibly disallowed.
+function checkIsDisallowed(expected) {
+ if (expected) {
+ is_element_visible(
+ getHtmlElem(".addon-detail-row-private-browsing-disallowed"),
+ "Private browsing should be disallowed"
+ );
+ } else {
+ is_element_hidden(
+ getHtmlElem(".addon-detail-row-private-browsing-disallowed"),
+ "Private browsing should not be disallowed"
+ );
+ }
+ checkHelpRow(".addon-detail-row-private-browsing-disallowed", expected);
+}
+
+// Check whether the details view shows that private browsing is forcibly allowed.
+function checkIsRequired(expected) {
+ if (expected) {
+ is_element_visible(
+ getHtmlElem(".addon-detail-row-private-browsing-required"),
+ "Private browsing should be required"
+ );
+ } else {
+ is_element_hidden(
+ getHtmlElem(".addon-detail-row-private-browsing-required"),
+ "Private browsing should not be required"
+ );
+ }
+ checkHelpRow(".addon-detail-row-private-browsing-required", expected);
+}
+
+function checkHelpRow(selector, expected) {
+ let helpRow = getHtmlElem(`${selector} + .addon-detail-help-row`);
+ if (expected) {
+ is_element_visible(helpRow, `Help row should be shown: ${selector}`);
+ is_element_visible(helpRow.querySelector("a"), "Expected learn more link");
+ } else {
+ is_element_hidden(helpRow, `Help row should be hidden: ${selector}`);
+ }
+}
+
+async function hasPrivateAllowed(id) {
+ let perms = await ExtensionPermissions.get(id);
+ return perms.permissions.includes("internal:privateBrowsingAllowed");
+}
+
+add_task(async function test_badge_and_toggle_incognito() {
+ let addons = new Map([
+ [
+ "@test-default",
+ {
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "@test-default" },
+ },
+ },
+ },
+ ],
+ [
+ "@test-override",
+ {
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "@test-override" },
+ },
+ },
+ incognitoOverride: "spanning",
+ },
+ ],
+ [
+ "@test-override-permanent",
+ {
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "@test-override-permanent" },
+ },
+ },
+ incognitoOverride: "spanning",
+ },
+ ],
+ [
+ "@test-not-allowed",
+ {
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "@test-not-allowed" },
+ },
+ incognito: "not_allowed",
+ },
+ },
+ ],
+ ]);
+ let extensions = [];
+ for (let definition of addons.values()) {
+ let extension = ExtensionTestUtils.loadExtension(definition);
+ extensions.push(extension);
+ await extension.startup();
+ }
+
+ gManagerWindow = await open_manager("addons://list/extension");
+ let items = get_test_items();
+ for (let [id, definition] of addons.entries()) {
+ ok(items[id], `${id} listed`);
+ let badge = getPrivateBrowsingBadge(items[id]);
+ if (definition.incognitoOverride == "spanning") {
+ is_element_visible(badge, `private browsing badge is visible`);
+ } else {
+ is_element_hidden(badge, `private browsing badge is hidden`);
+ }
+ }
+ await close_manager(gManagerWindow);
+
+ for (let [id, definition] of addons.entries()) {
+ gManagerWindow = await open_manager(
+ "addons://detail/" + encodeURIComponent(id)
+ );
+ ok(true, `==== ${id} detail opened`);
+ if (definition.manifest.incognito == "not_allowed") {
+ checkIsModifiable(false);
+ ok(!(await hasPrivateAllowed(id)), "Private browsing permission not set");
+ checkIsDisallowed(true);
+ } else {
+ // This assumes PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS, we test other options in a later test in this file.
+ checkIsModifiable(true);
+ if (definition.incognitoOverride == "spanning") {
+ is(getPrivateBrowsingValue(), "1", "Private browsing should be on");
+ ok(await hasPrivateAllowed(id), "Private browsing permission set");
+ await setPrivateBrowsingValue("0", id);
+ is(getPrivateBrowsingValue(), "0", "Private browsing should be off");
+ ok(
+ !(await hasPrivateAllowed(id)),
+ "Private browsing permission removed"
+ );
+ } else {
+ is(getPrivateBrowsingValue(), "0", "Private browsing should be off");
+ ok(
+ !(await hasPrivateAllowed(id)),
+ "Private browsing permission not set"
+ );
+ await setPrivateBrowsingValue("1", id);
+ is(getPrivateBrowsingValue(), "1", "Private browsing should be on");
+ ok(await hasPrivateAllowed(id), "Private browsing permission set");
+ }
+ }
+ await close_manager(gManagerWindow);
+ }
+
+ for (let extension of extensions) {
+ await extension.unload();
+ }
+});
+
+add_task(async function test_addon_preferences_button() {
+ let addons = new Map([
+ [
+ "test-inline-options@mozilla.com",
+ {
+ useAddonManager: "temporary",
+ manifest: {
+ name: "Extension with inline options",
+ browser_specific_settings: {
+ gecko: { id: "test-inline-options@mozilla.com" },
+ },
+ options_ui: { page: "options.html", open_in_tab: false },
+ },
+ },
+ ],
+ [
+ "test-newtab-options@mozilla.com",
+ {
+ useAddonManager: "temporary",
+ manifest: {
+ name: "Extension with options page in a new tab",
+ browser_specific_settings: {
+ gecko: { id: "test-newtab-options@mozilla.com" },
+ },
+ options_ui: { page: "options.html", open_in_tab: true },
+ },
+ },
+ ],
+ [
+ "test-not-allowed@mozilla.com",
+ {
+ useAddonManager: "temporary",
+ manifest: {
+ name: "Extension not allowed in PB windows",
+ incognito: "not_allowed",
+ browser_specific_settings: {
+ gecko: { id: "test-not-allowed@mozilla.com" },
+ },
+ options_ui: { page: "options.html", open_in_tab: true },
+ },
+ },
+ ],
+ ]);
+
+ async function runTest(openInPrivateWin) {
+ const win = await BrowserTestUtils.openNewBrowserWindow({
+ private: openInPrivateWin,
+ });
+
+ gManagerWindow = await open_manager(
+ "addons://list/extension",
+ undefined,
+ undefined,
+ undefined,
+ win
+ );
+
+ const checkPrefsVisibility = (id, hasInlinePrefs, expectVisible) => {
+ if (!hasInlinePrefs) {
+ const detailsPrefBtn = getPreferencesButtonAtDetailsView();
+ is(
+ !detailsPrefBtn.hidden,
+ expectVisible,
+ `The ${id} prefs button in the addon details has the expected visibility`
+ );
+ } else {
+ is(
+ isInlineOptionsVisible(),
+ expectVisible,
+ `The ${id} inline prefs in the addon details has the expected visibility`
+ );
+ }
+ };
+
+ const setAddonPrivateBrowsingAccess = async (id, allowPrivateBrowsing) => {
+ const cardUpdatedPromise = BrowserTestUtils.waitForEvent(
+ getHtmlElem("addon-card"),
+ "update"
+ );
+ is(
+ getPrivateBrowsingValue(),
+ allowPrivateBrowsing ? "0" : "1",
+ `Private browsing should be initially ${
+ allowPrivateBrowsing ? "off" : "on"
+ }`
+ );
+
+ // Get the DOM element we want to click on (to allow or disallow the
+ // addon on private browsing windows).
+ await setPrivateBrowsingValue(allowPrivateBrowsing ? "1" : "0", id);
+
+ info(`Waiting for details view of ${id} to be reloaded`);
+ await cardUpdatedPromise;
+
+ is(
+ getPrivateBrowsingValue(),
+ allowPrivateBrowsing ? "1" : "0",
+ `Private browsing should be initially ${
+ allowPrivateBrowsing ? "on" : "off"
+ }`
+ );
+
+ is(
+ await hasPrivateAllowed(id),
+ allowPrivateBrowsing,
+ `Private browsing permission ${
+ allowPrivateBrowsing ? "added" : "removed"
+ }`
+ );
+ let badge = getPrivateBrowsingBadge(getHtmlElem("addon-card"));
+ is(
+ !badge.hidden,
+ allowPrivateBrowsing,
+ `Expected private browsing badge at ${id}`
+ );
+ };
+
+ const extensions = [];
+ for (const definition of addons.values()) {
+ const extension = ExtensionTestUtils.loadExtension(definition);
+ extensions.push(extension);
+ await extension.startup();
+ }
+
+ const items = get_test_items();
+
+ for (const id of addons.keys()) {
+ // Check the preferences button in the addon list page.
+ is(
+ getPreferencesButtonAtListView(items[id]).hidden,
+ openInPrivateWin,
+ `The ${id} prefs button in the addon list has the expected visibility`
+ );
+ }
+
+ for (const [id, definition] of addons.entries()) {
+ // Check the preferences button or inline frame in the addon
+ // details page.
+ info(`Opening addon details for ${id}`);
+ const hasInlinePrefs = !definition.manifest.options_ui.open_in_tab;
+ const onceViewChanged = wait_for_view_load(gManagerWindow, null, true);
+ gManagerWindow.loadView(`addons://detail/${encodeURIComponent(id)}`);
+ await onceViewChanged;
+
+ checkPrefsVisibility(id, hasInlinePrefs, !openInPrivateWin);
+
+ // While testing in a private window, also check that the preferences
+ // are going to be visible when we toggle the PB access for the addon.
+ if (openInPrivateWin && definition.manifest.incognito !== "not_allowed") {
+ await setAddonPrivateBrowsingAccess(id, true);
+ checkPrefsVisibility(id, hasInlinePrefs, true);
+
+ await setAddonPrivateBrowsingAccess(id, false);
+ checkPrefsVisibility(id, hasInlinePrefs, false);
+ }
+ }
+
+ for (const extension of extensions) {
+ await extension.unload();
+ }
+
+ await close_manager(gManagerWindow);
+ await BrowserTestUtils.closeWindow(win);
+ }
+
+ // run tests in private and non-private windows.
+ await runTest(true);
+ await runTest(false);
+});
+
+add_task(async function test_addon_postinstall_incognito_hidden_checkbox() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.langpacks.signatures.required", false]],
+ });
+
+ const TEST_ADDONS = [
+ {
+ manifest: {
+ name: "Extension incognito default opt-in",
+ browser_specific_settings: {
+ gecko: { id: "ext-incognito-default-opt-in@mozilla.com" },
+ },
+ },
+ },
+ {
+ manifest: {
+ name: "Extension incognito not_allowed",
+ browser_specific_settings: {
+ gecko: { id: "ext-incognito-not-allowed@mozilla.com" },
+ },
+ incognito: "not_allowed",
+ },
+ },
+ {
+ manifest: {
+ name: "Static Theme",
+ browser_specific_settings: {
+ gecko: { id: "static-theme@mozilla.com" },
+ },
+ theme: {
+ colors: {
+ frame: "#FFFFFF",
+ tab_background_text: "#000",
+ },
+ },
+ },
+ },
+ {
+ manifest: {
+ name: "Dictionary",
+ browser_specific_settings: { gecko: { id: "dictionary@mozilla.com" } },
+ dictionaries: {
+ und: "dictionaries/und.dic",
+ },
+ },
+ files: {
+ "dictionaries/und.dic": "",
+ "dictionaries/und.aff": "",
+ },
+ },
+ {
+ manifest: {
+ name: "Langpack",
+ browser_specific_settings: { gecko: { id: "langpack@mozilla.com" } },
+ langpack_id: "und",
+ languages: {
+ und: {
+ chrome_resources: {
+ global: "chrome/und/locale/und/global",
+ },
+ version: "20190326174300",
+ },
+ },
+ },
+ },
+ ];
+
+ for (let definition of TEST_ADDONS) {
+ let { id } = definition.manifest.browser_specific_settings.gecko;
+ info(
+ `Testing incognito checkbox visibility on ${id} post install notification`
+ );
+
+ const xpi = AddonTestUtils.createTempWebExtensionFile(definition);
+ let install = await AddonManager.getInstallForFile(xpi);
+
+ await Promise.all([
+ waitAppMenuNotificationShown("addon-installed", id, true),
+ install.install().then(() => {
+ Services.obs.notifyObservers(
+ {
+ addon: install.addon,
+ target: gBrowser.selectedBrowser,
+ },
+ "webextension-install-notify"
+ );
+ }),
+ ]);
+
+ const { addon } = install;
+ const { permissions } = addon;
+ const canChangePBAccess = Boolean(
+ permissions & AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
+ );
+
+ if (id === "ext-incognito-default-opt-in@mozilla.com") {
+ ok(
+ canChangePBAccess,
+ `${id} should have the PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS permission`
+ );
+ } else {
+ ok(
+ !canChangePBAccess,
+ `${id} should not have the PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS permission`
+ );
+ }
+
+ // This tests the visibility of various private detail rows.
+ gManagerWindow = await open_manager(
+ "addons://detail/" + encodeURIComponent(id)
+ );
+ info(`addon ${id} detail opened`);
+ if (addon.type === "extension") {
+ checkIsModifiable(canChangePBAccess);
+ let required = addon.incognito === "spanning";
+ checkIsRequired(!canChangePBAccess && required);
+ checkIsDisallowed(!canChangePBAccess && !required);
+ } else {
+ checkIsModifiable(false);
+ checkIsRequired(false);
+ checkIsDisallowed(false);
+ }
+ await close_manager(gManagerWindow);
+
+ await addon.uninstall();
+ }
+
+ // It is not possible to create a privileged add-on and install it, so just
+ // simulate an installed privileged add-on and check the UI.
+ await test_incognito_of_privileged_addons();
+});
+
+// Checks that the private browsing flag of privileged add-ons cannot be modified.
+async function test_incognito_of_privileged_addons() {
+ // In mochitests it is not possible to create and install a privileged add-on
+ // or a system add-on, so create a mock provider that simulates privileged
+ // add-ons (which lack the PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS permission).
+ let provider = new MockProvider();
+ provider.createAddons([
+ {
+ name: "default incognito",
+ id: "default-incognito@mock",
+ incognito: "spanning", // This is the default.
+ // Anything without the PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS permission.
+ permissions: 0,
+ },
+ {
+ name: "not_allowed incognito",
+ id: "not-allowed-incognito@mock",
+ incognito: "not_allowed",
+ // Anything without the PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS permission.
+ permissions: 0,
+ },
+ ]);
+
+ gManagerWindow = await open_manager(
+ "addons://detail/default-incognito%40mock"
+ );
+ checkIsModifiable(false);
+ checkIsRequired(true);
+ checkIsDisallowed(false);
+ await close_manager(gManagerWindow);
+
+ gManagerWindow = await open_manager(
+ "addons://detail/not-allowed-incognito%40mock"
+ );
+ checkIsModifiable(false);
+ checkIsRequired(false);
+ checkIsDisallowed(true);
+ await close_manager(gManagerWindow);
+
+ provider.unregister();
+}
diff --git a/toolkit/mozapps/extensions/test/browser/discovery/api_response.json b/toolkit/mozapps/extensions/test/browser/discovery/api_response.json
new file mode 100644
index 0000000000..b36d3c1f02
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/discovery/api_response.json
@@ -0,0 +1,679 @@
+{
+ "results": [
+ {
+ "description_text": "",
+ "addon": {
+ "icon_url": "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+ "guid": "{e0d2e13b-2e07-49d5-9574-eb0227482320}",
+ "authors": [
+ {
+ "id": 7804538,
+ "name": "Sondergaard",
+ "picture_url": "https://addons-dev-cdn.allizom.org/user-media/userpics/7/7804/7804538.png?modified=1392125542",
+ "username": "EatingStick",
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/7804538/"
+ }
+ ],
+ "previews": [
+ {
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183758.png?modified=1555593109",
+ "image_size": [680, 92],
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183758.png?modified=1555593109",
+ "id": 183758,
+ "thumbnail_size": [473, 64],
+ "caption": null
+ },
+ {
+ "id": 183768,
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183768.png?modified=1555593111",
+ "image_size": [760, 92],
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183768.png?modified=1555593111",
+ "caption": null,
+ "thumbnail_size": [529, 64]
+ },
+ {
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183777.png?modified=1555593112",
+ "id": 183777,
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183777.png?modified=1555593112",
+ "image_size": [720, 92],
+ "caption": null,
+ "thumbnail_size": [501, 64]
+ }
+ ],
+ "name": "Tigers Matter ** DON'T DELTE ME**",
+ "id": 496012,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/tigers-matter/",
+ "type": "statictheme",
+ "ratings": {
+ "average": 4.7636,
+ "text_count": 55,
+ "count": 55,
+ "bayesian_average": 4.75672
+ },
+ "slug": "tigers-matter",
+ "average_daily_users": 1,
+ "current_version": {
+ "compatibility": {
+ "firefox": {
+ "max": "*",
+ "min": "53.0"
+ },
+ "android": {
+ "max": "*",
+ "min": "65.0"
+ }
+ },
+ "is_strict_compatibility_enabled": false,
+ "id": 1655900,
+ "files": [
+ {
+ "is_restart_required": false,
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/376561/tigers_matter_dont_delte_me-2.0-an+fx.xpi?src=",
+ "created": "2019-04-18T13:11:48Z",
+ "size": 86337,
+ "status": "public",
+ "is_webextension": true,
+ "is_mozilla_signed_extension": false,
+ "permissions": [],
+ "hash": "sha256:ebeb6e4f40ceafbc4affc5bc9a182ed44ae410d71b8c5f9c547f8d45863e0c37",
+ "platform": "all",
+ "id": 376561
+ }
+ ]
+ }
+ },
+ "is_recommendation": false
+ },
+ {
+ "is_recommendation": false,
+ "addon": {
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/awesome-screenshot-plus-/",
+ "type": "extension",
+ "ratings": {
+ "count": 848,
+ "bayesian_average": 3.87925,
+ "average": 3.8797,
+ "text_count": 842
+ },
+ "slug": "awesome-screenshot-plus-",
+ "average_daily_users": 1,
+ "current_version": {
+ "is_strict_compatibility_enabled": false,
+ "id": 1532816,
+ "files": [
+ {
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/253549/awesome_screenshot_plus-7-an+fx.xpi?src=",
+ "is_restart_required": false,
+ "size": 4196,
+ "created": "2017-09-01T13:31:17Z",
+ "is_webextension": true,
+ "status": "public",
+ "is_mozilla_signed_extension": false,
+ "permissions": [],
+ "hash": "sha256:4cd8e9b7e89f61e6855d01c73c5c05920c1e0e91f3ae0f45adbb4bd9919f59d7",
+ "platform": "all",
+ "id": 253549
+ }
+ ],
+ "compatibility": {
+ "android": {
+ "min": "48.0",
+ "max": "*"
+ },
+ "firefox": {
+ "max": "*",
+ "min": "48.0"
+ }
+ }
+ },
+ "authors": [
+ {
+ "username": "diigo-inc",
+ "name": "Diigo Inc.",
+ "picture_url": "https://addons-dev-cdn.allizom.org/user-media/userpics/0/6/6724.png?modified=1554393597",
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/6724/",
+ "id": 6724
+ }
+ ],
+ "icon_url": "https://addons-dev-cdn.allizom.org/user-media/addon_icons/287/287841-64.png?modified=mcrushed",
+ "guid": "jid0-GXjLLfbCoAx0LcltEdFrEkQdQPI@jetpack",
+ "previews": [
+ {
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/previews/thumbs/54/54638.png?modified=1543388383",
+ "id": 54638,
+ "image_size": [625, 525],
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/previews/full/54/54638.png?modified=1543388383",
+ "caption": "Capture and annotate a page",
+ "thumbnail_size": [571, 480]
+ },
+ {
+ "caption": "Crop selected area",
+ "thumbnail_size": [571, 480],
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/previews/full/54/54639.png?modified=1543388385",
+ "image_size": [625, 525],
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/previews/thumbs/54/54639.png?modified=1543388385",
+ "id": 54639
+ },
+ {
+ "caption": "Save as a local file or upload to get a sharable link",
+ "thumbnail_size": [640, 234],
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/previews/full/54/54641.png?modified=1543388385",
+ "image_size": [700, 256],
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/previews/thumbs/54/54641.png?modified=1543388385",
+ "id": 54641
+ }
+ ],
+ "name": "Awesome Screenshot Plus - Capture, Annotate & More",
+ "id": 287841
+ },
+ "description_text": "Capture the whole page or any portion, annotate it with rectangles, circles, arrows, lines and text, blur sensitive info, one-click upload to share. And more! Capture the whole page or any portion, annotate it with rectangles, circles, arrows, lines"
+ },
+ {
+ "description_text": "Help Admins in their daily work",
+ "addon": {
+ "slug": "amo-admin-assistant-test",
+ "average_daily_users": 0,
+ "current_version": {
+ "files": [
+ {
+ "is_restart_required": false,
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/255370/amo_admin_assistant-4.2-fx.xpi?src=",
+ "size": 16016,
+ "created": "2018-08-21T16:49:21Z",
+ "is_webextension": true,
+ "status": "public",
+ "is_mozilla_signed_extension": false,
+ "permissions": [
+ "tabs",
+ "https://addons-internal.prod.mozaws.net/*"
+ ],
+ "hash": "sha256:cd28c841a6daf8a2e3c94b0773b373fec0213404b70074309326cfc75e6725d3",
+ "platform": "all",
+ "id": 255370
+ }
+ ],
+ "is_strict_compatibility_enabled": false,
+ "id": 1534709,
+ "compatibility": {
+ "firefox": {
+ "min": "45.0",
+ "max": "*"
+ }
+ }
+ },
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/amo-admin-assistant-test/",
+ "ratings": {
+ "bayesian_average": 0,
+ "count": 0,
+ "text_count": 0,
+ "average": 0
+ },
+ "type": "extension",
+ "id": 496168,
+ "guid": "aaa-test-icon@xulforge.com",
+ "icon_url": "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+ "authors": [
+ {
+ "id": 4230,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/4230/",
+ "username": "jorge-villalobos",
+ "name": "Jorge Villalobos",
+ "picture_url": null
+ }
+ ],
+ "previews": [],
+ "name": "AMO Admin Assistant Test"
+ },
+ "is_recommendation": false
+ },
+ {
+ "addon": {
+ "authors": [
+ {
+ "name": "LexaDev",
+ "picture_url": "https://addons-dev-cdn.allizom.org/user-media/userpics/10/10640/10640485.png?modified=1554812253",
+ "username": "LexaSV",
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/10640485/",
+ "id": 10640485
+ }
+ ],
+ "icon_url": "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+ "guid": "{f9b9cdd3-91ae-476e-9c21-d5ecfce9889f}",
+ "previews": [
+ {
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183694.png?modified=1555593096",
+ "image_size": [680, 92],
+ "id": 183694,
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183694.png?modified=1555593096",
+ "thumbnail_size": [473, 64],
+ "caption": null
+ },
+ {
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183699.png?modified=1555593097",
+ "id": 183699,
+ "image_size": [760, 92],
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183699.png?modified=1555593097",
+ "caption": null,
+ "thumbnail_size": [529, 64]
+ },
+ {
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183703.png?modified=1555593098",
+ "image_size": [720, 92],
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183703.png?modified=1555593098",
+ "id": 183703,
+ "caption": null,
+ "thumbnail_size": [501, 64]
+ }
+ ],
+ "name": "iarba",
+ "id": 495969,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/iarba/",
+ "ratings": {
+ "bayesian_average": 4.86128,
+ "count": 10,
+ "text_count": 10,
+ "average": 4.9
+ },
+ "type": "statictheme",
+ "slug": "iarba",
+ "current_version": {
+ "files": [
+ {
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/376535/iarba-2.0-an+fx.xpi?src=",
+ "is_restart_required": false,
+ "size": 895804,
+ "created": "2019-04-18T13:11:35Z",
+ "is_mozilla_signed_extension": false,
+ "status": "public",
+ "is_webextension": true,
+ "id": 376535,
+ "permissions": [],
+ "platform": "all",
+ "hash": "sha256:d7ecbdfa8ba56c5d08129c867e68b02ffc8c6000a7f7f85d85d2a558045babfa"
+ }
+ ],
+ "is_strict_compatibility_enabled": false,
+ "id": 1655874,
+ "compatibility": {
+ "android": {
+ "min": "65.0",
+ "max": "*"
+ },
+ "firefox": {
+ "min": "53.0",
+ "max": "*"
+ }
+ }
+ },
+ "average_daily_users": 1
+ },
+ "description_text": "",
+ "is_recommendation": false
+ },
+ {
+ "description_text": "Get international weather forecasts",
+ "addon": {
+ "id": 502855,
+ "authors": [
+ {
+ "id": 10641527,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/10641527/",
+ "name": "Amoga-dev",
+ "picture_url": "https://addons-dev-cdn.allizom.org/user-media/userpics/10/10641/10641527.png?modified=1555333028",
+ "username": "Amoga_dev_REST"
+ }
+ ],
+ "icon_url": "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+ "guid": "forecastfox@s3_fix_version",
+ "previews": [],
+ "name": "Forecastfox (fix version)",
+ "slug": "forecastfox-fix-version",
+ "current_version": {
+ "id": 1541667,
+ "is_strict_compatibility_enabled": false,
+ "files": [
+ {
+ "permissions": [
+ "activeTab",
+ "tabs",
+ "background",
+ "storage",
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>",
+ "http://www.s3blog.org/geolocation.html*",
+ "https://embed.windy.com/embed2.html*"
+ ],
+ "platform": "all",
+ "hash": "sha256:89e4de4ce86005c57b0197f671e86936aaf843ebd5751caae02cad4991ccbf0a",
+ "id": 262328,
+ "is_webextension": true,
+ "status": "public",
+ "is_mozilla_signed_extension": false,
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/262328/forecastfox_fix_version-4.20-an+fx.xpi?src=",
+ "is_restart_required": false,
+ "created": "2019-01-16T07:54:26Z",
+ "size": 1331686
+ }
+ ],
+ "compatibility": {
+ "android": {
+ "min": "51.0",
+ "max": "*"
+ },
+ "firefox": {
+ "min": "51.0",
+ "max": "*"
+ }
+ }
+ },
+ "average_daily_users": 0,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/forecastfox-fix-version/",
+ "type": "extension",
+ "ratings": {
+ "count": 0,
+ "bayesian_average": 0,
+ "average": 0,
+ "text_count": 0
+ }
+ },
+ "is_recommendation": false
+ },
+ {
+ "description_text": "A test extension from webext-generator.",
+ "addon": {
+ "name": "tabby cat",
+ "previews": [],
+ "guid": "{1ed4b641-bac7-4492-b304-6ddc01f538ae}",
+ "icon_url": "https://addons-dev-cdn.allizom.org/user-media/addon_icons/502/502774-64.png?modified=f289a992",
+ "authors": [
+ {
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/10641572/",
+ "username": "AdminUserTestDev1",
+ "picture_url": "https://addons-dev-cdn.allizom.org/user-media/userpics/10/10641/10641572.png?modified=1555675110",
+ "name": "úþÿ Ψ Φ ֎",
+ "id": 10641572
+ }
+ ],
+ "id": 502774,
+ "ratings": {
+ "bayesian_average": 0,
+ "count": 0,
+ "text_count": 0,
+ "average": 0
+ },
+ "type": "extension",
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/tabby-catextension/",
+ "current_version": {
+ "compatibility": {
+ "firefox": {
+ "max": "*",
+ "min": "48.0"
+ },
+ "android": {
+ "max": "*",
+ "min": "48.0"
+ }
+ },
+ "is_strict_compatibility_enabled": false,
+ "id": 1541570,
+ "files": [
+ {
+ "created": "2018-12-04T09:54:24Z",
+ "size": 4374,
+ "is_restart_required": false,
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/262231/tabby_cat-1.0-an+fx.xpi?src=",
+ "is_mozilla_signed_extension": false,
+ "status": "public",
+ "is_webextension": true,
+ "id": 262231,
+ "hash": "sha256:f12c8a8b71e7d4c48e38db6b6a374ca8dcde42d6cb13fb1f2a8299bb51116615",
+ "platform": "all",
+ "permissions": []
+ }
+ ]
+ },
+ "average_daily_users": 1,
+ "slug": "tabby-catextension"
+ },
+ "is_recommendation": false
+ },
+ {
+ "addon": {
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/the-moon-cat/",
+ "ratings": {
+ "average": 4.8182,
+ "text_count": 11,
+ "count": 11,
+ "bayesian_average": 4.78325
+ },
+ "type": "statictheme",
+ "slug": "the-moon-cat",
+ "average_daily_users": 2,
+ "current_version": {
+ "files": [
+ {
+ "is_mozilla_signed_extension": false,
+ "status": "public",
+ "is_webextension": true,
+ "id": 262333,
+ "permissions": [],
+ "hash": "sha256:d159190add69c739b0fe07b19ad3ff48045c5ded502a8df0f892b8feb645c5ae",
+ "platform": "all",
+ "is_restart_required": false,
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/262333/the_moon_cat-1.0-an+fx.xpi?src=",
+ "size": 102889,
+ "created": "2019-01-16T08:31:21Z"
+ }
+ ],
+ "is_strict_compatibility_enabled": false,
+ "id": 1541672,
+ "compatibility": {
+ "firefox": {
+ "max": "*",
+ "min": "53.0"
+ },
+ "android": {
+ "min": "65.0",
+ "max": "*"
+ }
+ }
+ },
+ "icon_url": "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+ "authors": [
+ {
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/5822165/",
+ "username": "Rallara",
+ "name": "Rallara",
+ "picture_url": "https://addons-dev-cdn.allizom.org/user-media/userpics/5/5822/5822165.png?modified=1391855104",
+ "id": 5822165
+ }
+ ],
+ "guid": "{db4f6548-da04-43fb-a03e-249bf70ef5a1}",
+ "previews": [
+ {
+ "thumbnail_size": [473, 64],
+ "caption": null,
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14307.png?modified=1547627485",
+ "image_size": [680, 92],
+ "id": 14307,
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14307.png?modified=1547627485"
+ },
+ {
+ "thumbnail_size": [529, 64],
+ "caption": null,
+ "id": 14308,
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14308.png?modified=1547627486",
+ "image_size": [760, 92],
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14308.png?modified=1547627486"
+ },
+ {
+ "thumbnail_size": [501, 64],
+ "caption": null,
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14309.png?modified=1547627487",
+ "image_size": [720, 92],
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14309.png?modified=1547627487",
+ "id": 14309
+ }
+ ],
+ "name": "the Moon Cat",
+ "id": 502859
+ },
+ "description_text": "",
+ "is_recommendation": false
+ },
+ {
+ "is_recommendation": false,
+ "addon": {
+ "icon_url": "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+ "guid": "{2e5ff8c8-32fe-46d0-9fc8-6b8986621f3c}",
+ "authors": [
+ {
+ "id": 10641570,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/10641570/",
+ "name": "BobsDisplayName",
+ "picture_url": "https://addons-dev-cdn.allizom.org/user-media/userpics/10/10641/10641570.png?modified=1536063975",
+ "username": "BobsUserName"
+ }
+ ],
+ "previews": [],
+ "name": "SI",
+ "id": 495710,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/search_by_image/",
+ "ratings": {
+ "average": 3.8333,
+ "text_count": 5,
+ "count": 6,
+ "bayesian_average": 3.77144
+ },
+ "type": "extension",
+ "slug": "search_by_image",
+ "current_version": {
+ "files": [
+ {
+ "id": 262271,
+ "permissions": [
+ "contextMenus",
+ "storage",
+ "tabs",
+ "activeTab",
+ "notifications",
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>",
+ "http://*/*",
+ "https://*/*",
+ "ftp://*/*",
+ "file:///*"
+ ],
+ "platform": "all",
+ "hash": "sha256:f358b24d0b950f5acf035342dec64c99ee2e22a5cf369e7c787ebb00013127a8",
+ "is_mozilla_signed_extension": false,
+ "is_webextension": true,
+ "status": "public",
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/262271/search_by_image_reverse_image_search-1.12.6-fx.xpi?src=",
+ "is_restart_required": false,
+ "size": 372225,
+ "created": "2018-12-14T13:48:23Z"
+ }
+ ],
+ "id": 1541610,
+ "is_strict_compatibility_enabled": false,
+ "compatibility": {
+ "firefox": {
+ "min": "57.0",
+ "max": "*"
+ }
+ }
+ },
+ "average_daily_users": 374
+ },
+ "description_text": "AAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGG"
+ },
+ {
+ "addon": {
+ "icon_url": "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+ "guid": "{f5e7a6ee-ebe0-4add-8f75-b5e4015feca1}",
+ "authors": [
+ {
+ "id": 8733220,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/8733220/",
+ "username": "michellet-2",
+ "name": "michellet",
+ "picture_url": null
+ }
+ ],
+ "previews": [
+ {
+ "caption": null,
+ "thumbnail_size": [473, 64],
+ "id": 14304,
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14304.png?modified=1547627480",
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14304.png?modified=1547627480",
+ "image_size": [680, 92]
+ },
+ {
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14305.png?modified=1547627481",
+ "image_size": [760, 92],
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14305.png?modified=1547627481",
+ "id": 14305,
+ "thumbnail_size": [529, 64],
+ "caption": null
+ },
+ {
+ "caption": null,
+ "thumbnail_size": [501, 64],
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14306.png?modified=1547627482",
+ "id": 14306,
+ "image_size": [720, 92],
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14306.png?modified=1547627482"
+ }
+ ],
+ "name": "Purple Sparkles",
+ "id": 502858,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/purple-sparkles/",
+ "type": "statictheme",
+ "ratings": {
+ "count": 4,
+ "bayesian_average": 4.1476,
+ "average": 4.25,
+ "text_count": 3
+ },
+ "slug": "purple-sparkles",
+ "average_daily_users": 445,
+ "current_version": {
+ "compatibility": {
+ "firefox": {
+ "min": "53.0",
+ "max": "*"
+ },
+ "android": {
+ "max": "*",
+ "min": "65.0"
+ }
+ },
+ "id": 1541671,
+ "is_strict_compatibility_enabled": false,
+ "files": [
+ {
+ "created": "2019-01-16T08:31:18Z",
+ "size": 237348,
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/262332/purple_sparkles-1.0-an+fx.xpi?src=",
+ "is_restart_required": false,
+ "is_mozilla_signed_extension": false,
+ "is_webextension": true,
+ "status": "public",
+ "id": 262332,
+ "hash": "sha256:5a3d311b7c1be2ee32446dbcf1422c5d7c786c5a237aa3d4e2939074ab50ad30",
+ "platform": "all",
+ "permissions": []
+ }
+ ]
+ }
+ },
+ "description_text": "",
+ "is_recommendation": false
+ }
+ ],
+ "count": 9
+}
diff --git a/toolkit/mozapps/extensions/test/browser/discovery/api_response_empty.json b/toolkit/mozapps/extensions/test/browser/discovery/api_response_empty.json
new file mode 100644
index 0000000000..a5a3af7835
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/discovery/api_response_empty.json
@@ -0,0 +1 @@
+{ "results": [] }
diff --git a/toolkit/mozapps/extensions/test/browser/discovery/small-1x1.png b/toolkit/mozapps/extensions/test/browser/discovery/small-1x1.png
new file mode 100644
index 0000000000..862d1dd10c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/discovery/small-1x1.png
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/browser/head.js b/toolkit/mozapps/extensions/test/browser/head.js
new file mode 100644
index 0000000000..4d0f325858
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/head.js
@@ -0,0 +1,1701 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+/* globals end_test */
+
+/* eslint no-unused-vars: ["error", {vars: "local", args: "none"}] */
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+let { AddonManagerPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+var pathParts = gTestPath.split("/");
+// Drop the test filename
+pathParts.splice(pathParts.length - 1, pathParts.length);
+
+const RELATIVE_DIR = pathParts.slice(4).join("/") + "/";
+
+const TESTROOT = "http://example.com/" + RELATIVE_DIR;
+const SECURE_TESTROOT = "https://example.com/" + RELATIVE_DIR;
+const TESTROOT2 = "http://example.org/" + RELATIVE_DIR;
+const SECURE_TESTROOT2 = "https://example.org/" + RELATIVE_DIR;
+const CHROMEROOT = pathParts.join("/") + "/";
+const PREF_DISCOVER_ENABLED = "extensions.getAddons.showPane";
+const PREF_XPI_ENABLED = "xpinstall.enabled";
+const PREF_UPDATEURL = "extensions.update.url";
+const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
+const PREF_UI_LASTCATEGORY = "extensions.ui.lastCategory";
+
+const MANAGER_URI = "about:addons";
+const PREF_LOGGING_ENABLED = "extensions.logging.enabled";
+const PREF_STRICT_COMPAT = "extensions.strictCompatibility";
+
+var PREF_CHECK_COMPATIBILITY;
+(function () {
+ var channel = Services.prefs.getCharPref("app.update.channel", "default");
+ if (
+ channel != "aurora" &&
+ channel != "beta" &&
+ channel != "release" &&
+ channel != "esr"
+ ) {
+ var version = "nightly";
+ } else {
+ version = Services.appinfo.version.replace(
+ /^([^\.]+\.[0-9]+[a-z]*).*/gi,
+ "$1"
+ );
+ }
+ PREF_CHECK_COMPATIBILITY = "extensions.checkCompatibility." + version;
+})();
+
+var gPendingTests = [];
+var gTestsRun = 0;
+var gTestStart = null;
+
+var gRestorePrefs = [
+ { name: PREF_LOGGING_ENABLED },
+ { name: "extensions.webservice.discoverURL" },
+ { name: "extensions.update.url" },
+ { name: "extensions.update.background.url" },
+ { name: "extensions.update.enabled" },
+ { name: "extensions.update.autoUpdateDefault" },
+ { name: "extensions.getAddons.get.url" },
+ { name: "extensions.getAddons.getWithPerformance.url" },
+ { name: "extensions.getAddons.cache.enabled" },
+ { name: "devtools.chrome.enabled" },
+ { name: PREF_STRICT_COMPAT },
+ { name: PREF_CHECK_COMPATIBILITY },
+];
+
+for (let pref of gRestorePrefs) {
+ if (!Services.prefs.prefHasUserValue(pref.name)) {
+ pref.type = "clear";
+ continue;
+ }
+ pref.type = Services.prefs.getPrefType(pref.name);
+ if (pref.type == Services.prefs.PREF_BOOL) {
+ pref.value = Services.prefs.getBoolPref(pref.name);
+ } else if (pref.type == Services.prefs.PREF_INT) {
+ pref.value = Services.prefs.getIntPref(pref.name);
+ } else if (pref.type == Services.prefs.PREF_STRING) {
+ pref.value = Services.prefs.getCharPref(pref.name);
+ }
+}
+
+// Turn logging on for all tests
+Services.prefs.setBoolPref(PREF_LOGGING_ENABLED, true);
+
+function promiseFocus(window) {
+ return new Promise(resolve => waitForFocus(resolve, window));
+}
+
+// Tools to disable and re-enable the background update and blocklist timers
+// so that tests can protect themselves from unwanted timer events.
+var gCatMan = Services.catMan;
+// Default value from toolkit/mozapps/extensions/extensions.manifest, but disable*UpdateTimer()
+// records the actual value so we can put it back in enable*UpdateTimer()
+var backgroundUpdateConfig =
+ "@mozilla.org/addons/integration;1,getService,addon-background-update-timer,extensions.update.interval,86400";
+
+var UTIMER = "update-timer";
+var AMANAGER = "addonManager";
+var BLOCKLIST = "nsBlocklistService";
+
+function disableBackgroundUpdateTimer() {
+ info("Disabling " + UTIMER + " " + AMANAGER);
+ backgroundUpdateConfig = gCatMan.getCategoryEntry(UTIMER, AMANAGER);
+ gCatMan.deleteCategoryEntry(UTIMER, AMANAGER, true);
+}
+
+function enableBackgroundUpdateTimer() {
+ info("Enabling " + UTIMER + " " + AMANAGER);
+ gCatMan.addCategoryEntry(
+ UTIMER,
+ AMANAGER,
+ backgroundUpdateConfig,
+ false,
+ true
+ );
+}
+
+registerCleanupFunction(function () {
+ // Restore prefs
+ for (let pref of gRestorePrefs) {
+ if (pref.type == "clear") {
+ Services.prefs.clearUserPref(pref.name);
+ } else if (pref.type == Services.prefs.PREF_BOOL) {
+ Services.prefs.setBoolPref(pref.name, pref.value);
+ } else if (pref.type == Services.prefs.PREF_INT) {
+ Services.prefs.setIntPref(pref.name, pref.value);
+ } else if (pref.type == Services.prefs.PREF_STRING) {
+ Services.prefs.setCharPref(pref.name, pref.value);
+ }
+ }
+
+ return AddonManager.getAllInstalls().then(aInstalls => {
+ for (let install of aInstalls) {
+ if (install instanceof MockInstall) {
+ continue;
+ }
+
+ ok(
+ false,
+ "Should not have seen an install of " +
+ install.sourceURI.spec +
+ " in state " +
+ install.state
+ );
+ install.cancel();
+ }
+ });
+});
+
+function log_exceptions(aCallback, ...aArgs) {
+ try {
+ return aCallback.apply(null, aArgs);
+ } catch (e) {
+ info("Exception thrown: " + e);
+ throw e;
+ }
+}
+
+function log_callback(aPromise, aCallback) {
+ aPromise.then(aCallback).catch(e => info("Exception thrown: " + e));
+ return aPromise;
+}
+
+function add_test(test) {
+ gPendingTests.push(test);
+}
+
+function run_next_test() {
+ // Make sure we're not calling run_next_test from inside an add_task() test
+ // We're inside the browser_test.js 'testScope' here
+ if (this.__tasks) {
+ throw new Error(
+ "run_next_test() called from an add_task() test function. " +
+ "run_next_test() should not be called from inside add_task() " +
+ "under any circumstances!"
+ );
+ }
+ if (gTestsRun > 0) {
+ info("Test " + gTestsRun + " took " + (Date.now() - gTestStart) + "ms");
+ }
+
+ if (!gPendingTests.length) {
+ executeSoon(end_test);
+ return;
+ }
+
+ gTestsRun++;
+ var test = gPendingTests.shift();
+ if (test.name) {
+ info("Running test " + gTestsRun + " (" + test.name + ")");
+ } else {
+ info("Running test " + gTestsRun);
+ }
+
+ gTestStart = Date.now();
+ executeSoon(() => log_exceptions(test));
+}
+
+var get_tooltip_info = async function (addonEl, managerWindow) {
+ // Extract from title attribute.
+ const { addon } = addonEl;
+ const name = addon.name;
+
+ let nameWithVersion = addonEl.addonNameEl.title;
+ if (addonEl.addon.userDisabled) {
+ // TODO - Bug 1558077: Currently Fluent is clearing the addon title
+ // when the addon is disabled, fixing it requires changes to the
+ // HTML about:addons localized strings, and then remove this
+ // workaround.
+ nameWithVersion = `${name} ${addon.version}`;
+ }
+
+ return {
+ name,
+ version: nameWithVersion.substring(name.length + 1),
+ };
+};
+
+function get_addon_file_url(aFilename) {
+ try {
+ var cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+ var fileurl = cr.convertChromeURL(
+ makeURI(CHROMEROOT + "addons/" + aFilename)
+ );
+ return fileurl.QueryInterface(Ci.nsIFileURL);
+ } catch (ex) {
+ var jar = getJar(CHROMEROOT + "addons/" + aFilename);
+ var tmpDir = extractJarToTmp(jar);
+ tmpDir.append(aFilename);
+
+ return Services.io.newFileURI(tmpDir).QueryInterface(Ci.nsIFileURL);
+ }
+}
+
+function check_all_in_list(aManager, aIds, aIgnoreExtras) {
+ var doc = aManager.document;
+ var list = doc.getElementById("addon-list");
+
+ var inlist = [];
+ var node = list.firstChild;
+ while (node) {
+ if (node.value) {
+ inlist.push(node.value);
+ }
+ node = node.nextSibling;
+ }
+
+ for (let id of aIds) {
+ if (!inlist.includes(id)) {
+ ok(false, "Should find " + id + " in the list");
+ }
+ }
+
+ if (aIgnoreExtras) {
+ return;
+ }
+
+ for (let inlistItem of inlist) {
+ if (!aIds.includes(inlistItem)) {
+ ok(false, "Shouldn't have seen " + inlistItem + " in the list");
+ }
+ }
+}
+
+function getAddonCard(win, id) {
+ return win.document.querySelector(`addon-card[addon-id="${id}"]`);
+}
+
+async function wait_for_view_load(
+ aManagerWindow,
+ aCallback,
+ aForceWait,
+ aLongerTimeout
+) {
+ // Wait one tick to make sure that the microtask related to an
+ // async loadView call originated from outsite about:addons
+ // is already executing (otherwise isLoading would be still false
+ // and we wouldn't be waiting for that load before resolving
+ // the promise returned by this test helper function).
+ await Promise.resolve();
+
+ let p = new Promise(resolve => {
+ requestLongerTimeout(aLongerTimeout ? aLongerTimeout : 2);
+
+ if (!aForceWait && !aManagerWindow.gViewController.isLoading) {
+ resolve(aManagerWindow);
+ return;
+ }
+
+ aManagerWindow.document.addEventListener(
+ "view-loaded",
+ function () {
+ resolve(aManagerWindow);
+ },
+ { once: true }
+ );
+ });
+
+ return log_callback(p, aCallback);
+}
+
+function wait_for_manager_load(aManagerWindow, aCallback) {
+ info("Waiting for initialization");
+ return log_callback(
+ aManagerWindow.promiseInitialized.then(() => aManagerWindow),
+ aCallback
+ );
+}
+
+function open_manager(
+ aView,
+ aCallback,
+ aLoadCallback,
+ aLongerTimeout,
+ aWin = window
+) {
+ let p = new Promise((resolve, reject) => {
+ async function setup_manager(aManagerWindow) {
+ if (aLoadCallback) {
+ log_exceptions(aLoadCallback, aManagerWindow);
+ }
+
+ if (aView) {
+ aManagerWindow.loadView(aView);
+ }
+
+ ok(aManagerWindow != null, "Should have an add-ons manager window");
+ is(
+ aManagerWindow.location.href,
+ MANAGER_URI,
+ "Should be displaying the correct UI"
+ );
+
+ await promiseFocus(aManagerWindow);
+ info("window has focus, waiting for manager load");
+ await wait_for_manager_load(aManagerWindow);
+ info("Manager waiting for view load");
+ await wait_for_view_load(aManagerWindow, null, null, aLongerTimeout);
+ resolve(aManagerWindow);
+ }
+
+ info("Loading manager window in tab");
+ Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
+ Services.obs.removeObserver(observer, aTopic);
+ if (aSubject.location.href != MANAGER_URI) {
+ info("Ignoring load event for " + aSubject.location.href);
+ return;
+ }
+ setup_manager(aSubject);
+ }, "EM-loaded");
+
+ aWin.gBrowser.selectedTab = BrowserTestUtils.addTab(aWin.gBrowser);
+ aWin.switchToTabHavingURI(MANAGER_URI, true, {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ });
+
+ // The promise resolves with the manager window, so it is passed to the callback
+ return log_callback(p, aCallback);
+}
+
+function close_manager(aManagerWindow, aCallback, aLongerTimeout) {
+ let p = new Promise((resolve, reject) => {
+ requestLongerTimeout(aLongerTimeout ? aLongerTimeout : 2);
+
+ ok(
+ aManagerWindow != null,
+ "Should have an add-ons manager window to close"
+ );
+ is(
+ aManagerWindow.location.href,
+ MANAGER_URI,
+ "Should be closing window with correct URI"
+ );
+
+ aManagerWindow.addEventListener("unload", function listener() {
+ try {
+ dump("Manager window unload handler\n");
+ this.removeEventListener("unload", listener);
+ resolve();
+ } catch (e) {
+ reject(e);
+ }
+ });
+ });
+
+ info("Telling manager window to close");
+ aManagerWindow.close();
+ info("Manager window close() call returned");
+
+ return log_callback(p, aCallback);
+}
+
+function restart_manager(aManagerWindow, aView, aCallback, aLoadCallback) {
+ if (!aManagerWindow) {
+ return open_manager(aView, aCallback, aLoadCallback);
+ }
+
+ return close_manager(aManagerWindow).then(() =>
+ open_manager(aView, aCallback, aLoadCallback)
+ );
+}
+
+function wait_for_window_open(aCallback) {
+ let p = new Promise(resolve => {
+ Services.wm.addListener({
+ onOpenWindow(aXulWin) {
+ Services.wm.removeListener(this);
+
+ let domwindow = aXulWin.docShell.domWindow;
+ domwindow.addEventListener(
+ "load",
+ function () {
+ executeSoon(function () {
+ resolve(domwindow);
+ });
+ },
+ { once: true }
+ );
+ },
+
+ onCloseWindow(aWindow) {},
+ });
+ });
+
+ return log_callback(p, aCallback);
+}
+
+function formatDate(aDate) {
+ const dtOptions = { year: "numeric", month: "long", day: "numeric" };
+ return aDate.toLocaleDateString(undefined, dtOptions);
+}
+
+function is_hidden(aElement) {
+ var style = aElement.ownerGlobal.getComputedStyle(aElement);
+ if (style.display == "none") {
+ return true;
+ }
+ if (style.visibility != "visible") {
+ return true;
+ }
+
+ // Hiding a parent element will hide all its children
+ if (aElement.parentNode != aElement.ownerDocument) {
+ return is_hidden(aElement.parentNode);
+ }
+
+ return false;
+}
+
+function is_element_visible(aElement, aMsg) {
+ isnot(aElement, null, "Element should not be null, when checking visibility");
+ ok(!is_hidden(aElement), aMsg || aElement + " should be visible");
+}
+
+function is_element_hidden(aElement, aMsg) {
+ isnot(aElement, null, "Element should not be null, when checking visibility");
+ ok(is_hidden(aElement), aMsg || aElement + " should be hidden");
+}
+
+function promiseAddonByID(aId) {
+ return AddonManager.getAddonByID(aId);
+}
+
+function promiseAddonsByIDs(aIDs) {
+ return AddonManager.getAddonsByIDs(aIDs);
+}
+/**
+ * Install an add-on and call a callback when complete.
+ *
+ * The callback will receive the Addon for the installed add-on.
+ */
+async function install_addon(path, cb, pathPrefix = TESTROOT) {
+ let install = await AddonManager.getInstallForURL(pathPrefix + path);
+ let p = new Promise((resolve, reject) => {
+ install.addListener({
+ onInstallEnded: () => resolve(install.addon),
+ });
+
+ install.install();
+ });
+
+ return log_callback(p, cb);
+}
+
+function CategoryUtilities(aManagerWindow) {
+ this.window = aManagerWindow;
+ this.window.addEventListener("unload", () => (this.window = null), {
+ once: true,
+ });
+}
+
+CategoryUtilities.prototype = {
+ window: null,
+
+ get _categoriesBox() {
+ return this.window.document.querySelector("categories-box");
+ },
+
+ getSelectedViewId() {
+ let selectedItem = this._categoriesBox.querySelector("[selected]");
+ isnot(selectedItem, null, "A category should be selected");
+ return selectedItem.getAttribute("viewid");
+ },
+
+ get selectedCategory() {
+ isnot(
+ this.window,
+ null,
+ "Should not get selected category when manager window is not loaded"
+ );
+ let viewId = this.getSelectedViewId();
+ let view = this.window.gViewController.parseViewId(viewId);
+ return view.type == "list" ? view.param : view.type;
+ },
+
+ get(categoryType) {
+ isnot(
+ this.window,
+ null,
+ "Should not get category when manager window is not loaded"
+ );
+
+ let button = this._categoriesBox.querySelector(`[name="${categoryType}"]`);
+ if (button) {
+ return button;
+ }
+
+ ok(false, "Should have found a category with type " + categoryType);
+ return null;
+ },
+
+ isVisible(categoryButton) {
+ isnot(
+ this.window,
+ null,
+ "Should not check visible state when manager window is not loaded"
+ );
+
+ // There are some tests checking this before the categories have loaded.
+ if (!categoryButton) {
+ return false;
+ }
+
+ if (categoryButton.disabled || categoryButton.hidden) {
+ return false;
+ }
+
+ return !is_hidden(categoryButton);
+ },
+
+ isTypeVisible(categoryType) {
+ return this.isVisible(this.get(categoryType));
+ },
+
+ open(categoryButton) {
+ isnot(
+ this.window,
+ null,
+ "Should not open category when manager window is not loaded"
+ );
+ ok(
+ this.isVisible(categoryButton),
+ "Category should be visible if attempting to open it"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(categoryButton, {}, this.window);
+
+ // Use wait_for_view_load until all open_manager calls are gone.
+ return wait_for_view_load(this.window);
+ },
+
+ openType(categoryType) {
+ return this.open(this.get(categoryType));
+ },
+};
+
+// Returns a promise that will resolve when the certificate error override has been added, or reject
+// if there is some failure.
+function addCertOverride(host) {
+ return new Promise((resolve, reject) => {
+ let req = new XMLHttpRequest();
+ req.open("GET", "https://" + host + "/");
+ req.onload = reject;
+ req.onerror = () => {
+ if (req.channel && req.channel.securityInfo) {
+ let securityInfo = req.channel.securityInfo;
+ if (securityInfo.serverCert) {
+ let cos = Cc["@mozilla.org/security/certoverride;1"].getService(
+ Ci.nsICertOverrideService
+ );
+ cos.rememberValidityOverride(
+ host,
+ -1,
+ {},
+ securityInfo.serverCert,
+ false
+ );
+ resolve();
+ return;
+ }
+ }
+ reject();
+ };
+ req.send(null);
+ });
+}
+
+// Returns a promise that will resolve when the necessary certificate overrides have been added.
+function addCertOverrides() {
+ return Promise.all([
+ addCertOverride("nocert.example.com"),
+ addCertOverride("self-signed.example.com"),
+ addCertOverride("untrusted.example.com"),
+ addCertOverride("expired.example.com"),
+ ]);
+}
+
+/** *** Mock Provider *****/
+
+function MockProvider(addonTypes) {
+ this.addons = [];
+ this.installs = [];
+ this.addonTypes = addonTypes ?? ["extension"];
+
+ var self = this;
+ registerCleanupFunction(function () {
+ if (self.started) {
+ self.unregister();
+ }
+ });
+
+ this.register();
+}
+
+MockProvider.prototype = {
+ addons: null,
+ installs: null,
+ addonTypes: null,
+ started: null,
+ queryDelayPromise: Promise.resolve(),
+
+ blockQueryResponses() {
+ this.queryDelayPromise = new Promise(resolve => {
+ this._unblockQueries = resolve;
+ });
+ },
+
+ unblockQueryResponses() {
+ if (this._unblockQueries) {
+ this._unblockQueries();
+ this._unblockQueries = null;
+ } else {
+ throw new Error("Queries are not blocked");
+ }
+ },
+
+ /** *** Utility functions *****/
+
+ /**
+ * Register this provider with the AddonManager
+ */
+ register: function MP_register() {
+ info("Registering mock add-on provider");
+ // addonTypes is supposedly the full set of types supported by the provider.
+ // The current list is not complete (there are tests that mock add-on types
+ // other than "extension"), but it doesn't affect tests since addonTypes is
+ // mainly used to determine whether any of the AddonManager's providers
+ // support a type, and XPIProvider already defines the types of interest.
+ AddonManagerPrivate.registerProvider(this, this.addonTypes);
+ },
+
+ /**
+ * Unregister this provider with the AddonManager
+ */
+ unregister: function MP_unregister() {
+ info("Unregistering mock add-on provider");
+ AddonManagerPrivate.unregisterProvider(this);
+ },
+
+ /**
+ * Adds an add-on to the list of add-ons that this provider exposes to the
+ * AddonManager, dispatching appropriate events in the process.
+ *
+ * @param aAddon
+ * The add-on to add
+ */
+ addAddon: function MP_addAddon(aAddon) {
+ var oldAddons = this.addons.filter(aOldAddon => aOldAddon.id == aAddon.id);
+ var oldAddon = oldAddons.length ? oldAddons[0] : null;
+
+ this.addons = this.addons.filter(aOldAddon => aOldAddon.id != aAddon.id);
+
+ this.addons.push(aAddon);
+ aAddon._provider = this;
+
+ if (!this.started) {
+ return;
+ }
+
+ let requiresRestart =
+ (aAddon.operationsRequiringRestart &
+ AddonManager.OP_NEEDS_RESTART_INSTALL) !=
+ 0;
+ AddonManagerPrivate.callInstallListeners(
+ "onExternalInstall",
+ null,
+ aAddon,
+ oldAddon,
+ requiresRestart
+ );
+ },
+
+ /**
+ * Removes an add-on from the list of add-ons that this provider exposes to
+ * the AddonManager, dispatching the onUninstalled event in the process.
+ *
+ * @param aAddon
+ * The add-on to add
+ */
+ removeAddon: function MP_removeAddon(aAddon) {
+ var pos = this.addons.indexOf(aAddon);
+ if (pos == -1) {
+ ok(
+ false,
+ "Tried to remove an add-on that wasn't registered with the mock provider"
+ );
+ return;
+ }
+
+ this.addons.splice(pos, 1);
+
+ if (!this.started) {
+ return;
+ }
+
+ AddonManagerPrivate.callAddonListeners("onUninstalled", aAddon);
+ },
+
+ /**
+ * Adds an add-on install to the list of installs that this provider exposes
+ * to the AddonManager, dispatching appropriate events in the process.
+ *
+ * @param aInstall
+ * The add-on install to add
+ */
+ addInstall: function MP_addInstall(aInstall) {
+ this.installs.push(aInstall);
+ aInstall._provider = this;
+
+ if (!this.started) {
+ return;
+ }
+
+ aInstall.callListeners("onNewInstall");
+ },
+
+ removeInstall: function MP_removeInstall(aInstall) {
+ var pos = this.installs.indexOf(aInstall);
+ if (pos == -1) {
+ ok(
+ false,
+ "Tried to remove an install that wasn't registered with the mock provider"
+ );
+ return;
+ }
+
+ this.installs.splice(pos, 1);
+ },
+
+ /**
+ * Creates a set of mock add-on objects and adds them to the list of add-ons
+ * managed by this provider.
+ *
+ * @param aAddonProperties
+ * An array of objects containing properties describing the add-ons
+ * @return Array of the new MockAddons
+ */
+ createAddons: function MP_createAddons(aAddonProperties) {
+ var newAddons = [];
+ for (let addonProp of aAddonProperties) {
+ let addon = new MockAddon(addonProp.id);
+ for (let prop in addonProp) {
+ if (prop == "id") {
+ continue;
+ }
+ if (prop == "applyBackgroundUpdates") {
+ addon._applyBackgroundUpdates = addonProp[prop];
+ } else if (prop == "appDisabled") {
+ addon._appDisabled = addonProp[prop];
+ } else if (prop == "userDisabled") {
+ addon.setUserDisabled(addonProp[prop]);
+ } else {
+ addon[prop] = addonProp[prop];
+ }
+ }
+ if (!addon.optionsType && !!addon.optionsURL) {
+ addon.optionsType = AddonManager.OPTIONS_TYPE_DIALOG;
+ }
+
+ // Make sure the active state matches the passed in properties
+ addon.isActive = addon.shouldBeActive;
+
+ this.addAddon(addon);
+ newAddons.push(addon);
+ }
+
+ return newAddons;
+ },
+
+ /**
+ * Creates a set of mock add-on install objects and adds them to the list
+ * of installs managed by this provider.
+ *
+ * @param aInstallProperties
+ * An array of objects containing properties describing the installs
+ * @return Array of the new MockInstalls
+ */
+ createInstalls: function MP_createInstalls(aInstallProperties) {
+ var newInstalls = [];
+ for (let installProp of aInstallProperties) {
+ let install = new MockInstall(
+ installProp.name || null,
+ installProp.type || null,
+ null
+ );
+ for (let prop in installProp) {
+ switch (prop) {
+ case "name":
+ case "type":
+ break;
+ case "sourceURI":
+ install[prop] = NetUtil.newURI(installProp[prop]);
+ break;
+ default:
+ install[prop] = installProp[prop];
+ }
+ }
+ this.addInstall(install);
+ newInstalls.push(install);
+ }
+
+ return newInstalls;
+ },
+
+ /** *** AddonProvider implementation *****/
+
+ /**
+ * Called to initialize the provider.
+ */
+ startup: function MP_startup() {
+ this.started = true;
+ },
+
+ /**
+ * Called when the provider should shutdown.
+ */
+ shutdown: function MP_shutdown() {
+ this.started = false;
+ },
+
+ /**
+ * Called to get an Addon with a particular ID.
+ *
+ * @param aId
+ * The ID of the add-on to retrieve
+ */
+ async getAddonByID(aId) {
+ await this.queryDelayPromise;
+
+ for (let addon of this.addons) {
+ if (addon.id == aId) {
+ return addon;
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Called to get Addons of a particular type.
+ *
+ * @param aTypes
+ * An array of types to fetch. Can be null to get all types.
+ */
+ async getAddonsByTypes(aTypes) {
+ await this.queryDelayPromise;
+
+ var addons = this.addons.filter(function (aAddon) {
+ if (aTypes && !!aTypes.length && !aTypes.includes(aAddon.type)) {
+ return false;
+ }
+ return true;
+ });
+ return addons;
+ },
+
+ /**
+ * Called to get the current AddonInstalls, optionally restricting by type.
+ *
+ * @param aTypes
+ * An array of types or null to get all types
+ */
+ async getInstallsByTypes(aTypes) {
+ await this.queryDelayPromise;
+
+ var installs = this.installs.filter(function (aInstall) {
+ // Appear to have actually removed cancelled installs from the provider
+ if (aInstall.state == AddonManager.STATE_CANCELLED) {
+ return false;
+ }
+
+ if (aTypes && !!aTypes.length && !aTypes.includes(aInstall.type)) {
+ return false;
+ }
+
+ return true;
+ });
+ return installs;
+ },
+
+ /**
+ * Called when a new add-on has been enabled when only one add-on of that type
+ * can be enabled.
+ *
+ * @param aId
+ * The ID of the newly enabled add-on
+ * @param aType
+ * The type of the newly enabled add-on
+ * @param aPendingRestart
+ * true if the newly enabled add-on will only become enabled after a
+ * restart
+ */
+ addonChanged: function MP_addonChanged(aId, aType, aPendingRestart) {
+ // Not implemented
+ },
+
+ /**
+ * Update the appDisabled property for all add-ons.
+ */
+ updateAddonAppDisabledStates: function MP_updateAddonAppDisabledStates() {
+ // Not needed
+ },
+
+ /**
+ * Called to get an AddonInstall to download and install an add-on from a URL.
+ *
+ * @param {string} aUrl
+ * The URL to be installed
+ * @param {object} aOptions
+ * Options for the install
+ */
+ getInstallForURL: function MP_getInstallForURL(aUrl, aOptions) {
+ // Not yet implemented
+ },
+
+ /**
+ * Called to get an AddonInstall to install an add-on from a local file.
+ *
+ * @param aFile
+ * The file to be installed
+ */
+ getInstallForFile: function MP_getInstallForFile(aFile) {
+ // Not yet implemented
+ },
+
+ /**
+ * Called to test whether installing add-ons is enabled.
+ *
+ * @return true if installing is enabled
+ */
+ isInstallEnabled: function MP_isInstallEnabled() {
+ return false;
+ },
+
+ /**
+ * Called to test whether this provider supports installing a particular
+ * mimetype.
+ *
+ * @param aMimetype
+ * The mimetype to check for
+ * @return true if the mimetype is supported
+ */
+ supportsMimetype: function MP_supportsMimetype(aMimetype) {
+ return false;
+ },
+
+ /**
+ * Called to test whether installing add-ons from a URI is allowed.
+ *
+ * @param aUri
+ * The URI being installed from
+ * @return true if installing is allowed
+ */
+ isInstallAllowed: function MP_isInstallAllowed(aUri) {
+ return false;
+ },
+};
+
+/** *** Mock Addon object for the Mock Provider *****/
+
+function MockAddon(aId, aName, aType, aOperationsRequiringRestart) {
+ // Only set required attributes.
+ this.id = aId || "";
+ this.name = aName || "";
+ this.type = aType || "extension";
+ this.version = "";
+ this.isCompatible = true;
+ this.providesUpdatesSecurely = true;
+ this.blocklistState = 0;
+ this._appDisabled = false;
+ this._userDisabled = false;
+ this._applyBackgroundUpdates = AddonManager.AUTOUPDATE_ENABLE;
+ this.scope = AddonManager.SCOPE_PROFILE;
+ this.isActive = true;
+ this.creator = "";
+ this.pendingOperations = 0;
+ this._permissions =
+ AddonManager.PERM_CAN_UNINSTALL |
+ AddonManager.PERM_CAN_ENABLE |
+ AddonManager.PERM_CAN_DISABLE |
+ AddonManager.PERM_CAN_UPGRADE |
+ AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS;
+ this.operationsRequiringRestart =
+ aOperationsRequiringRestart != undefined
+ ? aOperationsRequiringRestart
+ : AddonManager.OP_NEEDS_RESTART_INSTALL |
+ AddonManager.OP_NEEDS_RESTART_UNINSTALL |
+ AddonManager.OP_NEEDS_RESTART_ENABLE |
+ AddonManager.OP_NEEDS_RESTART_DISABLE;
+}
+
+MockAddon.prototype = {
+ get isCorrectlySigned() {
+ if (this.signedState === AddonManager.SIGNEDSTATE_NOT_REQUIRED) {
+ return true;
+ }
+ return this.signedState > AddonManager.SIGNEDSTATE_MISSING;
+ },
+
+ get shouldBeActive() {
+ return (
+ !this.appDisabled &&
+ !this._userDisabled &&
+ !(this.pendingOperations & AddonManager.PENDING_UNINSTALL)
+ );
+ },
+
+ get appDisabled() {
+ return this._appDisabled;
+ },
+
+ set appDisabled(val) {
+ if (val == this._appDisabled) {
+ return;
+ }
+
+ AddonManagerPrivate.callAddonListeners("onPropertyChanged", this, [
+ "appDisabled",
+ ]);
+
+ var currentActive = this.shouldBeActive;
+ this._appDisabled = val;
+ var newActive = this.shouldBeActive;
+ this._updateActiveState(currentActive, newActive);
+ },
+
+ get userDisabled() {
+ return this._userDisabled;
+ },
+
+ set userDisabled(val) {
+ throw new Error("No. Bad.");
+ },
+
+ setUserDisabled(val) {
+ if (val == this._userDisabled) {
+ return;
+ }
+
+ var currentActive = this.shouldBeActive;
+ this._userDisabled = val;
+ var newActive = this.shouldBeActive;
+ this._updateActiveState(currentActive, newActive);
+ },
+
+ async enable() {
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+
+ this.setUserDisabled(false);
+ },
+ async disable() {
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+
+ this.setUserDisabled(true);
+ },
+
+ get permissions() {
+ let permissions = this._permissions;
+ if (this.appDisabled || !this._userDisabled) {
+ permissions &= ~AddonManager.PERM_CAN_ENABLE;
+ }
+ if (this.appDisabled || this._userDisabled) {
+ permissions &= ~AddonManager.PERM_CAN_DISABLE;
+ }
+ return permissions;
+ },
+
+ set permissions(val) {
+ this._permissions = val;
+ },
+
+ get applyBackgroundUpdates() {
+ return this._applyBackgroundUpdates;
+ },
+
+ set applyBackgroundUpdates(val) {
+ if (
+ val != AddonManager.AUTOUPDATE_DEFAULT &&
+ val != AddonManager.AUTOUPDATE_DISABLE &&
+ val != AddonManager.AUTOUPDATE_ENABLE
+ ) {
+ ok(false, "addon.applyBackgroundUpdates set to an invalid value: " + val);
+ }
+ this._applyBackgroundUpdates = val;
+ AddonManagerPrivate.callAddonListeners("onPropertyChanged", this, [
+ "applyBackgroundUpdates",
+ ]);
+ },
+
+ isCompatibleWith(aAppVersion, aPlatformVersion) {
+ return true;
+ },
+
+ findUpdates(aListener, aReason, aAppVersion, aPlatformVersion) {
+ // Tests can implement this if they need to
+ },
+
+ async getBlocklistURL() {
+ return this.blocklistURL;
+ },
+
+ uninstall(aAlwaysAllowUndo = false) {
+ if (
+ this.operationsRequiringRestart &
+ AddonManager.OP_NEED_RESTART_UNINSTALL &&
+ this.pendingOperations & AddonManager.PENDING_UNINSTALL
+ ) {
+ throw Components.Exception("Add-on is already pending uninstall");
+ }
+
+ var needsRestart =
+ aAlwaysAllowUndo ||
+ !!(
+ this.operationsRequiringRestart &
+ AddonManager.OP_NEEDS_RESTART_UNINSTALL
+ );
+ this.pendingOperations |= AddonManager.PENDING_UNINSTALL;
+ AddonManagerPrivate.callAddonListeners(
+ "onUninstalling",
+ this,
+ needsRestart
+ );
+ if (!needsRestart) {
+ this.pendingOperations -= AddonManager.PENDING_UNINSTALL;
+ this._provider.removeAddon(this);
+ } else if (
+ !(this.operationsRequiringRestart & AddonManager.OP_NEEDS_RESTART_DISABLE)
+ ) {
+ this.isActive = false;
+ }
+ },
+
+ cancelUninstall() {
+ if (!(this.pendingOperations & AddonManager.PENDING_UNINSTALL)) {
+ throw Components.Exception("Add-on is not pending uninstall");
+ }
+
+ this.pendingOperations -= AddonManager.PENDING_UNINSTALL;
+ this.isActive = this.shouldBeActive;
+ AddonManagerPrivate.callAddonListeners("onOperationCancelled", this);
+ },
+
+ markAsSeen() {
+ this.seen = true;
+ },
+
+ _updateActiveState(currentActive, newActive) {
+ if (currentActive == newActive) {
+ return;
+ }
+
+ if (newActive == this.isActive) {
+ this.pendingOperations -= newActive
+ ? AddonManager.PENDING_DISABLE
+ : AddonManager.PENDING_ENABLE;
+ AddonManagerPrivate.callAddonListeners("onOperationCancelled", this);
+ } else if (newActive) {
+ let needsRestart = !!(
+ this.operationsRequiringRestart & AddonManager.OP_NEEDS_RESTART_ENABLE
+ );
+ this.pendingOperations |= AddonManager.PENDING_ENABLE;
+ AddonManagerPrivate.callAddonListeners("onEnabling", this, needsRestart);
+ if (!needsRestart) {
+ this.isActive = newActive;
+ this.pendingOperations -= AddonManager.PENDING_ENABLE;
+ AddonManagerPrivate.callAddonListeners("onEnabled", this);
+ }
+ } else {
+ let needsRestart = !!(
+ this.operationsRequiringRestart & AddonManager.OP_NEEDS_RESTART_DISABLE
+ );
+ this.pendingOperations |= AddonManager.PENDING_DISABLE;
+ AddonManagerPrivate.callAddonListeners("onDisabling", this, needsRestart);
+ if (!needsRestart) {
+ this.isActive = newActive;
+ this.pendingOperations -= AddonManager.PENDING_DISABLE;
+ AddonManagerPrivate.callAddonListeners("onDisabled", this);
+ }
+ }
+ },
+};
+
+/** *** Mock AddonInstall object for the Mock Provider *****/
+
+function MockInstall(aName, aType, aAddonToInstall) {
+ this.name = aName || "";
+ // Don't expose type until download completed
+ this._type = aType || "extension";
+ this.type = null;
+ this.version = "1.0";
+ this.iconURL = "";
+ this.infoURL = "";
+ this.state = AddonManager.STATE_AVAILABLE;
+ this.error = 0;
+ this.sourceURI = null;
+ this.file = null;
+ this.progress = 0;
+ this.maxProgress = -1;
+ this.certificate = null;
+ this.certName = "";
+ this.existingAddon = null;
+ this.addon = null;
+ this._addonToInstall = aAddonToInstall;
+ this.listeners = [];
+
+ // Another type of install listener for tests that want to check the results
+ // of code run from standard install listeners
+ this.testListeners = [];
+}
+
+MockInstall.prototype = {
+ install() {
+ switch (this.state) {
+ case AddonManager.STATE_AVAILABLE:
+ this.state = AddonManager.STATE_DOWNLOADING;
+ if (!this.callListeners("onDownloadStarted")) {
+ this.state = AddonManager.STATE_CANCELLED;
+ this.callListeners("onDownloadCancelled");
+ return;
+ }
+
+ this.type = this._type;
+
+ // Adding addon to MockProvider to be implemented when needed
+ if (this._addonToInstall) {
+ this.addon = this._addonToInstall;
+ } else {
+ this.addon = new MockAddon("", this.name, this.type);
+ this.addon.version = this.version;
+ this.addon.pendingOperations = AddonManager.PENDING_INSTALL;
+ }
+ this.addon.install = this;
+ if (this.existingAddon) {
+ if (!this.addon.id) {
+ this.addon.id = this.existingAddon.id;
+ }
+ this.existingAddon.pendingUpgrade = this.addon;
+ this.existingAddon.pendingOperations |= AddonManager.PENDING_UPGRADE;
+ }
+
+ this.state = AddonManager.STATE_DOWNLOADED;
+ this.callListeners("onDownloadEnded");
+ // fall through
+ case AddonManager.STATE_DOWNLOADED:
+ this.state = AddonManager.STATE_INSTALLING;
+ if (!this.callListeners("onInstallStarted")) {
+ this.state = AddonManager.STATE_CANCELLED;
+ this.callListeners("onInstallCancelled");
+ return;
+ }
+
+ let needsRestart =
+ this.operationsRequiringRestart &
+ AddonManager.OP_NEEDS_RESTART_INSTALL;
+ AddonManagerPrivate.callAddonListeners(
+ "onInstalling",
+ this.addon,
+ needsRestart
+ );
+ if (!needsRestart) {
+ AddonManagerPrivate.callAddonListeners("onInstalled", this.addon);
+ }
+
+ this.state = AddonManager.STATE_INSTALLED;
+ this.callListeners("onInstallEnded");
+ break;
+ case AddonManager.STATE_DOWNLOADING:
+ case AddonManager.STATE_CHECKING_UPDATE:
+ case AddonManager.STATE_INSTALLING:
+ // Installation is already running
+ return;
+ default:
+ ok(false, "Cannot start installing when state = " + this.state);
+ }
+ },
+
+ cancel() {
+ switch (this.state) {
+ case AddonManager.STATE_AVAILABLE:
+ this.state = AddonManager.STATE_CANCELLED;
+ break;
+ case AddonManager.STATE_INSTALLED:
+ this.state = AddonManager.STATE_CANCELLED;
+ this._provider.removeInstall(this);
+ this.callListeners("onInstallCancelled");
+ break;
+ default:
+ // Handling cancelling when downloading to be implemented when needed
+ ok(false, "Cannot cancel when state = " + this.state);
+ }
+ },
+
+ addListener(aListener) {
+ if (!this.listeners.some(i => i == aListener)) {
+ this.listeners.push(aListener);
+ }
+ },
+
+ removeListener(aListener) {
+ this.listeners = this.listeners.filter(i => i != aListener);
+ },
+
+ addTestListener(aListener) {
+ if (!this.testListeners.some(i => i == aListener)) {
+ this.testListeners.push(aListener);
+ }
+ },
+
+ removeTestListener(aListener) {
+ this.testListeners = this.testListeners.filter(i => i != aListener);
+ },
+
+ callListeners(aMethod) {
+ var result = AddonManagerPrivate.callInstallListeners(
+ aMethod,
+ this.listeners,
+ this,
+ this.addon
+ );
+
+ // Call test listeners after standard listeners to remove race condition
+ // between standard and test listeners
+ for (let listener of this.testListeners) {
+ try {
+ if (aMethod in listener) {
+ if (listener[aMethod](this, this.addon) === false) {
+ result = false;
+ }
+ }
+ } catch (e) {
+ ok(false, "Test listener threw exception: " + e);
+ }
+ }
+
+ return result;
+ },
+};
+
+function waitForCondition(condition, nextTest, errorMsg) {
+ let tries = 0;
+ let interval = setInterval(function () {
+ if (tries >= 30) {
+ ok(false, errorMsg);
+ moveOn();
+ }
+ var conditionPassed;
+ try {
+ conditionPassed = condition();
+ } catch (e) {
+ ok(false, e + "\n" + e.stack);
+ conditionPassed = false;
+ }
+ if (conditionPassed) {
+ moveOn();
+ }
+ tries++;
+ }, 100);
+ let moveOn = function () {
+ clearInterval(interval);
+ nextTest();
+ };
+}
+
+// Wait for and then acknowledge (by pressing the primary button) the
+// given notification.
+function promiseNotification(id = "addon-webext-permissions") {
+ return new Promise(resolve => {
+ function popupshown() {
+ let notification = PopupNotifications.getNotification(id);
+ if (notification) {
+ PopupNotifications.panel.removeEventListener("popupshown", popupshown);
+ PopupNotifications.panel.firstElementChild.button.click();
+ resolve();
+ }
+ }
+ PopupNotifications.panel.addEventListener("popupshown", popupshown);
+ });
+}
+
+/**
+ * Wait for the given PopupNotification to display
+ *
+ * @param {string} name
+ * The name of the notification to wait for.
+ *
+ * @returns {Promise}
+ * Resolves with the notification window.
+ */
+function promisePopupNotificationShown(name = "addon-webext-permissions") {
+ return new Promise(resolve => {
+ function popupshown() {
+ let notification = PopupNotifications.getNotification(name);
+ if (!notification) {
+ return;
+ }
+
+ ok(notification, `${name} notification shown`);
+ ok(PopupNotifications.isPanelOpen, "notification panel open");
+
+ PopupNotifications.panel.removeEventListener("popupshown", popupshown);
+ resolve(PopupNotifications.panel.firstChild);
+ }
+ PopupNotifications.panel.addEventListener("popupshown", popupshown);
+ });
+}
+
+function waitAppMenuNotificationShown(
+ id,
+ addonId,
+ accept = false,
+ win = window
+) {
+ const { AppMenuNotifications } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppMenuNotifications.sys.mjs"
+ );
+ return new Promise(resolve => {
+ let { document, PanelUI } = win;
+
+ async function popupshown() {
+ let notification = AppMenuNotifications.activeNotification;
+ if (!notification) {
+ return;
+ }
+
+ is(notification.id, id, `${id} notification shown`);
+ ok(PanelUI.isNotificationPanelOpen, "notification panel open");
+
+ PanelUI.notificationPanel.removeEventListener("popupshown", popupshown);
+
+ if (id == "addon-installed" && addonId) {
+ let addon = await AddonManager.getAddonByID(addonId);
+ if (!addon) {
+ ok(false, `Addon with id "${addonId}" not found`);
+ }
+ let hidden = !(
+ addon.permissions &
+ AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
+ );
+ let checkbox = document.getElementById("addon-incognito-checkbox");
+ is(checkbox.hidden, hidden, "checkbox visibility is correct");
+ }
+ if (accept) {
+ let popupnotificationID = PanelUI._getPopupId(notification);
+ let popupnotification = document.getElementById(popupnotificationID);
+ popupnotification.button.click();
+ }
+
+ resolve();
+ }
+ // If it's already open just run the test.
+ let notification = AppMenuNotifications.activeNotification;
+ if (notification && PanelUI.isNotificationPanelOpen) {
+ popupshown();
+ return;
+ }
+ PanelUI.notificationPanel.addEventListener("popupshown", popupshown);
+ });
+}
+
+function acceptAppMenuNotificationWhenShown(id, addonId) {
+ return waitAppMenuNotificationShown(id, addonId, true);
+}
+
+/* HTML view helpers */
+async function loadInitialView(type, opts) {
+ if (type) {
+ // Force the first page load to be the view we want.
+ let viewId;
+ if (type.startsWith("addons://")) {
+ viewId = type;
+ } else {
+ viewId =
+ type == "discover" ? "addons://discover/" : `addons://list/${type}`;
+ }
+ Services.prefs.setCharPref(PREF_UI_LASTCATEGORY, viewId);
+ }
+
+ let loadCallback;
+ let loadCallbackDone = Promise.resolve();
+
+ if (opts && opts.loadCallback) {
+ loadCallback = win => {
+ loadCallbackDone = (async () => {
+ // Wait for the test code to finish running before proceeding.
+ await opts.loadCallback(win);
+ })();
+ };
+ }
+
+ let win = await open_manager(null, null, loadCallback);
+ if (!opts || !opts.withAnimations) {
+ win.document.body.setAttribute("skip-animations", "");
+ }
+
+ // Let any load callback code to run before the rest of the test continues.
+ await loadCallbackDone;
+
+ return win;
+}
+
+function getSection(doc, className) {
+ return doc.querySelector(`section.${className}`);
+}
+
+function waitForViewLoad(win) {
+ return wait_for_view_load(win, undefined, true);
+}
+
+function closeView(win) {
+ return close_manager(win);
+}
+
+function switchView(win, type) {
+ return new CategoryUtilities(win).openType(type);
+}
+
+function isCategoryVisible(win, type) {
+ return new CategoryUtilities(win).isTypeVisible(type);
+}
+
+function mockPromptService() {
+ let { prompt } = Services;
+ let promptService = {
+ // The prompt returns 1 for cancelled and 0 for accepted.
+ _response: 1,
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ confirmEx: () => promptService._response,
+ };
+ Services.prompt = promptService;
+ registerCleanupFunction(() => {
+ Services.prompt = prompt;
+ });
+ return promptService;
+}
+
+function assertHasPendingUninstalls(addonList, expectedPendingUninstallsCount) {
+ const pendingUninstalls = addonList.querySelector(
+ "message-bar-stack.pending-uninstall"
+ );
+ ok(pendingUninstalls, "Got a pending-uninstall message-bar-stack");
+ is(
+ pendingUninstalls.childElementCount,
+ expectedPendingUninstallsCount,
+ "Got a message bar in the pending-uninstall message-bar-stack"
+ );
+}
+
+function assertHasPendingUninstallAddon(addonList, addon) {
+ const pendingUninstalls = addonList.querySelector(
+ "message-bar-stack.pending-uninstall"
+ );
+ const addonPendingUninstall = addonList.getPendingUninstallBar(addon);
+ ok(
+ addonPendingUninstall,
+ "Got expected message-bar for the pending uninstall test extension"
+ );
+ is(
+ addonPendingUninstall.parentNode,
+ pendingUninstalls,
+ "pending uninstall bar should be part of the message-bar-stack"
+ );
+ is(
+ addonPendingUninstall.getAttribute("addon-id"),
+ addon.id,
+ "Got expected addon-id attribute on the pending uninstall message-bar"
+ );
+}
+
+async function testUndoPendingUninstall(addonList, addon) {
+ const addonPendingUninstall = addonList.getPendingUninstallBar(addon);
+ const undoButton = addonPendingUninstall.querySelector("button[action=undo]");
+ ok(undoButton, "Got undo action button in the pending uninstall message-bar");
+
+ info(
+ "Clicking the pending uninstall undo button and wait for addon card rendered"
+ );
+ const updated = BrowserTestUtils.waitForEvent(addonList, "add");
+ undoButton.click();
+ await updated;
+
+ ok(
+ addon && !(addon.pendingOperations & AddonManager.PENDING_UNINSTALL),
+ "The addon pending uninstall cancelled"
+ );
+}
+
+function loadTestSubscript(filePath) {
+ Services.scriptloader.loadSubScript(new URL(filePath, gTestPath).href, this);
+}
+
+function cleanupPendingNotifications() {
+ const { ExtensionsUI } = ChromeUtils.import(
+ "resource:///modules/ExtensionsUI.jsm"
+ );
+ info("Cleanup any pending notification before exiting the test");
+ const keys = ChromeUtils.nondeterministicGetWeakSetKeys(
+ ExtensionsUI.pendingNotifications
+ );
+ if (keys) {
+ keys.forEach(key => ExtensionsUI.pendingNotifications.delete(key));
+ }
+}
+
+function promisePermissionPrompt(addonId) {
+ return BrowserUtils.promiseObserved(
+ "webextension-permission-prompt",
+ subject => {
+ const { info } = subject.wrappedJSObject || {};
+ return !addonId || (info.addon && info.addon.id === addonId);
+ }
+ ).then(({ subject }) => {
+ return subject.wrappedJSObject.info;
+ });
+}
+
+async function handlePermissionPrompt({
+ addonId,
+ reject = false,
+ assertIcon = true,
+} = {}) {
+ const info = await promisePermissionPrompt(addonId);
+ // Assert that info.addon and info.icon are defined as expected.
+ is(
+ info.addon && info.addon.id,
+ addonId,
+ "Got the AddonWrapper in the permission prompt info"
+ );
+
+ if (assertIcon) {
+ ok(info.icon != null, "Got an addon icon in the permission prompt info");
+ }
+
+ if (reject) {
+ info.reject();
+ } else {
+ info.resolve();
+ }
+}
+
+async function switchToDetailView({ id, win }) {
+ let card = getAddonCard(win, id);
+ ok(card, `Addon card found for ${id}`);
+ ok(!card.querySelector("addon-details"), "The card doesn't have details");
+ let loaded = waitForViewLoad(win);
+ EventUtils.synthesizeMouseAtCenter(card, { clickCount: 1 }, win);
+ await loaded;
+ card = getAddonCard(win, id);
+ ok(card.querySelector("addon-details"), "The card does have details");
+ return card;
+}
diff --git a/toolkit/mozapps/extensions/test/browser/head_abuse_report.js b/toolkit/mozapps/extensions/test/browser/head_abuse_report.js
new file mode 100644
index 0000000000..89daa2a219
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/head_abuse_report.js
@@ -0,0 +1,587 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* eslint max-len: ["error", 80] */
+
+/* exported installTestExtension, addCommonAbuseReportTestTasks,
+ * createPromptConfirmEx, DEFAULT_BUILTIN_THEME_ID,
+ * gManagerWindow, handleSubmitRequest, makeWidgetId,
+ * waitForNewWindow, waitClosedWindow, AbuseReporter,
+ * AbuseReporterTestUtils, AddonTestUtils
+ */
+
+/* global MockProvider, loadInitialView, closeView */
+
+const { AbuseReporter } = ChromeUtils.importESModule(
+ "resource://gre/modules/AbuseReporter.sys.mjs"
+);
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+
+const { makeWidgetId } = ExtensionCommon;
+
+const ADDON_ID = "test-extension-to-report@mochi.test";
+const REPORT_ENTRY_POINT = "menu";
+const BASE_TEST_MANIFEST = {
+ name: "Fake extension to report",
+ author: "Fake author",
+ homepage_url: "https://fake.extension.url/",
+};
+const DEFAULT_BUILTIN_THEME_ID = "default-theme@mozilla.org";
+const EXT_DICTIONARY_ADDON_ID = "fake-dictionary@mochi.test";
+const EXT_LANGPACK_ADDON_ID = "fake-langpack@mochi.test";
+const EXT_WITH_PRIVILEGED_URL_ID = "ext-with-privileged-url@mochi.test";
+const EXT_SYSTEM_ADDON_ID = "test-system-addon@mochi.test";
+const EXT_UNSUPPORTED_TYPE_ADDON_ID = "report-unsupported-type@mochi.test";
+const THEME_NO_UNINSTALL_ID = "theme-without-perm-can-uninstall@mochi.test";
+
+let gManagerWindow;
+
+AddonTestUtils.initMochitest(this);
+
+async function openAboutAddons(type = "extension") {
+ gManagerWindow = await loadInitialView(type);
+}
+
+async function closeAboutAddons() {
+ if (gManagerWindow) {
+ await closeView(gManagerWindow);
+ gManagerWindow = null;
+ }
+}
+
+function waitForNewWindow() {
+ return new Promise(resolve => {
+ let listener = win => {
+ Services.obs.removeObserver(listener, "toplevel-window-ready");
+ resolve(win);
+ };
+
+ Services.obs.addObserver(listener, "toplevel-window-ready");
+ });
+}
+
+function waitClosedWindow(win) {
+ return new Promise((resolve, reject) => {
+ function onWindowClosed() {
+ if (win && !win.closed) {
+ // If a specific window reference has been passed, then check
+ // that the window is closed before resolving the promise.
+ return;
+ }
+ Services.obs.removeObserver(onWindowClosed, "xul-window-destroyed");
+ resolve();
+ }
+ Services.obs.addObserver(onWindowClosed, "xul-window-destroyed");
+ });
+}
+
+async function installTestExtension(
+ id = ADDON_ID,
+ type = "extension",
+ manifest = {}
+) {
+ let additionalProps = {
+ icons: {
+ 32: "test-icon.png",
+ },
+ };
+
+ switch (type) {
+ case "theme":
+ additionalProps = {
+ ...additionalProps,
+ theme: {
+ colors: {
+ frame: "#a14040",
+ tab_background_text: "#fac96e",
+ },
+ },
+ };
+ break;
+
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based
+ // implementation is also removed.
+ case "sitepermission-deprecated":
+ additionalProps = {
+ name: "WebMIDI test addon for https://mochi.test",
+ install_origins: ["https://mochi.test"],
+ site_permissions: ["midi"],
+ };
+ break;
+ case "extension":
+ break;
+ default:
+ throw new Error(`Unexpected addon type: ${type}`);
+ }
+
+ const extensionOpts = {
+ manifest: {
+ ...BASE_TEST_MANIFEST,
+ ...additionalProps,
+ ...manifest,
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ };
+
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based
+ // implementation is also removed.
+ if (type === "sitepermission-deprecated") {
+ const xpi = AddonTestUtils.createTempWebExtensionFile(extensionOpts);
+ const addon = await AddonManager.installTemporaryAddon(xpi);
+ // The extension object that ExtensionTestUtils.loadExtension returns for
+ // mochitest is pretty tight to the Extension class, and so for now this
+ // returns a more minimal `extension` test object which only provides the
+ // `unload` method.
+ //
+ // For the purpose of the abuse reports tests that are using this helper
+ // this should be already enough.
+ return {
+ addon,
+ unload: () => addon.uninstall(),
+ };
+ }
+
+ const extension = ExtensionTestUtils.loadExtension(extensionOpts);
+ await extension.startup();
+ return extension;
+}
+
+function handleSubmitRequest({ request, response }) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/json", false);
+ response.write("{}");
+}
+
+const AbuseReportTestUtils = {
+ _mockProvider: null,
+ _mockServer: null,
+ _abuseRequestHandlers: [],
+
+ // Mock addon details API endpoint.
+ amoAddonDetailsMap: new Map(),
+
+ // Setup the test environment by setting the expected prefs and
+ // initializing MockProvider and the mock AMO server.
+ async setup() {
+ // Enable html about:addons and the abuse reporting and
+ // set the api endpoints url to the mock service.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.abuseReport.enabled", true],
+ ["extensions.abuseReport.url", "http://test.addons.org/api/report/"],
+ [
+ "extensions.abuseReport.amoDetailsURL",
+ "http://test.addons.org/api/addons/addon/",
+ ],
+ ],
+ });
+
+ this._setupMockProvider();
+ this._setupMockServer();
+ },
+
+ // Returns the currently open abuse report dialog window (if any).
+ getReportDialog() {
+ return Services.ww.getWindowByName("addons-abuse-report-dialog");
+ },
+
+ // Returns the parameters related to the report dialog (if any).
+ getReportDialogParams() {
+ const win = this.getReportDialog();
+ return win && win.arguments[0] && win.arguments[0].wrappedJSObject;
+ },
+
+ // Returns a reference to the addon-abuse-report element from the currently
+ // open abuse report.
+ getReportPanel() {
+ const win = this.getReportDialog();
+ ok(win, "Got an abuse report dialog open");
+ return win && win.document.querySelector("addon-abuse-report");
+ },
+
+ // Returns the list of abuse report reasons.
+ getReasons(abuseReportEl) {
+ return Object.keys(abuseReportEl.ownerGlobal.ABUSE_REPORT_REASONS);
+ },
+
+ // Returns the info related to a given abuse report reason.
+ getReasonInfo(abuseReportEl, reason) {
+ return abuseReportEl.ownerGlobal.ABUSE_REPORT_REASONS[reason];
+ },
+
+ async promiseReportOpened({ addonId, reportEntryPoint, managerWindow }) {
+ let abuseReportEl;
+
+ if (!this.getReportDialog()) {
+ info("Wait for the report dialog window");
+ const dialog = await waitForNewWindow();
+ is(dialog, this.getReportDialog(), "Report dialog opened");
+ }
+
+ info("Wait for the abuse report panel render");
+ abuseReportEl = await AbuseReportTestUtils.promiseReportDialogRendered();
+
+ ok(abuseReportEl, "Got an abuse report panel");
+ is(
+ abuseReportEl.addon && abuseReportEl.addon.id,
+ addonId,
+ "Abuse Report panel rendered for the expected addonId"
+ );
+ is(
+ abuseReportEl._report && abuseReportEl._report.reportEntryPoint,
+ reportEntryPoint,
+ "Abuse Report panel rendered for the expected reportEntryPoint"
+ );
+
+ return abuseReportEl;
+ },
+
+ // Return a promise resolved when the currently open report panel
+ // is closed.
+ // Also asserts that a specific report panel element has been closed,
+ // if one has been provided through the optional panel parameter.
+ async promiseReportClosed(panel) {
+ const win = panel ? panel.ownerGlobal : this.getReportDialog();
+ if (!win || win.closed) {
+ throw Error("Expected report dialog not found or already closed");
+ }
+
+ await waitClosedWindow(win);
+ // Assert that the panel has been closed (if the caller has passed it).
+ if (panel) {
+ ok(!panel.ownerGlobal, "abuse report dialog closed");
+ }
+ },
+
+ // Returns a promise resolved when the report panel has been rendered
+ // (rejects is there is no dialog currently open).
+ async promiseReportDialogRendered() {
+ const params = this.getReportDialogParams();
+ if (!params) {
+ throw new Error("abuse report dialog not found");
+ }
+ return params.promiseReportPanel;
+ },
+
+ // Given a `requestHandler` function, an HTTP server handler function
+ // to use to handle a report submit request received by the mock AMO server),
+ // returns a promise resolved when the mock AMO server has received and
+ // handled the report submit request.
+ async promiseReportSubmitHandled(requestHandler) {
+ if (typeof requestHandler !== "function") {
+ throw new Error("requestHandler should be a function");
+ }
+ return new Promise((resolve, reject) => {
+ this._abuseRequestHandlers.unshift({ resolve, reject, requestHandler });
+ });
+ },
+
+ // Return a promise resolved to the abuse report panel element,
+ // once its rendering is completed.
+ // If abuseReportEl is undefined, it looks for the currently opened
+ // report panel.
+ async promiseReportRendered(abuseReportEl) {
+ let el = abuseReportEl;
+
+ if (!el) {
+ const win = this.getReportDialog();
+ if (!win) {
+ await waitForNewWindow();
+ }
+
+ el = await this.promiseReportDialogRendered();
+ ok(el, "Got an abuse report panel");
+ }
+
+ return el._radioCheckedReason
+ ? el
+ : BrowserTestUtils.waitForEvent(
+ el,
+ "abuse-report:updated",
+ "Wait the abuse report panel to be rendered"
+ ).then(() => el);
+ },
+
+ // A promise resolved when the given abuse report panel element
+ // has been rendered. If a panel name ("reasons" or "submit") is
+ // passed as a second parameter, it also asserts that the panel is
+ // updated to the expected view mode.
+ async promiseReportUpdated(abuseReportEl, panel) {
+ const evt = await BrowserTestUtils.waitForEvent(
+ abuseReportEl,
+ "abuse-report:updated",
+ "Wait abuse report panel update"
+ );
+
+ if (panel) {
+ is(evt.detail.panel, panel, `Got a "${panel}" update event`);
+
+ const el = abuseReportEl;
+ switch (evt.detail.panel) {
+ case "reasons":
+ ok(!el._reasonsPanel.hidden, "Reasons panel should be visible");
+ ok(el._submitPanel.hidden, "Submit panel should be hidden");
+ break;
+ case "submit":
+ ok(el._reasonsPanel.hidden, "Reasons panel should be hidden");
+ ok(!el._submitPanel.hidden, "Submit panel should be visible");
+ break;
+ }
+ }
+ },
+
+ // Returns a promise resolved once the expected number of abuse report
+ // message bars have been created.
+ promiseMessageBars(expectedMessageBarCount) {
+ return new Promise(resolve => {
+ const details = [];
+ function listener(evt) {
+ details.push(evt.detail);
+ if (details.length >= expectedMessageBarCount) {
+ cleanup();
+ resolve(details);
+ }
+ }
+ function cleanup() {
+ if (gManagerWindow) {
+ gManagerWindow.document.removeEventListener(
+ "abuse-report:new-message-bar",
+ listener
+ );
+ }
+ }
+ gManagerWindow.document.addEventListener(
+ "abuse-report:new-message-bar",
+ listener
+ );
+ });
+ },
+
+ async assertFluentStrings(containerEl) {
+ // Make sure all localized elements have defined Fluent strings.
+ const localizedEls = Array.from(
+ containerEl.querySelectorAll("[data-l10n-id]")
+ );
+ ok(localizedEls.length, "Got localized elements");
+ for (let el of localizedEls) {
+ const l10nId = el.getAttribute("data-l10n-id");
+ await TestUtils.waitForCondition(
+ () => el.textContent !== "",
+ `Element with Fluent id '${l10nId}' should not be empty`
+ );
+ }
+ },
+
+ // Assert that the report action is hidden on the addon card
+ // for the given about:addons windows and extension id.
+ async assertReportActionHidden(gManagerWindow, extId) {
+ let addonCard = gManagerWindow.document.querySelector(
+ `addon-list addon-card[addon-id="${extId}"]`
+ );
+ ok(addonCard, `Got the addon-card for the ${extId} test extension`);
+
+ let reportButton = addonCard.querySelector("[action=report]");
+ ok(reportButton, `Got the report action for ${extId}`);
+ ok(reportButton.hidden, `${extId} report action should be hidden`);
+ },
+
+ // Assert that the report panel is hidden (or closed if the report
+ // panel is opened in its own dialog window).
+ async assertReportPanelHidden() {
+ const win = this.getReportDialog();
+ ok(!win, "Abuse Report dialog should be initially hidden");
+ },
+
+ createMockAddons(mockProviderAddons) {
+ this._mockProvider.createAddons(mockProviderAddons);
+ },
+
+ async clickPanelButton(buttonEl, { label = undefined } = {}) {
+ info(`Clicking the '${buttonEl.textContent.trim() || label}' button`);
+ // NOTE: ideally this should synthesize the mouse event,
+ // we call the click method to prevent intermittent timeouts
+ // due to the mouse event not received by the target element.
+ buttonEl.click();
+ },
+
+ triggerNewReport(addonId, reportEntryPoint) {
+ gManagerWindow.openAbuseReport({ addonId, reportEntryPoint });
+ },
+
+ triggerSubmit(reason, message) {
+ const reportEl =
+ this.getReportDialog().document.querySelector("addon-abuse-report");
+ reportEl._form.elements.message.value = message;
+ reportEl._form.elements.reason.value = reason;
+ reportEl.submit();
+ },
+
+ async openReport(addonId, reportEntryPoint = REPORT_ENTRY_POINT) {
+ // Close the current about:addons window if it has been leaved open from
+ // a previous test case failure.
+ if (gManagerWindow) {
+ await closeAboutAddons();
+ }
+
+ await openAboutAddons();
+
+ let promiseReportPanel = waitForNewWindow().then(() =>
+ this.promiseReportDialogRendered()
+ );
+
+ this.triggerNewReport(addonId, reportEntryPoint);
+
+ const panelEl = await promiseReportPanel;
+ await this.promiseReportRendered(panelEl);
+ is(panelEl.addonId, addonId, `Got Abuse Report panel for ${addonId}`);
+
+ return panelEl;
+ },
+
+ async closeReportPanel(panelEl) {
+ const onceReportClosed = AbuseReportTestUtils.promiseReportClosed(panelEl);
+
+ info("Cancel report and wait the dialog to be closed");
+ panelEl.dispatchEvent(new CustomEvent("abuse-report:cancel"));
+
+ await onceReportClosed;
+ },
+
+ // Internal helper methods.
+
+ _setupMockProvider() {
+ this._mockProvider = new MockProvider();
+ this._mockProvider.createAddons([
+ {
+ id: THEME_NO_UNINSTALL_ID,
+ name: "This theme cannot be uninstalled",
+ version: "1.1",
+ creator: { name: "Theme creator", url: "http://example.com/creator" },
+ type: "theme",
+ permissions: 0,
+ },
+ {
+ id: EXT_WITH_PRIVILEGED_URL_ID,
+ name: "This extension has an unexpected privileged creator URL",
+ version: "1.1",
+ creator: { name: "creator", url: "about:config" },
+ type: "extension",
+ },
+ {
+ id: EXT_SYSTEM_ADDON_ID,
+ name: "This is a system addon",
+ version: "1.1",
+ creator: { name: "creator", url: "http://example.com/creator" },
+ type: "extension",
+ isSystem: true,
+ },
+ {
+ id: EXT_UNSUPPORTED_TYPE_ADDON_ID,
+ name: "This is a fake unsupported addon type",
+ version: "1.1",
+ type: "unsupported_addon_type",
+ },
+ {
+ id: EXT_LANGPACK_ADDON_ID,
+ name: "This is a fake langpack",
+ version: "1.1",
+ type: "locale",
+ },
+ {
+ id: EXT_DICTIONARY_ADDON_ID,
+ name: "This is a fake dictionary",
+ version: "1.1",
+ type: "dictionary",
+ },
+ ]);
+ },
+
+ _setupMockServer() {
+ if (this._mockServer) {
+ return;
+ }
+
+ // Init test report api server.
+ const server = AddonTestUtils.createHttpServer({
+ hosts: ["test.addons.org"],
+ });
+ this._mockServer = server;
+
+ server.registerPathHandler("/api/report/", (request, response) => {
+ const stream = request.bodyInputStream;
+ const buffer = NetUtil.readInputStream(stream, stream.available());
+ const data = new TextDecoder().decode(buffer);
+ const promisedHandler = this._abuseRequestHandlers.pop();
+ if (promisedHandler) {
+ const { requestHandler, resolve, reject } = promisedHandler;
+ try {
+ requestHandler({ data, request, response });
+ resolve();
+ } catch (err) {
+ ok(false, `Unexpected requestHandler error ${err} ${err.stack}\n`);
+ reject(err);
+ }
+ } else {
+ ok(false, `Unexpected request: ${request.path} ${data}`);
+ }
+ });
+
+ server.registerPrefixHandler("/api/addons/addon/", (request, response) => {
+ const addonId = request.path.split("/").pop();
+ if (!this.amoAddonDetailsMap.has(addonId)) {
+ response.setStatusLine(request.httpVersion, 404, "Not Found");
+ response.write(JSON.stringify({ detail: "Not found." }));
+ } else {
+ response.setStatusLine(request.httpVersion, 200, "Success");
+ response.write(JSON.stringify(this.amoAddonDetailsMap.get(addonId)));
+ }
+ });
+ server.registerPathHandler(
+ "/assets/fake-icon-url.png",
+ (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "Success");
+ response.write("");
+ response.finish();
+ }
+ );
+ },
+};
+
+function createPromptConfirmEx({
+ remove = false,
+ report = false,
+ expectCheckboxHidden = false,
+} = {}) {
+ return (...args) => {
+ const checkboxState = args.pop();
+ const checkboxMessage = args.pop();
+ is(
+ checkboxState && checkboxState.value,
+ false,
+ "checkboxState should be initially false"
+ );
+ if (expectCheckboxHidden) {
+ ok(
+ !checkboxMessage,
+ "Should not have a checkboxMessage in promptService.confirmEx call"
+ );
+ } else {
+ ok(
+ checkboxMessage,
+ "Got a checkboxMessage in promptService.confirmEx call"
+ );
+ }
+
+ // Report checkbox selected.
+ checkboxState.value = report;
+
+ // Remove accepted.
+ return remove ? 0 : 1;
+ };
+}
diff --git a/toolkit/mozapps/extensions/test/browser/head_disco.js b/toolkit/mozapps/extensions/test/browser/head_disco.js
new file mode 100644
index 0000000000..64c346f3dd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/head_disco.js
@@ -0,0 +1,125 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* eslint max-len: ["error", 80] */
+
+/* exported DISCOAPI_DEFAULT_FIXTURE, getCardContainer,
+ getDiscoveryElement, promiseAddonInstall, promiseDiscopaneUpdate,
+ promiseEvent, promiseObserved, readAPIResponseFixture */
+
+/* globals RELATIVE_DIR, promisePopupNotificationShown,
+ waitAppMenuNotificationShown */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+const {
+ ExtensionUtils: { promiseEvent, promiseObserved },
+} = ChromeUtils.importESModule("resource://gre/modules/ExtensionUtils.sys.mjs");
+
+AddonTestUtils.initMochitest(this);
+
+// The response to the discovery API, as documented at:
+// https://addons-server.readthedocs.io/en/latest/topics/api/discovery.html
+//
+// The tests using this fixure are meant to verify that the discopane works
+// with the latest AMO API.
+// The following fixure file should be kept in sync with the content of
+// latest AMO API response, e.g. from
+//
+// https://addons.allizom.org/api/v4/discovery/?lang=en-US
+//
+// The response must contain at least one theme, and one extension.
+const DISCOAPI_DEFAULT_FIXTURE = PathUtils.join(
+ Services.dirsvc.get("CurWorkD", Ci.nsIFile).path,
+ ...RELATIVE_DIR.split("/"),
+ "discovery",
+ "api_response.json"
+);
+
+// Read the content of API_RESPONSE_FILE, and replaces any embedded URLs with
+// URLs that point to the `amoServer` test server.
+async function readAPIResponseFixture(
+ amoTestHost,
+ fixtureFilePath = DISCOAPI_DEFAULT_FIXTURE
+) {
+ let apiText = await IOUtils.readUTF8(fixtureFilePath);
+ apiText = apiText.replace(/\bhttps?:\/\/[^"]+(?=")/g, url => {
+ try {
+ url = new URL(url);
+ } catch (e) {
+ // Responses may contain "http://*/*"; ignore it.
+ return url;
+ }
+ // In this test, we only need to distinguish between different file types,
+ // so just use the file extension as path name for amoServer.
+ let ext = url.pathname.split(".").pop();
+ return `http://${amoTestHost}/${ext}?${url.pathname}${url.search}`;
+ });
+
+ return apiText;
+}
+
+// Wait until the current `<discovery-pane>` element has finished loading its
+// cards. This can be used after the cards have been loaded.
+function promiseDiscopaneUpdate(win) {
+ let { cardsReady } = getCardContainer(win);
+ ok(cardsReady, "Discovery cards should have started to initialize");
+ return cardsReady;
+}
+
+function getCardContainer(win) {
+ return getDiscoveryElement(win).querySelector("recommended-addon-list");
+}
+
+function getDiscoveryElement(win) {
+ return win.document.querySelector("discovery-pane");
+}
+
+// A helper that waits until an installation has been requested from `amoServer`
+// and proceeds with approving the installation.
+async function promiseAddonInstall(
+ amoServer,
+ extensionData,
+ expectedTelemetryInfo = { source: "disco", taarRecommended: false }
+) {
+ let description = extensionData.manifest.description;
+ let xpiFile = AddonTestUtils.createTempWebExtensionFile(extensionData);
+ amoServer.registerFile("/xpi", xpiFile);
+
+ let addonId =
+ extensionData.manifest?.browser_specific_settings?.gecko?.id ||
+ extensionData.manifest?.applications?.gecko?.id;
+ let installedPromise = waitAppMenuNotificationShown(
+ "addon-installed",
+ addonId,
+ true
+ );
+
+ if (!extensionData.manifest.theme) {
+ info(`${description}: Waiting for permission prompt`);
+ // Extensions have install prompts.
+ let panel = await promisePopupNotificationShown("addon-webext-permissions");
+ panel.button.click();
+ } else {
+ info(`${description}: Waiting for install prompt`);
+ let panel = await promisePopupNotificationShown(
+ "addon-install-confirmation"
+ );
+ panel.button.click();
+ }
+
+ info("Waiting for post-install doorhanger");
+ await installedPromise;
+
+ let addon = await AddonManager.getAddonByID(addonId);
+ Assert.deepEqual(
+ addon.installTelemetryInfo,
+ expectedTelemetryInfo,
+ "The installed add-on should have the expected telemetry info"
+ );
+}
diff --git a/toolkit/mozapps/extensions/test/browser/moz.build b/toolkit/mozapps/extensions/test/browser/moz.build
new file mode 100644
index 0000000000..09ada90ad1
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/moz.build
@@ -0,0 +1,31 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+BROWSER_CHROME_MANIFESTS += [
+ "browser.ini",
+]
+
+addons = [
+ "browser_dragdrop1",
+ "browser_dragdrop2",
+ "browser_dragdrop_incompat",
+ "browser_installssl",
+ "browser_theme",
+ "options_signed",
+]
+
+output_dir = (
+ OBJDIR_FILES._tests.testing.mochitest.browser.toolkit.mozapps.extensions.test.browser.addons
+)
+
+for addon in addons:
+ for file_type in ["xpi", "zip"]:
+ indir = "addons/%s" % addon
+ path = "%s.%s" % (indir, file_type)
+
+ GeneratedFile(path, script="../create_xpi.py", inputs=[indir])
+
+ output_dir += ["!%s" % path]
diff --git a/toolkit/mozapps/extensions/test/browser/plugin_test.html b/toolkit/mozapps/extensions/test/browser/plugin_test.html
new file mode 100644
index 0000000000..0709eda066
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/plugin_test.html
@@ -0,0 +1,7 @@
+<!DOCTYPE html>
+<html>
+<head><meta charset="utf-8"></head>
+<body>
+<object id="test" width=200 height=200 type="application/x-test"></object>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/browser/redirect.sjs b/toolkit/mozapps/extensions/test/browser/redirect.sjs
new file mode 100644
index 0000000000..8f9d1c08af
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/redirect.sjs
@@ -0,0 +1,5 @@
+function handleRequest(request, response) {
+ dump("*** Received redirect for " + request.queryString + "\n");
+ response.setStatusLine(request.httpVersion, 301, "Moved Permanently");
+ response.setHeader("Location", request.queryString, false);
+}
diff --git a/toolkit/mozapps/extensions/test/browser/sandboxed.html b/toolkit/mozapps/extensions/test/browser/sandboxed.html
new file mode 100644
index 0000000000..219426f0a9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/sandboxed.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<html dir="ltr" xml:lang="en-US" lang="en-US">
+ <head>
+ <meta charset="utf8">
+ </head>
+ <body>
+ Sandboxed page
+ </body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/browser/sandboxed.html^headers^ b/toolkit/mozapps/extensions/test/browser/sandboxed.html^headers^
new file mode 100644
index 0000000000..4705ce9ded
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/sandboxed.html^headers^
@@ -0,0 +1 @@
+Content-Security-Policy: sandbox allow-scripts;
diff --git a/toolkit/mozapps/extensions/test/browser/webapi_addon_listener.html b/toolkit/mozapps/extensions/test/browser/webapi_addon_listener.html
new file mode 100644
index 0000000000..383d2a0986
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/webapi_addon_listener.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+<p id="result"></p>
+<script type="text/javascript">
+let events = [];
+let resultEl = document.getElementById("result");
+[ "onEnabling",
+ "onEnabled",
+ "onDisabling",
+ "onDisabled",
+ "onInstalling",
+ "onInstalled",
+ "onUninstalling",
+ "onUninstalled",
+ "onOperationCancelled",
+].forEach(event => {
+ navigator.mozAddonManager.addEventListener(event, data => {
+ let obj = {event, id: data.id, needsRestart: data.needsRestart};
+ events.push(JSON.stringify(obj));
+ resultEl.textContent = events.join("\n");
+ });
+});
+</script>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/browser/webapi_checkavailable.html b/toolkit/mozapps/extensions/test/browser/webapi_checkavailable.html
new file mode 100644
index 0000000000..141f09cc61
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/webapi_checkavailable.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+<p id="result"></p>
+<script type="text/javascript">
+document.getElementById("result").textContent = ("mozAddonManager" in window.navigator);
+</script>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/browser/webapi_checkchromeframe.xhtml b/toolkit/mozapps/extensions/test/browser/webapi_checkchromeframe.xhtml
new file mode 100644
index 0000000000..6e3ba328ec
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/webapi_checkchromeframe.xhtml
@@ -0,0 +1,6 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <browser id="frame" disablehistory="true" flex="1" type="content"
+ src="https://example.com/browser/toolkit/mozapps/extensions/test/browser/webapi_checkavailable.html"/>
+</window>
diff --git a/toolkit/mozapps/extensions/test/browser/webapi_checkframed.html b/toolkit/mozapps/extensions/test/browser/webapi_checkframed.html
new file mode 100644
index 0000000000..1467699789
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/webapi_checkframed.html
@@ -0,0 +1,7 @@
+<!DOCTYPE html>
+
+<html>
+<body>
+<iframe id="frame" height="200" width="200" src="https://example.com/browser/toolkit/mozapps/extensions/test/browser/webapi_checkavailable.html">
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/browser/webapi_checknavigatedwindow.html b/toolkit/mozapps/extensions/test/browser/webapi_checknavigatedwindow.html
new file mode 100644
index 0000000000..e1f96a0b0c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/webapi_checknavigatedwindow.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+
+<html>
+<body>
+<script type="text/javascript">
+/* exported openWindow, navigate, check */
+var nav, win;
+
+function openWindow() {
+ return new Promise(resolve => {
+ win = window.open(window.location);
+
+ win.addEventListener("load", function listener() {
+ nav = win.navigator;
+ resolve();
+ });
+ });
+}
+
+function navigate() {
+ win.location = "http://example.com/";
+}
+
+function check() {
+ return "mozAddonManager" in nav;
+}
+</script>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/create_xpi.py b/toolkit/mozapps/extensions/test/create_xpi.py
new file mode 100644
index 0000000000..fcd6756e44
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/create_xpi.py
@@ -0,0 +1,21 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from os.path import abspath, relpath
+
+from mozbuild.action.zip import main as create_zip
+
+
+def main(output, input_dir, *files):
+ output.close()
+
+ if files:
+ # The zip builder doesn't handle the files being an absolute path.
+ in_files = [relpath(file, input_dir) for file in files]
+
+ return create_zip(["-C", input_dir, abspath(output.name)] + in_files)
+ else:
+ return create_zip(["-C", input_dir, abspath(output.name), "**"])
diff --git a/toolkit/mozapps/extensions/test/mochitest/chrome.ini b/toolkit/mozapps/extensions/test/mochitest/chrome.ini
new file mode 100644
index 0000000000..a8a8d49e3c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/mochitest/chrome.ini
@@ -0,0 +1,2 @@
+
+[test_default_theme.html]
diff --git a/toolkit/mozapps/extensions/test/mochitest/file_empty.html b/toolkit/mozapps/extensions/test/mochitest/file_empty.html
new file mode 100644
index 0000000000..b6c8a53b41
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/mochitest/file_empty.html
@@ -0,0 +1,2 @@
+<!DOCTYPE html>
+<html><head></head><body><span id="text">Nothing to see here</span></body></html>
diff --git a/toolkit/mozapps/extensions/test/mochitest/mochitest.ini b/toolkit/mozapps/extensions/test/mochitest/mochitest.ini
new file mode 100644
index 0000000000..c3cda3c091
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/mochitest/mochitest.ini
@@ -0,0 +1,6 @@
+[DEFAULT]
+support-files =
+ file_empty.html
+
+[test_blocklist_gfx_initialized.html]
+[test_bug887098.html]
diff --git a/toolkit/mozapps/extensions/test/mochitest/test_blocklist_gfx_initialized.html b/toolkit/mozapps/extensions/test/mochitest/test_blocklist_gfx_initialized.html
new file mode 100644
index 0000000000..ab0ad34173
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/mochitest/test_blocklist_gfx_initialized.html
@@ -0,0 +1,31 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test gfx blocklist is initialized</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script type="application/javascript">
+
+add_task(async function check_GfxBlocklistRS_initialized() {
+ // We have extensive xpcshell tests to ensure the blocklist works
+ // correctly. Here we just want to ensure the blocklist is indeed
+ // initialized in a regular browser setup.
+ // In fact, calling GfxBlocklistRS.checkForEntries() in order to test
+ // specific functionality would lazily initialize the blocklist, negating
+ // the value of this test.
+ let initialized = await SpecialPowers.spawnChrome([], async () => {
+ const { BlocklistPrivate } = ChromeUtils.import(
+ "resource://gre/modules/Blocklist.jsm"
+ );
+
+ return BlocklistPrivate.GfxBlocklistRS._initialized;
+ });
+
+ ok(initialized, "Gfx Blocklist was initialized")
+});
+ </script>
+</head>
+<body>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/mochitest/test_bug887098.html b/toolkit/mozapps/extensions/test/mochitest/test_bug887098.html
new file mode 100644
index 0000000000..acf646bd3c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/mochitest/test_bug887098.html
@@ -0,0 +1,70 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=887098
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 887098</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script type="application/javascript">
+ /* exported loaded */
+
+ /** Test for Bug 887098 **/
+ SimpleTest.waitForExplicitFinish();
+ /* globals $,evalRef */
+
+ async function loaded() {
+ if (!SpecialPowers.Services.prefs.getBoolPref("extensions.InstallTrigger.enabled") ||
+ !SpecialPowers.Services.prefs.getBoolPref("extensions.InstallTriggerImpl.enabled")) {
+ ok(true, "InstallTrigger is not enabled");
+ SimpleTest.finish();
+ return;
+ }
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Bug 1703215: Using SpecialPowers causes about:mozilla to be loaded in the wrong
+ // process, hence we have to flip the pref and don't enforce IPC based Principal Vetting.
+ ["dom.security.enforceIPCBasedPrincipalVetting", false],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+
+ var iwin = $("ifr").contentWindow;
+ var href = SpecialPowers.wrap(iwin).location.href;
+ if (/file_empty/.test(href)) {
+ window.evalRef = iwin.eval;
+ window.installTriggerRef = iwin.InstallTrigger; // Force lazy instantiation.
+ // about:mozilla is privileged, so we need to be privileged to load it.
+ SpecialPowers.wrap(iwin).location.href = "about:mozilla";
+ } else {
+ is(href, "about:mozilla", "Successfully navigated to about:mozilla");
+ try {
+ evalRef('InstallTrigger.install({URL: "chrome://global/skin/global.css"});');
+ ok(false, "Should have thrown when trying to install restricted URI from InstallTrigger");
+ } catch (e) {
+ // XXXgijs this test broke because of the switch to webidl. I'm told
+ // it has to do with compartments and the fact that we eval in "about:mozilla".
+ // Tracking in bug 1007671
+ todo(/permission/.test(e), "We should throw a security exception. Got: " + e);
+ }
+ SimpleTest.finish();
+ }
+ }
+
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=887098">Mozilla Bug 887098</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<iframe onload="loaded();" id="ifr" src="file_empty.html"></iframe>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/mochitest/test_default_theme.html b/toolkit/mozapps/extensions/test/mochitest/test_default_theme.html
new file mode 100644
index 0000000000..3896b22032
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/mochitest/test_default_theme.html
@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for correct installation of default theme</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css">
+</head>
+<body>
+
+<script>
+"use strict";
+
+const {AddonManager} = ChromeUtils.import("resource://gre/modules/AddonManager.jsm");
+const {AppConstants} = ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
+
+add_task(async function() {
+ let addon = await AddonManager.getAddonByID("default-theme@mozilla.org");
+
+ // Dev edition uses a different default theme on desktop.
+ const expectActive = (!AppConstants.MOZ_DEV_EDITION ||
+ AppConstants.MOZ_BUILD_APP !== "browser");
+
+ ok(addon != null, "Default theme exists");
+ is(addon.type, "theme", "Add-on type is correct");
+ is(addon.isActive, expectActive, "Add-on is active?");
+ is(addon.hidden, false, "Add-on is not hidden");
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/moz.build b/toolkit/mozapps/extensions/test/moz.build
new file mode 100644
index 0000000000..fe77aa6dd8
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/moz.build
@@ -0,0 +1,20 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += ["browser"]
+
+BROWSER_CHROME_MANIFESTS += ["xpinstall/browser.ini"]
+MOCHITEST_MANIFESTS += ["mochitest/mochitest.ini"]
+MOCHITEST_CHROME_MANIFESTS += ["mochitest/chrome.ini"]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "xpcshell/rs-blocklist/xpcshell.ini",
+ "xpcshell/xpcshell-unpack.ini",
+ "xpcshell/xpcshell.ini",
+]
+
+with Files("xpcshell/rs-blocklist/**"):
+ BUG_COMPONENT = ("Toolkit", "Blocklist Implementation")
diff --git a/toolkit/mozapps/extensions/test/xpcshell/.eslintrc.js b/toolkit/mozapps/extensions/test/xpcshell/.eslintrc.js
new file mode 100644
index 0000000000..8e3971b385
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/.eslintrc.js
@@ -0,0 +1,24 @@
+"use strict";
+
+module.exports = {
+ rules: {
+ "no-unused-vars": [
+ "error",
+ { args: "none", varsIgnorePattern: "^end_test$" },
+ ],
+ },
+ overrides: [
+ {
+ files: "head*.js",
+ rules: {
+ "no-unused-vars": [
+ "error",
+ {
+ args: "none",
+ vars: "local",
+ },
+ ],
+ },
+ },
+ ],
+};
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_block.xml b/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_block.xml
new file mode 100644
index 0000000000..1f673ef2fb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_block.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem id="test_bug455906_1@tests.mozilla.org" blockID="test_bug455906_1@tests.mozilla.org"/>
+ <emItem id="test_bug455906_2@tests.mozilla.org" blockID="test_bug455906_2@tests.mozilla.org"/>
+ <emItem id="test_bug455906_3@tests.mozilla.org" blockID="test_bug455906_3@tests.mozilla.org"/>
+ <emItem id="test_bug455906_4@tests.mozilla.org" blockID="test_bug455906_4@tests.mozilla.org"/>
+ <emItem id="test_bug455906_5@tests.mozilla.org" blockID="test_bug455906_5@tests.mozilla.org"/>
+ <emItem id="test_bug455906_6@tests.mozilla.org" blockID="test_bug455906_6@tests.mozilla.org"/>
+ <emItem id="test_bug455906_7@tests.mozilla.org" blockID="test_bug455906_7@tests.mozilla.org"/>
+ </emItems>
+ <pluginItems>
+ <pluginItem blockID="test_bug455906_plugin">
+ <match name="name" exp="^test_bug455906"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_empty.xml b/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_empty.xml
new file mode 100644
index 0000000000..88d22f281f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_empty.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem id="dummy_bug455906_2@tests.mozilla.org"/>
+ </emItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_start.xml b/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_start.xml
new file mode 100644
index 0000000000..daba6f4c1c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_start.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem id="test_bug455906_4@tests.mozilla.org">
+ <versionRange severity="-1"/>
+ </emItem>
+ <emItem id="test_bug455906_5@tests.mozilla.org">
+ <versionRange severity="1"/>
+ </emItem>
+ <emItem id="test_bug455906_6@tests.mozilla.org">
+ <versionRange severity="2"/>
+ </emItem>
+ <emItem id="dummy_bug455906_1@tests.mozilla.org"/>
+ </emItems>
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="^test_bug455906_4$"/>
+ <versionRange severity="0"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug455906_5$"/>
+ <versionRange severity="1"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug455906_6$"/>
+ <versionRange severity="2"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_warn.xml b/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_warn.xml
new file mode 100644
index 0000000000..232fd0d079
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_warn.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem id="test_bug455906_1@tests.mozilla.org">
+ <versionRange severity="-1"/>
+ </emItem>
+ <emItem id="test_bug455906_2@tests.mozilla.org">
+ <versionRange severity="-1"/>
+ </emItem>
+ <emItem id="test_bug455906_3@tests.mozilla.org">
+ <versionRange severity="-1"/>
+ </emItem>
+ <emItem id="test_bug455906_4@tests.mozilla.org">
+ <versionRange severity="-1"/>
+ </emItem>
+ <emItem id="test_bug455906_5@tests.mozilla.org">
+ <versionRange severity="-1"/>
+ </emItem>
+ <emItem id="test_bug455906_6@tests.mozilla.org">
+ <versionRange severity="-1"/>
+ </emItem>
+ <emItem id="test_bug455906_7@tests.mozilla.org">
+ <versionRange severity="-1"/>
+ </emItem>
+ </emItems>
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="^test_bug455906"/>
+ <versionRange severity="-1"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/corrupt.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/corrupt.xpi
new file mode 100644
index 0000000000..35d7bd5e5d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/corrupt.xpi
@@ -0,0 +1 @@
+This is a corrupt zip file
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/corruptfile.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/corruptfile.xpi
new file mode 100644
index 0000000000..0c30989aa5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/corruptfile.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/empty.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/empty.xpi
new file mode 100644
index 0000000000..74ed2b8174
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/empty.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/mlbf-blocked1-unblocked2.bin b/toolkit/mozapps/extensions/test/xpcshell/data/mlbf-blocked1-unblocked2.bin
new file mode 100644
index 0000000000..fe8e08fa68
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/mlbf-blocked1-unblocked2.bin
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/pluginInfoURL_block.xml b/toolkit/mozapps/extensions/test/xpcshell/data/pluginInfoURL_block.xml
new file mode 100644
index 0000000000..75e252a46b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/pluginInfoURL_block.xml
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ </emItems>
+ <pluginItems>
+ <pluginItem blockID="test_plugin_wInfoURL">
+ <match name="name" exp="^test_with_infoURL"/>
+ <match name="version" exp="^5"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="*"/>
+ </targetApplication>
+ </versionRange>
+ <infoURL>http://test.url.com/</infoURL>
+ </pluginItem>
+ <pluginItem blockID="test_plugin_wAltInfoURL">
+ <match name="name" exp="^test_with_altInfoURL"/>
+ <match name="version" exp="^5"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="*"/>
+ </targetApplication>
+ </versionRange>
+ <infoURL>http://alt.test.url.com/</infoURL>
+ </pluginItem>
+ <pluginItem blockID="test_plugin_noInfoURL">
+ <match name="name" exp="^test_no_infoURL"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="*"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem blockID="test_plugin_newVersion">
+ <match name="name" exp="^test_newVersion"/>
+ <infoURL>http://test.url2.com/</infoURL>
+ <versionRange minVersion="1" maxVersion="2">
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="*"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.txt b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.txt
new file mode 100644
index 0000000000..f17f98b15b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.txt
@@ -0,0 +1 @@
+Not an xml file! \ No newline at end of file
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.xml b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.xml
new file mode 100644
index 0000000000..0e3d415c44
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.xml
@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<foobar></barfoo> \ No newline at end of file
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad2.xml b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad2.xml
new file mode 100644
index 0000000000..55ad1c7d55
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad2.xml
@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<test></test>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_aus_ee.pem b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_aus_ee.pem
new file mode 100644
index 0000000000..727f5fbf1f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_aus_ee.pem
@@ -0,0 +1,15 @@
+-----BEGIN CERTIFICATE-----
+MIICRzCCAS+gAwIBAgIUHTYZB656hjXTPTOENW1guxnd52owDQYJKoZIhvcNAQEL
+BQAwETEPMA0GA1UEAwwGaW50LUNBMCIYDzIwMjExMTI3MDAwMDAwWhgPMjAyNDAy
+MDUwMDAwMDBaMA0xCzAJBgNVBAMMAmVlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE
+oWhyQzYrXHsYifN5FUYVocc/tI3uhj4CKRXbYI4lLeS3Ey2ozpjoMVNOapwMCwnI
+1jmt6DIG5bqBNHOhH6Mw4F2oyW5Dg/4nhz2pcQO+KIjP8ALwWvcaH93Mg3SqbqnO
+o0UwQzATBgNVHSUEDDAKBggrBgEFBQcDAzAsBgNVHREEJTAjgiFhdXMuY29udGVu
+dC1zaWduYXR1cmUubW96aWxsYS5vcmcwDQYJKoZIhvcNAQELBQADggEBALbaJLMG
+X6B4ICeFWkEmwIHpDklRm17teCCZhUUTm9c2gBoz/32hBEp9XwIZVFcD4AVpJuKQ
+8uE1iy2ZKemmgwg/wzq+ktwmQ0unlHyXvDPo/3mhrswEBxS8bmZLYZSUlOi9eZ82
+hsK5TfWVkRLdmLKr+7z4acfZL1Q6Y2yz26R2vSXGbvs6V0IkGIJyrzrAQjXkBS8j
+Xx03wTI2z9PLNWyh4OQTfjDvcI79FpVIp0JsoV96Uil+L1opdXMc3QiXE5OggrGY
+p6ZSEKBKw9N/8SOcK5iEEJ84qcG7uPnQWNBwgFeVVCqByDWKRhBmZB2CicCd5qvA
+YSoHlKlTgCdmYCQ=
+-----END CERTIFICATE-----
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_aus_ee.pem.certspec b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_aus_ee.pem.certspec
new file mode 100644
index 0000000000..ee9fea9110
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_aus_ee.pem.certspec
@@ -0,0 +1,5 @@
+issuer:int-CA
+subject:ee
+subjectKey:secp384r1
+extension:extKeyUsage:codeSigning
+extension:subjectAlternativeName:aus.content-signature.mozilla.org
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_int.pem b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_int.pem
new file mode 100644
index 0000000000..d615eccf22
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_int.pem
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE-----
+MIIC8TCCAdmgAwIBAgIUNZb51bNpKyzQtWTCj5zrdME7cKYwDQYJKoZIhvcNAQEL
+BQAwKTEnMCUGA1UEAwweeHBjc2hlbGwgc2lnbmVkIGFwcHMgdGVzdCByb290MCIY
+DzIwMjExMTI3MDAwMDAwWhgPMjAyNDAyMDUwMDAwMDBaMBExDzANBgNVBAMMBmlu
+dC1DQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALqIUahEjhbWQf1u
+togGNhA9PBPZ6uQ1SrTs9WhXbCR7wcclqODYH72xnAabbhqG8mvir1p1a2pkcQh6
+pVqnRYf3HNUknAJ+zUP8HmnQOCApk6sgw0nk27lMwmtsDu0Vgg/xfq1pGrHTAjqL
+KkHup3DgDw2N/WYLK7AkkqR9uYhheZCxV5A90jvF4LhIH6g304hD7ycW2FW3Zlqq
+fgKQLzp7EIAGJMwcbJetlmFbt+KWEsB1MaMMkd20yvf8rR0l0wnvuRcOp2jhs3sv
+Im9p47SKlWEd7ibWJZ2rkQhONsscJAQsvxaLL+Xxj5kXMbiz/kkj+nJRxDHVA6za
+GAo17Y0CAwEAAaMlMCMwDAYDVR0TBAUwAwEB/zATBgNVHSUEDDAKBggrBgEFBQcD
+AzANBgkqhkiG9w0BAQsFAAOCAQEAefvKJnF/4qRY9sf/jYCPhWyngBx6JhWFJKiy
+IUHmejn9q/LUX3nskHXA4gAt+KF9hfk9Nx5naL5DaYOkvETawdrSw55Hvphi4MB2
+yHManuj+yplqr8rtDh8Tb2Wm/AeiBqKMTa4AFN9xPbKOrUAVgU+VsXlEIUmOzEI+
+E0HeeIoPCCa6vWPpwhKb4LUlVupe3toJHVbFSp2KcD4gCRsgK60lyqZBosAG8Sat
+Vk7XLPv152/jl7j+pYqnlwabF/LEyVSqegVvvr481kgX8RyEjiPx2wNYYqUF3CPG
+SE2lDXWy629KUGwTH9rUpayMqbfL5bQ9fSGA5vE9pT7vlbBaRg==
+-----END CERTIFICATE-----
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_int.pem.certspec b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_int.pem.certspec
new file mode 100644
index 0000000000..fc9dfd47ae
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_int.pem.certspec
@@ -0,0 +1,4 @@
+issuer:xpcshell signed apps test root
+subject:int-CA
+extension:basicConstraints:cA,
+extension:extKeyUsage:codeSigning
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/empty.xml b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/empty.xml
new file mode 100644
index 0000000000..42cb20bd01
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/empty.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<updates>
+ <addons></addons>
+</updates>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/good.xml b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/good.xml
new file mode 100644
index 0000000000..e1da86fa54
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/good.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<updates>
+ <addons>
+ <addon id="test1" URL="http://example.com/test1.xpi"/>
+ <addon id="test2" URL="http://example.com/test2.xpi" hashFunction="md5" hashValue="djhfgsjdhf"/>
+ <addon id="test3" URL="http://example.com/test3.xpi" version="1.0" size="45"/>
+ <addon id="test4"/>
+ <addon URL="http://example.com/test5.xpi"/>
+ </addons>
+</updates>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/missing.xml b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/missing.xml
new file mode 100644
index 0000000000..8c9501478e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/missing.xml
@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<updates></updates>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/unsigned.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/unsigned.xpi
new file mode 100644
index 0000000000..51b00475a9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/unsigned.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/langpack_signed.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/langpack_signed.xpi
new file mode 100644
index 0000000000..f60d00348e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/langpack_signed.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/langpack_unsigned.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/langpack_unsigned.xpi
new file mode 100644
index 0000000000..89de7f4409
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/langpack_unsigned.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/long.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/long.xpi
new file mode 100644
index 0000000000..f95f3df91e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/long.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/privileged.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/privileged.xpi
new file mode 100644
index 0000000000..c22acaacd2
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/privileged.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/signed1.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/signed1.xpi
new file mode 100644
index 0000000000..e2ba7d6fd8
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/signed1.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/signed2.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/signed2.xpi
new file mode 100644
index 0000000000..ccb20796f2
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/signed2.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/unsigned.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/unsigned.xpi
new file mode 100644
index 0000000000..9e10be5db3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/unsigned.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_cache.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_cache.json
new file mode 100644
index 0000000000..a9fdcf1782
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_cache.json
@@ -0,0 +1,134 @@
+{
+ "page_size": 25,
+ "page_count": 1,
+ "count": 4,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "name": "Repo Add-on 1",
+ "type": "extension",
+ "guid": "test_AddonRepository_1@tests.mozilla.org",
+ "current_version": {
+ "version": "2.1",
+ "files": [
+ {
+ "platform": "all",
+ "size": 9,
+ "url": "http://example.com/repo/1/install.xpi"
+ }
+ ]
+ },
+ "authors": [
+ {
+ "name": "Repo Add-on 1 - Creator",
+ "url": "http://example.com/repo/1/creator.html"
+ },
+ {
+ "name": "Repo Add-on 1 - First Developer",
+ "url": "http://example.com/repo/1/firstDeveloper.html"
+ },
+ {
+ "name": "Repo Add-on 1 - Second Developer",
+ "url": "http://example.com/repo/1/secondDeveloper.html"
+ }
+ ],
+ "summary": "Repo Add-on 1 - Description<br>Second line",
+ "description": "<p>Repo Add-on 1 - Full Description &amp; some extra</p>",
+ "icons": {
+ "32": "http://example.com/repo/1/icon.png"
+ },
+ "ratings": {
+ "count": 1234,
+ "text_count": 1111,
+ "average": 1
+ },
+ "homepage": "http://example.com/repo/1/homepage.html",
+ "support_url": "http://example.com/repo/1/support.html",
+ "contributions_url": "http://example.com/repo/1/meetDevelopers.html",
+ "ratings_url": "http://example.com/repo/1/review.html",
+ "weekly_downloads": 3331,
+ "last_updated": "1970-01-01T00:00:09Z"
+ },
+ {
+ "name": "Repo Add-on 2",
+ "type": "theme",
+ "guid": "test_AddonRepository_2@tests.mozilla.org",
+ "current_version": {
+ "version": "2.2",
+ "files": [
+ {
+ "platform": "all",
+ "size": 9,
+ "url": "http://example.com/repo/2/install.xpi"
+ }
+ ]
+ },
+ "authors": [
+ {
+ "name": "Repo Add-on 2 - Creator",
+ "url": "http://example.com/repo/2/creator.html"
+ },
+ {
+ "name": "Repo Add-on 2 - First Developer",
+ "url": "http://example.com/repo/2/firstDeveloper.html"
+ },
+ {
+ "name": "Repo Add-on 2 - Second Developer",
+ "url": "http://example.com/repo/2/secondDeveloper.html"
+ }
+ ],
+ "summary": "Repo Add-on 2 - Description",
+ "description": "Repo Add-on 2 - Full Description",
+ "icons": {
+ "32": "http://example.com/repo/2/icon.png"
+ },
+ "previews": [
+ {
+ "image_url": "http://example.com/repo/2/firstFull.png",
+ "thumbnail_url": "http://example.com/repo/2/firstThumbnail.png",
+ "caption": "Repo Add-on 2 - First Caption"
+ },
+ {
+ "image_url": "http://example.com/repo/2/secondFull.png",
+ "thumbnail_url": "http://example.com/repo/2/secondThumbnail.png",
+ "caption": "Repo Add-on 2 - Second Caption"
+ }
+ ],
+ "ratings": {
+ "count": 2223,
+ "text_count": 1112,
+ "average": 2
+ },
+ "homepage": "http://example.com/repo/2/homepage.html",
+ "support_url": "http://example.com/repo/2/support.html",
+ "contributions_url": "http://example.com/repo/2/meetDevelopers.html",
+ "ratings_url": "http://example.com/repo/2/review.html",
+ "weekly_downloads": 3332,
+ "last_updated": "1970-01-01T00:00:09Z"
+ },
+ {
+ "name": "Repo Add-on 3",
+ "type": "theme",
+ "guid": "test_AddonRepository_3@tests.mozilla.org",
+ "current_version": {
+ "version": "2.3"
+ },
+ "icons": {
+ "32": "http://example.com/repo/3/icon.png"
+ },
+ "previews": [
+ {
+ "image_url": "http://example.com/repo/3/firstFull.png",
+ "thumbnail_url": "http://example.com/repo/3/firstThumbnail.png",
+ "caption": "Repo Add-on 3 - First Caption"
+ },
+ {
+ "image_url": "http://example.com/repo/3/secondFull.png",
+ "thumbnail_url": "http://example.com/repo/3/secondThumbnail.png",
+ "caption": "Repo Add-on 3 - Second Caption"
+ }
+ ]
+ }
+ ]
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_empty.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_empty.json
new file mode 100644
index 0000000000..c6c09cdf92
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_empty.json
@@ -0,0 +1,7 @@
+{
+ "page_size": 25,
+ "count": 0,
+ "next": null,
+ "previous": null,
+ "results": []
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_fail.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_fail.json
new file mode 100644
index 0000000000..d29d525a81
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_fail.json
@@ -0,0 +1 @@
+this should yield a json parse error
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getAddonsByIDs.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getAddonsByIDs.json
new file mode 100644
index 0000000000..6f83c5bdd2
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getAddonsByIDs.json
@@ -0,0 +1,116 @@
+{
+ "page_size": 25,
+ "page_count": 1,
+ "count": 4,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "name": "PASS",
+ "type": "extension",
+ "guid": "test1@tests.mozilla.org",
+ "current_version": {
+ "version": "1.1",
+ "files": [
+ {
+ "platform": "all",
+ "url": "http://example.com/addons/test_AddonRepository_2.xpi",
+ "size": 5555
+ }
+ ]
+ },
+ "authors": [
+ {
+ "name": "Test Creator 1",
+ "url": "http://example.com/creator1.html"
+ },
+ {
+ "name": "Test Developer 1",
+ "url": "http://example.com/developer1.html"
+ }
+ ],
+ "summary": "Test Summary 1",
+ "description": "Test Description 1",
+ "icons": {
+ "32": "http://example.com/icon1.png"
+ },
+ "previews": [
+ {
+ "caption": "Caption 1 - 1",
+ "image_size": [400, 300],
+ "image_url": "http://example.com/full1-1.png",
+ "thumbnail_size": [200, 150],
+ "thumbnail_url": "http://example.com/thumbnail1-1.png"
+ },
+ {
+ "caption": "Caption 2 - 1",
+ "image_url": "http://example.com/full2-1.png",
+ "thumbnail_url": "http://example.com/thumbnail2-1.png"
+ }
+ ],
+ "ratings": {
+ "count": 1234,
+ "text_count": 1111,
+ "average": 4
+ },
+ "ratings_url": "http://example.com/review1.html",
+ "support_url": "http://example.com/support1.html",
+ "contributions_url": "http://example.com/contribution1.html",
+ "weekly_downloads": 3333,
+ "last_updated": "2010-02-01T14:04:05Z"
+ },
+ {
+ "name": "PASS",
+ "type": "extension",
+ "guid": "test2@tests.mozilla.org",
+ "current_version": {
+ "version": "2.0",
+ "files": [
+ {
+ "platform": "XPCShell",
+ "url": "http://example.com/addons/bleah.xpi",
+ "size": 1000
+ }
+ ]
+ }
+ },
+ {
+ "name": "FAIL",
+ "type": "extension",
+ "guid": "notRequested@tests.mozilla.org",
+ "current_version": {
+ "version": "1.3",
+ "files": [
+ {
+ "platform": "all",
+ "url": "http://example.com/test3.xpi"
+ }
+ ]
+ },
+ "authors": [
+ {
+ "name": "Test Creator 3"
+ }
+ ],
+ "summary": "Add-on with a guid that wasn't requested should be ignored."
+ },
+ {
+ "name": "PASS",
+ "type": "theme",
+ "guid": "test_AddonRepository_1@tests.mozilla.org",
+ "current_version": {
+ "version": "1.4",
+ "files": [
+ {
+ "platform": "UNKNOWN1",
+ "url": "http://example.com/test4.xpi"
+ },
+ {
+ "platform": "UNKNOWN2",
+ "url": "http://example.com/test4.xpi"
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_backgroundupdate.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_backgroundupdate.json
new file mode 100644
index 0000000000..b83e0b04ba
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_backgroundupdate.json
@@ -0,0 +1,46 @@
+{
+ "addons": {
+ "addon2@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "1"
+ }
+ },
+ "version": "2",
+ "update_link": "http://example.com/broken.xpi"
+ }
+ ]
+ },
+ "addon3@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "1"
+ }
+ },
+ "version": "2",
+ "update_link": "http://example.com/broken.xpi"
+ }
+ ]
+ },
+ "addon1@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "1"
+ }
+ },
+ "version": "2",
+ "update_link": "http://example.com/broken.xpi"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_metadata_filters_1.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_metadata_filters_1.xml
new file mode 100644
index 0000000000..b092418bbb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_metadata_filters_1.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem name="/^Mozilla Corp\.$/">
+ <versionRange severity="1">
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="2.*"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="/block2/" name="/^Moz/"
+ homepageURL="/\.dangerous\.com/" updateURL="/\.dangerous\.com/">
+ <versionRange severity="3">
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="2.*"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ </emItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_prefs_1.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_prefs_1.xml
new file mode 100644
index 0000000000..41df457b05
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_prefs_1.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem id="block1@tests.mozilla.org">
+ <prefs>
+ <pref>test.blocklist.pref1</pref>
+ <pref>test.blocklist.pref2</pref>
+ </prefs>
+ <versionRange severity="1">
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="2.*"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="block2@tests.mozilla.org">
+ <prefs>
+ <pref>test.blocklist.pref3</pref>
+ <pref>test.blocklist.pref4</pref>
+ </prefs>
+ <versionRange severity="3">
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="2.*"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ </emItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug393285.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug393285.xml
new file mode 100644
index 0000000000..1767b4332f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug393285.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem id="test_bug393285_2@tests.mozilla.org"/>
+ <emItem id="test_bug393285_3a@tests.mozilla.org">
+ <versionRange minVersion="1.0" maxVersion="1.0"/>
+ </emItem>
+ <emItem id="test_bug393285_3b@tests.mozilla.org">
+ <versionRange minVersion="1.0" maxVersion="1.0"/>
+ </emItem>
+ <emItem id="test_bug393285_4@tests.mozilla.org">
+ <versionRange minVersion="1.0" maxVersion="1.0">
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1.0" maxVersion="1.0"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug393285_5@tests.mozilla.org" os="Darwin"/>
+ <emItem id="test_bug393285_6@tests.mozilla.org" os="XPCShell"/>
+ <emItem id="test_bug393285_7@tests.mozilla.org" os="Darwin,XPCShell,WINNT"/>
+ <emItem id="test_bug393285_8@tests.mozilla.org" xpcomabi="x86-msvc"/>
+ <emItem id="test_bug393285_9@tests.mozilla.org" xpcomabi="noarch-spidermonkey"/>
+ <emItem id="test_bug393285_10@tests.mozilla.org" xpcomabi="ppc-gcc3,noarch-spidermonkey,x86-msvc"/>
+ <emItem id="test_bug393285_11@tests.mozilla.org" os="Darwin" xpcomabi="ppc-gcc3,x86-msvc"/>
+ <emItem id="test_bug393285_12@tests.mozilla.org" os="Darwin" xpcomabi="ppc-gcc3,noarch-spidermonkey,x86-msvc"/>
+ <emItem id="test_bug393285_13@tests.mozilla.org" os="XPCShell" xpcomabi="ppc-gcc3,x86-msvc"/>
+ <emItem id="test_bug393285_14@tests.mozilla.org" os="XPCShell,WINNT" xpcomabi="ppc-gcc3,x86-msvc,noarch-spidermonkey"/>
+ </emItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app-extensions.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app-extensions.json
new file mode 100644
index 0000000000..2c1fff10c5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app-extensions.json
@@ -0,0 +1,332 @@
+[
+ {
+ "_comment": "Always blocked",
+ "guid": "test_bug449027_2@tests.mozilla.org",
+ "versionRange": []
+ },
+ {
+ "_comment": "Always blocked",
+ "guid": "test_bug449027_3@tests.mozilla.org",
+ "versionRange": [{}]
+ },
+ {
+ "_comment": "Not blocked since neither version range matches",
+ "guid": "test_bug449027_4@tests.mozilla.org",
+ "versionRange": [
+ {
+ "minVersion": "6"
+ },
+ {
+ "maxVersion": "4"
+ }
+ ]
+ },
+ {
+ "_comment": "Invalid version range, should not block",
+ "guid": "test_bug449027_5@tests.mozilla.org",
+ "versionRange": [
+ {
+ "maxVersion": "4",
+ "minVersion": "6"
+ }
+ ]
+ },
+ {
+ "_comment": "Should block all of these",
+ "guid": "test_bug449027_6@tests.mozilla.org",
+ "versionRange": [
+ {
+ "maxVersion": "8",
+ "minVersion": "7"
+ },
+ {
+ "maxVersion": "6",
+ "minVersion": "5"
+ },
+ {
+ "maxVersion": "4"
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_7@tests.mozilla.org",
+ "versionRange": [
+ {
+ "maxVersion": "4"
+ },
+ {
+ "maxVersion": "5",
+ "minVersion": "4"
+ },
+ {
+ "maxVersion": "7",
+ "minVersion": "6"
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_8@tests.mozilla.org",
+ "versionRange": [
+ {
+ "maxVersion": "2",
+ "minVersion": "2"
+ },
+ {
+ "maxVersion": "6",
+ "minVersion": "4"
+ },
+ {
+ "maxVersion": "8",
+ "minVersion": "7"
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_9@tests.mozilla.org",
+ "versionRange": [
+ {
+ "minVersion": "4"
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_10@tests.mozilla.org",
+ "versionRange": [
+ {
+ "minVersion": "5"
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_11@tests.mozilla.org",
+ "versionRange": [
+ {
+ "maxVersion": "6"
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_12@tests.mozilla.org",
+ "versionRange": [
+ {
+ "maxVersion": "5"
+ }
+ ]
+ },
+ {
+ "_comment": "This should block all versions for any application",
+ "guid": "test_bug449027_13@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [{}]
+ }
+ ]
+ },
+ {
+ "_comment": "Shouldn't block",
+ "guid": "test_bug449027_14@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Should block for any version of the app",
+ "guid": "test_bug449027_15@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Should still block",
+ "guid": "test_bug449027_16@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Not blocked since neither version range matches",
+ "guid": "test_bug449027_17@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "minVersion": "4"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "2"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Invalid version range, should not block",
+ "guid": "test_bug449027_18@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "4",
+ "minVersion": "6"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Should block all of these",
+ "guid": "test_bug449027_19@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "6",
+ "minVersion": "5"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "4",
+ "minVersion": "3"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "2"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_20@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "2"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "3",
+ "minVersion": "2"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "5",
+ "minVersion": "4"
+ },
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_21@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "1",
+ "minVersion": "1"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "4",
+ "minVersion": "2"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "6",
+ "minVersion": "5"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_22@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "minVersion": "3"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_23@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "minVersion": "2"
+ },
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_24@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "3"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_25@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "4"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app-plugins.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app-plugins.json
new file mode 100644
index 0000000000..c88088c9b3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app-plugins.json
@@ -0,0 +1,332 @@
+[
+ {
+ "_comment": "Always blocked",
+ "matchName": "^test_bug449027_2$",
+ "versionRange": []
+ },
+ {
+ "_comment": "Always blocked",
+ "matchName": "^test_bug449027_3$",
+ "versionRange": [{}]
+ },
+ {
+ "_comment": "Not blocked since neither version range matches",
+ "matchName": "^test_bug449027_4$",
+ "versionRange": [
+ {
+ "minVersion": "6"
+ },
+ {
+ "maxVersion": "4"
+ }
+ ]
+ },
+ {
+ "_comment": "Invalid version range, should not block",
+ "matchName": "^test_bug449027_5$",
+ "versionRange": [
+ {
+ "maxVersion": "4",
+ "minVersion": "6"
+ }
+ ]
+ },
+ {
+ "_comment": "Should block all of these",
+ "matchName": "^test_bug449027_6$",
+ "versionRange": [
+ {
+ "maxVersion": "8",
+ "minVersion": "7"
+ },
+ {
+ "maxVersion": "6",
+ "minVersion": "5"
+ },
+ {
+ "maxVersion": "4"
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_7$",
+ "versionRange": [
+ {
+ "maxVersion": "4"
+ },
+ {
+ "maxVersion": "5",
+ "minVersion": "4"
+ },
+ {
+ "maxVersion": "7",
+ "minVersion": "6"
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_8$",
+ "versionRange": [
+ {
+ "maxVersion": "2",
+ "minVersion": "2"
+ },
+ {
+ "maxVersion": "6",
+ "minVersion": "4"
+ },
+ {
+ "maxVersion": "8",
+ "minVersion": "7"
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_9$",
+ "versionRange": [
+ {
+ "minVersion": "4"
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_10$",
+ "versionRange": [
+ {
+ "minVersion": "5"
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_11$",
+ "versionRange": [
+ {
+ "maxVersion": "6"
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_12$",
+ "versionRange": [
+ {
+ "maxVersion": "5"
+ }
+ ]
+ },
+ {
+ "_comment": "This should block all versions for any application",
+ "matchName": "^test_bug449027_13$",
+ "versionRange": [
+ {
+ "targetApplication": [{}]
+ }
+ ]
+ },
+ {
+ "_comment": "Shouldn't block",
+ "matchName": "^test_bug449027_14$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Should block for any version of the app",
+ "matchName": "^test_bug449027_15$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Should still block",
+ "matchName": "^test_bug449027_16$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Not blocked since neither version range matches",
+ "matchName": "^test_bug449027_17$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "minVersion": "4"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "2"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Invalid version range, should not block",
+ "matchName": "^test_bug449027_18$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "4",
+ "minVersion": "6"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Should block all of these",
+ "matchName": "^test_bug449027_19$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "6",
+ "minVersion": "5"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "4",
+ "minVersion": "3"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "2"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_20$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "2"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "3",
+ "minVersion": "2"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "5",
+ "minVersion": "4"
+ },
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_21$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "1",
+ "minVersion": "1"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "4",
+ "minVersion": "2"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "6",
+ "minVersion": "5"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_22$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "minVersion": "3"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_23$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "minVersion": "2"
+ },
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_24$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "3"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_25$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "4"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app.xml
new file mode 100644
index 0000000000..f12ca1fa6d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app.xml
@@ -0,0 +1,333 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <!-- All extensions are version 5 and tests run against appVersion 3 -->
+
+ <!-- Test 1 not listed, should never get blocked -->
+ <!-- Always blocked -->
+ <emItem id="test_bug449027_2@tests.mozilla.org"/>
+ <!-- Always blocked -->
+ <emItem id="test_bug449027_3@tests.mozilla.org">
+ <versionRange/>
+ </emItem>
+ <!-- Not blocked since neither version range matches -->
+ <emItem id="test_bug449027_4@tests.mozilla.org">
+ <versionRange minVersion="6"/>
+ <versionRange maxVersion="4"/>
+ </emItem>
+ <!-- Invalid version range, should not block -->
+ <emItem id="test_bug449027_5@tests.mozilla.org">
+ <versionRange minVersion="6" maxVersion="4"/>
+ </emItem>
+ <!-- Should block all of these -->
+ <emItem id="test_bug449027_6@tests.mozilla.org">
+ <versionRange minVersion="7" maxVersion="8"/>
+ <versionRange minVersion="5" maxVersion="6"/>
+ <versionRange maxVersion="4"/>
+ </emItem>
+ <emItem id="test_bug449027_7@tests.mozilla.org">
+ <versionRange maxVersion="4"/>
+ <versionRange minVersion="4" maxVersion="5"/>
+ <versionRange minVersion="6" maxVersion="7"/>
+ </emItem>
+ <emItem id="test_bug449027_8@tests.mozilla.org">
+ <versionRange minVersion="2" maxVersion="2"/>
+ <versionRange minVersion="4" maxVersion="6"/>
+ <versionRange minVersion="7" maxVersion="8"/>
+ </emItem>
+ <emItem id="test_bug449027_9@tests.mozilla.org">
+ <versionRange minVersion="4"/>
+ </emItem>
+ <emItem id="test_bug449027_10@tests.mozilla.org">
+ <versionRange minVersion="5"/>
+ </emItem>
+ <emItem id="test_bug449027_11@tests.mozilla.org">
+ <versionRange maxVersion="6"/>
+ </emItem>
+ <emItem id="test_bug449027_12@tests.mozilla.org">
+ <versionRange maxVersion="5"/>
+ </emItem>
+
+ <!-- This should block all versions for any application -->
+ <emItem id="test_bug449027_13@tests.mozilla.org">
+ <versionRange>
+ <targetApplication/>
+ </versionRange>
+ </emItem>
+ <!-- Shouldn't block -->
+ <emItem id="test_bug449027_14@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </emItem>
+ <!-- Should block for any version of the app -->
+ <emItem id="test_bug449027_15@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org"/>
+ </versionRange>
+ </emItem>
+ <!-- Should still block -->
+ <emItem id="test_bug449027_16@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <!-- Not blocked since neither version range matches -->
+ <emItem id="test_bug449027_17@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="4"/>
+ <versionRange maxVersion="2"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <!-- Invalid version range, should not block -->
+ <emItem id="test_bug449027_18@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="6" maxVersion="4"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <!-- Should block all of these -->
+ <emItem id="test_bug449027_19@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="5" maxVersion="6"/>
+ <versionRange minVersion="3" maxVersion="4"/>
+ <versionRange maxVersion="2"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_20@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange maxVersion="2"/>
+ <versionRange minVersion="2" maxVersion="3"/>
+ <versionRange minVersion="4" maxVersion="5"/>
+ </targetApplication>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_21@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="1"/>
+ <versionRange minVersion="2" maxVersion="4"/>
+ <versionRange minVersion="5" maxVersion="6"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_22@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="3"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_23@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="2"/>
+ </targetApplication>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_24@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange maxVersion="3"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_25@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange maxVersion="4"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ </emItems>
+ <pluginItems>
+ <!-- All plugins are version 5 and tests run against appVersion 3 -->
+
+ <!-- Test 1 not listed, should never get blocked -->
+ <!-- Always blocked -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_2$"/>
+ </pluginItem>
+ <!-- Always blocked -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_3$"/>
+ <versionRange/>
+ </pluginItem>
+ <!-- Not blocked since neither version range matches -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_4$"/>
+ <versionRange minVersion="6"/>
+ <versionRange maxVersion="4"/>
+ </pluginItem>
+ <!-- Invalid version range, should not block -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_5$"/>
+ <versionRange minVersion="6" maxVersion="4"/>
+ </pluginItem>
+ <!-- Should block all of these -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_6$"/>
+ <versionRange minVersion="7" maxVersion="8"/>
+ <versionRange minVersion="5" maxVersion="6"/>
+ <versionRange maxVersion="4"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_7$"/>
+ <versionRange maxVersion="4"/>
+ <versionRange minVersion="4" maxVersion="5"/>
+ <versionRange minVersion="6" maxVersion="7"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_8$"/>
+ <versionRange minVersion="2" maxVersion="2"/>
+ <versionRange minVersion="4" maxVersion="6"/>
+ <versionRange minVersion="7" maxVersion="8"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_9$"/>
+ <versionRange minVersion="4"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_10$"/>
+ <versionRange minVersion="5"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_11$"/>
+ <versionRange maxVersion="6"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_12$"/>
+ <versionRange maxVersion="5"/>
+ </pluginItem>
+
+ <!-- This should block all versions for any application -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_13$"/>
+ <versionRange>
+ <targetApplication/>
+ </versionRange>
+ </pluginItem>
+ <!-- Shouldn't block -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_14$"/>
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </pluginItem>
+ <!-- Should block for any version of the app -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_15$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org"/>
+ </versionRange>
+ </pluginItem>
+ <!-- Should still block -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_16$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <!-- Not blocked since neither version range matches -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_17$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="4"/>
+ <versionRange maxVersion="2"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <!-- Invalid version range, should not block -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_18$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="6" maxVersion="4"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <!-- Should block all of these -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_19$"/>
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="5" maxVersion="6"/>
+ <versionRange minVersion="3" maxVersion="4"/>
+ <versionRange maxVersion="2"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_20$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange maxVersion="2"/>
+ <versionRange minVersion="2" maxVersion="3"/>
+ <versionRange minVersion="4" maxVersion="5"/>
+ </targetApplication>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_21$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="1"/>
+ <versionRange minVersion="2" maxVersion="4"/>
+ <versionRange minVersion="5" maxVersion="6"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_22$"/>
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="3"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_23$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="2"/>
+ </targetApplication>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_24$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange maxVersion="3"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_25$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange maxVersion="4"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit-extensions.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit-extensions.json
new file mode 100644
index 0000000000..107079fd41
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit-extensions.json
@@ -0,0 +1,189 @@
+[
+ {
+ "_general_comment": "All extensions are version 5 and tests run against toolkitVersion 8",
+ "_general_comment2": "Test 1-14 not listed, should never get blocked",
+
+ "_comment": "Should block for any version of the app",
+ "guid": "test_bug449027_15@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [{ "guid": "toolkit@mozilla.org" }]
+ }
+ ]
+ },
+ {
+ "_comment": "Should still block",
+ "guid": "test_bug449027_16@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [{ "guid": "toolkit@mozilla.org" }]
+ }
+ ]
+ },
+ {
+ "_comment": "Not blocked since neither version range matches",
+ "guid": "test_bug449027_17@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "minVersion": "9",
+ "guid": "toolkit@mozilla.org"
+ },
+ {
+ "maxVersion": "7",
+ "guid": "toolkit@mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Invalid version range, should not block",
+ "guid": "test_bug449027_18@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "minVersion": "11",
+ "maxVersion": "9",
+ "guid": "toolkit@mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Should block all of the following",
+ "guid": "test_bug449027_19@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "10",
+ "maxVersion": "11"
+ },
+ {
+ "minVersion": "8",
+ "maxVersion": "9"
+ },
+ {
+ "maxVersion": "7"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_20@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "toolkit@mozilla.org",
+ "maxVersion": "7"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "7",
+ "maxVersion": "8"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "9",
+ "maxVersion": "10"
+ },
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_21@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "6",
+ "maxVersion": "6"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "7",
+ "maxVersion": "9"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "10",
+ "maxVersion": "11"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_22@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "8"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_23@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "7"
+ },
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_24@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "maxVersion": "8",
+ "guid": "toolkit@mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_25@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "toolkit@mozilla.org",
+ "maxVersion": "9"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit-plugins.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit-plugins.json
new file mode 100644
index 0000000000..c3565d2073
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit-plugins.json
@@ -0,0 +1,189 @@
+[
+ {
+ "_general_comment": "All plugins are version 5 and tests run against appVersion 3",
+ "_general_comment2": "Test 1-14 not listed, should never get blocked",
+
+ "_comment": "Should block for any version of the app",
+ "matchName": "^test_bug449027_15$",
+ "versionRange": [
+ {
+ "targetApplication": [{ "guid": "toolkit@mozilla.org" }]
+ }
+ ]
+ },
+ {
+ "_comment": "Should still block",
+ "matchName": "^test_bug449027_16$",
+ "versionRange": [
+ {
+ "targetApplication": [{ "guid": "toolkit@mozilla.org" }]
+ }
+ ]
+ },
+ {
+ "_comment": "Not blocked since neither version range matches",
+ "matchName": "^test_bug449027_17$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "minVersion": "9",
+ "guid": "toolkit@mozilla.org"
+ },
+ {
+ "maxVersion": "7",
+ "guid": "toolkit@mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Invalid version range, should not block",
+ "matchName": "^test_bug449027_18$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "minVersion": "11",
+ "maxVersion": "9",
+ "guid": "toolkit@mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Should block all of the following",
+ "matchName": "^test_bug449027_19$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "10",
+ "maxVersion": "11"
+ },
+ {
+ "minVersion": "8",
+ "maxVersion": "9"
+ },
+ {
+ "maxVersion": "7"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_20$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "toolkit@mozilla.org",
+ "maxVersion": "7"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "7",
+ "maxVersion": "8"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "9",
+ "maxVersion": "10"
+ },
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_21$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "6",
+ "maxVersion": "6"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "7",
+ "maxVersion": "9"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "10",
+ "maxVersion": "11"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_22$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "8"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_23$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "7"
+ },
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_24$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "maxVersion": "8",
+ "guid": "toolkit@mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_25$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "toolkit@mozilla.org",
+ "maxVersion": "9"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit.xml
new file mode 100644
index 0000000000..ad8ec5ed9d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit.xml
@@ -0,0 +1,208 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <!-- All extensions are version 5 and tests run against toolkitVersion 8 -->
+
+ <!-- Test 1-14 not listed, should never get blocked -->
+
+ <!-- Should block for any version of the app -->
+ <emItem id="test_bug449027_15@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org"/>
+ </versionRange>
+ </emItem>
+ <!-- Should still block -->
+ <emItem id="test_bug449027_16@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <!-- Not blocked since neither version range matches -->
+ <emItem id="test_bug449027_17@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="9"/>
+ <versionRange maxVersion="7"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <!-- Invalid version range, should not block -->
+ <emItem id="test_bug449027_18@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="11" maxVersion="9"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <!-- Should block all of these -->
+ <emItem id="test_bug449027_19@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="10" maxVersion="11"/>
+ <versionRange minVersion="8" maxVersion="9"/>
+ <versionRange maxVersion="7"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_20@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange maxVersion="7"/>
+ <versionRange minVersion="7" maxVersion="8"/>
+ <versionRange minVersion="9" maxVersion="10"/>
+ </targetApplication>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_21@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="6" maxVersion="6"/>
+ <versionRange minVersion="7" maxVersion="9"/>
+ <versionRange minVersion="10" maxVersion="11"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_22@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="8"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_23@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="7"/>
+ </targetApplication>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_24@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange maxVersion="8"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_25@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange maxVersion="9"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ </emItems>
+ <pluginItems>
+ <!-- All plugins are version 5 and tests run against appVersion 3 -->
+
+ <!-- Test 1-14 not listed, should never get blocked -->
+ <!-- Should block for any version of the app -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_15$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org"/>
+ </versionRange>
+ </pluginItem>
+ <!-- Should still block -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_16$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <!-- Not blocked since neither version range matches -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_17$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="9"/>
+ <versionRange maxVersion="7"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <!-- Invalid version range, should not block -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_18$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="11" maxVersion="9"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <!-- Should block all of these -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_19$"/>
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="10" maxVersion="11"/>
+ <versionRange minVersion="8" maxVersion="9"/>
+ <versionRange maxVersion="7"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_20$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange maxVersion="7"/>
+ <versionRange minVersion="7" maxVersion="8"/>
+ <versionRange minVersion="9" maxVersion="10"/>
+ </targetApplication>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_21$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="6" maxVersion="6"/>
+ <versionRange minVersion="7" maxVersion="9"/>
+ <versionRange minVersion="10" maxVersion="11"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_22$"/>
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="8"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_23$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="7"/>
+ </targetApplication>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_24$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange maxVersion="8"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_25$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange maxVersion="9"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug468528.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug468528.xml
new file mode 100644
index 0000000000..85f0da57ce
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug468528.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="^test_bug468528_1"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug468528_2["/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug468528_3"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_1.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_1.xml
new file mode 100644
index 0000000000..c4cc2fe37a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_1.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="^test_bug514327_1"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug514327_2"/>
+ <versionRange severity="0"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug514327_3"/>
+ <versionRange severity="0"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_2.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_2.xml
new file mode 100644
index 0000000000..cc0a0c69df
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_2.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="Test Plug-in"/>
+ <versionRange severity="0"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_empty.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_empty.xml
new file mode 100644
index 0000000000..0261794f8a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_empty.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_outdated_1.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_outdated_1.xml
new file mode 100644
index 0000000000..d651f87996
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_outdated_1.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="test_bug514327_1"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="test_bug514327_outdated"/>
+ <versionRange severity="0"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_outdated_2.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_outdated_2.xml
new file mode 100644
index 0000000000..208444681e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_outdated_2.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="test_bug514327_2"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="test_bug514327_outdated"/>
+ <versionRange severity="0"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug655254.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug655254.json
new file mode 100644
index 0000000000..3b1dd81dab
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug655254.json
@@ -0,0 +1,17 @@
+{
+ "addons": {
+ "addon1@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "2"
+ }
+ },
+ "version": "1"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_corrupt.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_corrupt.json
new file mode 100644
index 0000000000..7cb48d4798
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_corrupt.json
@@ -0,0 +1,30 @@
+{
+ "addons": {
+ "addon3@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "2"
+ }
+ },
+ "version": "1.0"
+ }
+ ]
+ },
+ "addon4@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "2"
+ }
+ },
+ "version": "1.0"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete.json
new file mode 100644
index 0000000000..b79dc236c3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete.json
@@ -0,0 +1,12 @@
+{
+ "addons": {
+ "test_delay_update_complete_webext@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_delay_update_complete_webextension_v2.xpi"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete_legacy.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete_legacy.json
new file mode 100644
index 0000000000..125d1b1a91
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete_legacy.json
@@ -0,0 +1,18 @@
+{
+ "addons": {
+ "test_delay_update_complete@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "1"
+ }
+ },
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_delay_update_complete_v2.xpi"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer.json
new file mode 100644
index 0000000000..c2ea01e8c5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer.json
@@ -0,0 +1,12 @@
+{
+ "addons": {
+ "test_delay_update_defer_webext@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_delay_update_defer_webextension_v2.xpi"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer_legacy.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer_legacy.json
new file mode 100644
index 0000000000..d434fe2e17
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer_legacy.json
@@ -0,0 +1,18 @@
+{
+ "addons": {
+ "test_delay_update_defer@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "1"
+ }
+ },
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_delay_update_defer_v2.xpi"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore.json
new file mode 100644
index 0000000000..5d5dc262cb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore.json
@@ -0,0 +1,12 @@
+{
+ "addons": {
+ "test_delay_update_ignore_webext@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_delay_update_ignore_webextension_v2.xpi"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore_legacy.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore_legacy.json
new file mode 100644
index 0000000000..bc46fab8fd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore_legacy.json
@@ -0,0 +1,18 @@
+{
+ "addons": {
+ "test_delay_update_ignore@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "1"
+ }
+ },
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_delay_update_ignore_v2.xpi"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_staged.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_staged.json
new file mode 100644
index 0000000000..e0611edb35
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_staged.json
@@ -0,0 +1,32 @@
+{
+ "addons": {
+ "test_delay_update_staged_webext@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_delay_update_staged_webextension_v2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "43"
+ }
+ }
+ }
+ ]
+ },
+ "test_delay_update_staged_webext_no_update_url@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_delay_update_staged_webextension_no_update_url_v2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "43"
+ }
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist.json
new file mode 100644
index 0000000000..6f5d61288d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist.json
@@ -0,0 +1,377 @@
+[
+ {
+ "blockID": "g35",
+ "os": "WINNT 6.1",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.2202 ",
+ "driverVersionComparator": " LESS_THAN "
+ },
+ {
+ "os": "WINNT 6.0",
+ "vendor": "0xdcba",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_9_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.2202 ",
+ "driverVersionComparator": " LESS_THAN "
+ },
+ {
+ "blockID": "g36",
+ "os": "WINNT 6.1",
+ "vendor": "0xabab",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.2202 ",
+ "driverVersionComparator": " GREATER_THAN_OR_EQUAL "
+ },
+ {
+ "os": "WINNT 6.1",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1111 ",
+ "driverVersionComparator": " EQUAL "
+ },
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+ {
+ "os": "Android",
+ "vendor": "abcd",
+ "devices": ["wxyz", "asdf", "erty"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionComparator": " LESS_THAN_OR_EQUAL "
+ },
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop", "vbnm", "hjkl"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionComparator": " EQUAL "
+ },
+ {
+ "os": "Android",
+ "vendor": "abab",
+ "devices": ["ghjk", "cvbn"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 7 ",
+ "driverVersionComparator": " GREATER_THAN_OR_EQUAL "
+ },
+ {
+ "os": "WINNT 6.1",
+ "vendor": "0xabcd",
+ "devices": ["0x6666"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DEVICE "
+ },
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "devices": ["0x6666"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DEVICE "
+ },
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "devices": ["0x6666"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DEVICE "
+ },
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "devices": ["0x6666"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DEVICE "
+ },
+ {
+ "os": "WINNT 6.1",
+ "vendor": "0xabcd",
+ "devices": ["0x6666"],
+ "feature": " WEBRENDER ",
+ "featureStatus": " BLOCKED_DEVICE "
+ },
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "devices": ["0x6666"],
+ "feature": " WEBRENDER ",
+ "featureStatus": " BLOCKED_DEVICE "
+ },
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "devices": ["0x6666"],
+ "feature": " WEBRENDER ",
+ "featureStatus": " BLOCKED_DEVICE "
+ },
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "devices": ["0x6666"],
+ "feature": " WEBRENDER ",
+ "featureStatus": " BLOCKED_DEVICE "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1112 ",
+ "driverVersionMax": " 8.52.323.1000 ",
+ "driverVersionComparator": " BETWEEN_EXCLUSIVE "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " OPENGL_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.50.322.1000 ",
+ "driverVersionMax": " 8.52.322.1112 ",
+ "driverVersionComparator": " BETWEEN_EXCLUSIVE "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1000 ",
+ "driverVersionMax": " 9.52.322.1000 ",
+ "driverVersionComparator": " BETWEEN_EXCLUSIVE "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " HARDWARE_VIDEO_DECODING ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 7.82.322.1000 ",
+ "driverVersionMax": " 9.25.322.1001 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_H264 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1112 ",
+ "driverVersionMax": " 9.52.322.1300 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_DECODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1000 ",
+ "driverVersionMax": " 8.52.322.1112 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_ENCODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1112 ",
+ "driverVersionMax": " 8.52.322.1200 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE_START "
+ },
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_MSAA ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1000 ",
+ "driverVersionMax": " 8.52.322.1200 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE_START "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1000 ",
+ "driverVersionMax": " 8.52.322.1112 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE_START "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL2 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1000 ",
+ "driverVersionMax": " 8.52.322.1112 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE_START "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " CANVAS2D_ACCELERATION ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1000 ",
+ "driverVersionMax": " 9.52.322.1000 ",
+ "driverVersionComparator": " BETWEEN_EXCLUSIVE "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " DIRECT3D_11_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionMax": " 6 ",
+ "driverVersionComparator": " BETWEEN_EXCLUSIVE "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " OPENGL_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 6 ",
+ "driverVersionMax": " 7 ",
+ "driverVersionComparator": " BETWEEN_EXCLUSIVE "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " DIRECT3D_11_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionMax": " 7 ",
+ "driverVersionComparator": " BETWEEN_EXCLUSIVE "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " HARDWARE_VIDEO_DECODING ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionMax": " 7 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " WEBRTC_HW_ACCELERATION_H264 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 6 ",
+ "driverVersionMax": " 7 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " WEBRTC_HW_ACCELERATION_DECODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionMax": " 6 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " WEBRTC_HW_ACCELERATION_ENCODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 6 ",
+ "driverVersionMax": " 7 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE_START "
+ },
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " WEBGL_MSAA ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionMax": " 7 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE_START "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " WEBGL_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionMax": " 6 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE_START "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " WEBGL2 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionMax": " 6 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE_START "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " CANVAS2D_ACCELERATION ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionMax": " 7 ",
+ "driverVersionComparator": " BETWEEN_EXCLUSIVE "
+ }
+]
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist_AllOS.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist_AllOS.json
new file mode 100755
index 0000000000..3f44eb330f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist_AllOS.json
@@ -0,0 +1,581 @@
+[
+ {
+ "blockID": "g1",
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g2",
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "22.0a1" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_9_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "16.0a1" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_10_LAYERS",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "16.0a1", "maxVersion": "22.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_10_1_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "12.0", "maxVersion": "16.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " OPENGL_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g11",
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "14.0b2", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_OPENGL ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL2 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "12.0", "maxVersion": "16.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_MSAA ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "maxVersion": "13.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " STAGEFRIGHT ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_H264 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_ENCODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_DECODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "17.2a2", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "13.2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " HARDWARE_VIDEO_DECODING ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "10.5", "maxVersion": "13.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g1",
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g2",
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "22.0a1" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_9_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "16.0a1" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_10_LAYERS",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "16.0a1", "maxVersion": "22.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_10_1_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "12.0", "maxVersion": "16.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " OPENGL_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g11",
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "14.0b2", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_OPENGL ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL2 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "12.0", "maxVersion": "16.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_MSAA ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "maxVersion": "13.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " STAGEFRIGHT ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_H264 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_ENCODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_DECODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "17.2a2", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "13.2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " HARDWARE_VIDEO_DECODING ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "10.5", "maxVersion": "13.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g1",
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g2",
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "22.0a1" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_9_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "16.0a1" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_10_LAYERS",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "16.0a1", "maxVersion": "22.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_10_1_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "12.0", "maxVersion": "16.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " OPENGL_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g11",
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "14.0b2", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_OPENGL ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL2 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "12.0", "maxVersion": "16.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_MSAA ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "maxVersion": "13.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " STAGEFRIGHT ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_H264 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_ENCODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_DECODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "17.2a2", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "13.2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " HARDWARE_VIDEO_DECODING ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "10.5", "maxVersion": "13.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g1",
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g2",
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "22.0a1" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_9_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "16.0a1" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_10_LAYERS",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "16.0a1", "maxVersion": "22.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_10_1_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "12.0", "maxVersion": "16.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " OPENGL_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g11",
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "14.0b2", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_OPENGL ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL2 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "12.0", "maxVersion": "16.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_MSAA ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "maxVersion": "13.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " STAGEFRIGHT ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_H264 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_ENCODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_DECODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "17.2a2", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "13.2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " HARDWARE_VIDEO_DECODING ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "10.5", "maxVersion": "13.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ }
+]
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist_OSVersion.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist_OSVersion.json
new file mode 100644
index 0000000000..c80bf3eedd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist_OSVersion.json
@@ -0,0 +1,20 @@
+[
+ {
+ "os": "WINNT 6.2",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.2202 ",
+ "driverVersionComparator": " LESS_THAN "
+ },
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " OPENGL_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.2202 ",
+ "driverVersionComparator": " LESS_THAN "
+ }
+]
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_install_addons.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_install_addons.json
new file mode 100644
index 0000000000..d7307831af
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_install_addons.json
@@ -0,0 +1,31 @@
+{
+ "page_size": 25,
+ "page_count": 1,
+ "count": 1,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "name": "Real Test 2",
+ "type": "extension",
+ "guid": "addon2@tests.mozilla.org",
+ "current_version": {
+ "version": "1.0",
+ "files": [
+ {
+ "size": 2,
+ "url": "http://example.com/browser/toolkit/mozapps/extensions/test/browser/addons/browser_install1_2.xpi"
+ }
+ ]
+ },
+ "authors": [
+ {
+ "name": "Test Creator",
+ "url": "http://example.com/creator.html"
+ }
+ ],
+ "summary": "Repository summary",
+ "description": "Repository description"
+ }
+ ]
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_install_compat.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_install_compat.json
new file mode 100644
index 0000000000..93d0cf3d3d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_install_compat.json
@@ -0,0 +1,27 @@
+{
+ "page_size": 25,
+ "page_count": 1,
+ "count": 1,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "addon_guid": "addon6@tests.mozilla.org",
+ "name": "Addon Test 6",
+ "version_ranges": [
+ {
+ "addon_min_version": "1.0",
+ "addon_max_version": "1.0",
+ "applications": [
+ {
+ "name": "XPCShell",
+ "guid": "xpcshell@tests.mozilla.org",
+ "min_version": "1.0",
+ "max_version": "1.0"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_no_update.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_no_update.json
new file mode 100644
index 0000000000..2773c7f98f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_no_update.json
@@ -0,0 +1,7 @@
+{
+ "addons": {
+ "test_no_update_webext@tests.mozilla.org": {
+ "updates": []
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/ancient.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/ancient.xml
new file mode 100644
index 0000000000..699257f87e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/ancient.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0"?>
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem blockID="i454" id="ancient@tests.mozilla.org">
+ <versionRange minVersion="0" maxVersion="*" severity="3"/>
+ </emItem>
+ </emItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/new.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/new.xml
new file mode 100644
index 0000000000..8cbfb5d6a0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/new.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0"?>
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist" lastupdate="1396046918000">
+ <emItems>
+ <emItem blockID="i454" id="new@tests.mozilla.org">
+ <versionRange minVersion="0" maxVersion="*" severity="3"/>
+ </emItem>
+ </emItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/old.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/old.xml
new file mode 100644
index 0000000000..75bd6e934c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/old.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0"?>
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist" lastupdate="1296046918000">
+ <emItems>
+ <emItem blockID="i454" id="old@tests.mozilla.org">
+ <versionRange minVersion="0" maxVersion="*" severity="3"/>
+ </emItem>
+ </emItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_pluginBlocklistCtp.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_pluginBlocklistCtp.xml
new file mode 100644
index 0000000000..937d8a5901
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_pluginBlocklistCtp.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="^test_plugin_0"/>
+ <versionRange minVersion="0" maxVersion="*" severity="0"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_plugin_1"/>
+ <versionRange minVersion="0" maxVersion="*" severity="0"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_plugin_2"/>
+ <versionRange minVersion="0" maxVersion="*" severity="0"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_plugin_3"/>
+ <versionRange minVersion="0" maxVersion="*"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_plugin_4"/>
+ <versionRange minVersion="0" maxVersion="*" severity="1"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_pluginBlocklistCtpUndo.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_pluginBlocklistCtpUndo.xml
new file mode 100644
index 0000000000..162876230e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_pluginBlocklistCtpUndo.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="^Test Plug-in"/>
+ <versionRange minVersion="0" maxVersion="*" severity="0"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_softblocked1.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_softblocked1.xml
new file mode 100644
index 0000000000..a1d18470c8
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_softblocked1.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem id="softblock1@tests.mozilla.org">
+ <versionRange severity="1"/>
+ </emItem>
+ </emItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_trash_directory.worker.js b/toolkit/mozapps/extensions/test/xpcshell/data/test_trash_directory.worker.js
new file mode 100644
index 0000000000..8963bd21e4
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_trash_directory.worker.js
@@ -0,0 +1,42 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/. */
+
+/* eslint-env mozilla/chrome-worker */
+
+/* import-globals-from /toolkit/components/workerloader/require.js */
+importScripts("resource://gre/modules/workers/require.js");
+
+const PromiseWorker = require("resource://gre/modules/workers/PromiseWorker.js");
+
+class OpenFileWorker extends PromiseWorker.AbstractWorker {
+ constructor() {
+ super();
+
+ this._file = null;
+ }
+
+ postMessage(message, ...transfers) {
+ self.postMessage(message, transfers);
+ }
+
+ dispatch(method, args) {
+ return this[method](...args);
+ }
+
+ open(path) {
+ this._file = IOUtils.openFileForSyncReading(path);
+ }
+
+ close() {
+ if (this._file) {
+ this._file.close();
+ }
+ }
+}
+
+const worker = new OpenFileWorker();
+
+self.addEventListener("message", msg => worker.handleMessage(msg));
+self.addEventListener("unhandledrejection", err => {
+ throw err.reason;
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_update.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_update.json
new file mode 100644
index 0000000000..9e3483d520
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_update.json
@@ -0,0 +1,137 @@
+{
+ "addons": {
+ "addon1@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_min_version": "1"
+ }
+ }
+ },
+ {
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "2",
+ "strict_min_version": "2"
+ }
+ }
+ },
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_update.xpi",
+ "update_info_url": "http://example.com/updateInfo.xhtml",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "addon2@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0",
+ "advisory_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "addon2@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0",
+ "advisory_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "addon3@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "3",
+ "advisory_max_version": "3"
+ }
+ }
+ }
+ ]
+ },
+
+ "addon4@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "5.0",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0",
+ "advisory_max_version": "0"
+ }
+ }
+ }
+ ]
+ },
+
+ "addon7@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0",
+ "advisory_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "addon8@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_update8.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "addon12@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_update12.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "1"
+ }
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_update_addons.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_update_addons.json
new file mode 100644
index 0000000000..d9777335a6
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_update_addons.json
@@ -0,0 +1,14 @@
+{
+ "page_size": 25,
+ "page_count": 1,
+ "count": 1,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "name": "Ttest Addon 9",
+ "type": "extension",
+ "guid": "addon9@tests.mozilla.org"
+ }
+ ]
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_update_compat.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_update_compat.json
new file mode 100644
index 0000000000..cc2cc15ad5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_update_compat.json
@@ -0,0 +1,28 @@
+{
+ "page_size": 25,
+ "page_count": 1,
+ "count": 1,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "addon_guid": "addon9@tests.mozilla.org",
+ "name": "Test Addon 9",
+ "version_ranges": [
+ {
+ "addon_min_version": "4",
+ "addon_max_version": "4",
+ "applications": [
+ {
+ "name": "XPCShell",
+ "id": "XPCShell",
+ "guid": "xpcshell@tests.mozilla.org",
+ "min_version": "1",
+ "max_version": "1"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_updatecheck.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_updatecheck.json
new file mode 100644
index 0000000000..f61bfeacd3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_updatecheck.json
@@ -0,0 +1,269 @@
+{
+ "addons": {
+ "updatecheck1@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "1.0",
+ "update_link": "https://example.com/addons/test1.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "1"
+ }
+ }
+ },
+ {
+ "_comment_": "This update is incompatible and so should not be considered a valid update",
+ "version": "2.0",
+ "update_link": "https://example.com/addons/test2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "2",
+ "strict_max_version": "2"
+ }
+ }
+ },
+ {
+ "version": "3.0",
+ "update_link": "https://example.com/addons/test3.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "1"
+ }
+ }
+ },
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/addons/test2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "2"
+ }
+ }
+ },
+ {
+ "_comment_": "This update is incompatible and so should not be considered a valid update",
+ "version": "4.0",
+ "update_link": "https://example.com/addons/test4.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "2",
+ "strict_max_version": "2"
+ }
+ }
+ }
+ ]
+ },
+
+ "test_bug378216_8@tests.mozilla.org": {
+ "_comment_": "The updateLink will be ignored since it is not secure and there is no updateHash.",
+
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/broken.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "test_bug378216_9@tests.mozilla.org": {
+ "_comment_": "The updateLink will used since there is an updateHash to verify it.",
+
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/broken.xpi",
+ "update_hash": "sha256:78fc1d2887eda35b4ad2e3a0b60120ca271ce6e6",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "test_bug378216_10@tests.mozilla.org": {
+ "_comment_": "The updateLink will used since it is a secure URL.",
+
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/broken.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "test_bug378216_11@tests.mozilla.org": {
+ "_comment_": "The updateLink will used since it is a secure URL.",
+
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/broken.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "test_bug378216_12@tests.mozilla.org": {
+ "_comment_": "The updateLink will not be used since the updateHash verifying it is not strong enough.",
+
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/broken.xpi",
+ "update_hash": "sha1:78fc1d2887eda35b4ad2e3a0b60120ca271ce6e6",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "test_bug378216_13@tests.mozilla.org": {
+ "_comment_": "An update with a weak hash. The updateLink will used since it is a secure URL.",
+
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/broken.xpi",
+ "update_hash": "sha1:78fc1d2887eda35b4ad2e3a0b60120ca271ce6e6",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "_comment_": "There should be no information present for test_bug378216_14",
+
+ "test_bug378216_15@tests.mozilla.org": {
+ "_comment_": "Invalid update JSON",
+
+ "updates": "foo"
+ },
+
+ "ignore-compat@tests.mozilla.org": {
+ "_comment_": "Various updates available - one is not compatible, but compatibility checking is disabled",
+
+ "updates": [
+ {
+ "version": "1.0",
+ "update_link": "https://example.com/addons/test1.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0.1",
+ "advisory_max_version": "0.2"
+ }
+ }
+ },
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/addons/test2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0.5",
+ "advisory_max_version": "0.6"
+ }
+ }
+ },
+ {
+ "_comment_": "Update for future app versions - should never be compatible",
+ "version": "3.0",
+ "update_link": "https://example.com/addons/test3.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "2",
+ "advisory_max_version": "3"
+ }
+ }
+ }
+ ]
+ },
+
+ "compat-override@tests.mozilla.org": {
+ "_comment_": "Various updates available - one is not compatible, but compatibility checking is disabled",
+
+ "updates": [
+ {
+ "_comment_": "Has compatibility override, but it doesn't match this app version",
+ "version": "1.0",
+ "update_link": "https://example.com/addons/test1.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0.1",
+ "advisory_max_version": "0.2"
+ }
+ }
+ },
+ {
+ "_comment_": "Has compatibility override, so is incompaible",
+ "version": "2.0",
+ "update_link": "https://example.com/addons/test2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0.5",
+ "advisory_max_version": "0.6"
+ }
+ }
+ },
+ {
+ "_comment_": "Update for future app versions - should never be compatible",
+ "version": "3.0",
+ "update_link": "https://example.com/addons/test3.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "2",
+ "advisory_max_version": "3"
+ }
+ }
+ }
+ ]
+ },
+
+ "compat-strict-optin@tests.mozilla.org": {
+ "_comment_": "Opt-in to strict compatibility checking",
+
+ "updates": [
+ {
+ "version": "1.0",
+ "update_link": "https://example.com/addons/test1.xpi",
+ "_comment_": "strictCompatibility: true",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0.1",
+ "strict_max_version": "0.2"
+ }
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/unsigned.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/unsigned.xpi
new file mode 100644
index 0000000000..12a13f139b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/unsigned.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/webext-implicit-id.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/webext-implicit-id.xpi
new file mode 100644
index 0000000000..6b4abaa691
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/webext-implicit-id.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/head_addons.js b/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
new file mode 100644
index 0000000000..626d9dea36
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
@@ -0,0 +1,1226 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* eslint no-unused-vars: ["error", {vars: "local", args: "none"}] */
+
+if (!_TEST_NAME.includes("toolkit/mozapps/extensions/test/xpcshell/")) {
+ Assert.ok(
+ false,
+ "head_addons.js may not be loaded by tests outside of " +
+ "the add-on manager component."
+ );
+}
+
+const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity";
+const PREF_EM_STRICT_COMPATIBILITY = "extensions.strictCompatibility";
+const PREF_GETADDONS_BYIDS = "extensions.getAddons.get.url";
+const PREF_XPI_SIGNATURES_REQUIRED = "xpinstall.signatures.required";
+
+// Maximum error in file modification times. Some file systems don't store
+// modification times exactly. As long as we are closer than this then it
+// still passes.
+const MAX_TIME_DIFFERENCE = 3000;
+
+// Time to reset file modified time relative to Date.now() so we can test that
+// times are modified (10 hours old).
+const MAKE_FILE_OLD_DIFFERENCE = 10 * 3600 * 1000;
+
+const { AddonManager, AddonManagerPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { AddonRepository } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonRepository.sys.mjs"
+);
+
+var { AddonTestUtils, MockAsyncShutdown } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "HttpServer",
+ "resource://testing-common/httpd.js"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ Blocklist: "resource://gre/modules/Blocklist.sys.mjs",
+ Extension: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs",
+ ExtensionTestUtils:
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs",
+ MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs",
+ MockRegistry: "resource://testing-common/MockRegistry.sys.mjs",
+ PromiseTestUtils: "resource://testing-common/PromiseTestUtils.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "aomStartup",
+ "@mozilla.org/addons/addon-manager-startup;1",
+ "amIAddonManagerStartup"
+);
+
+const {
+ createAppInfo,
+ createHttpServer,
+ createTempWebExtensionFile,
+ getFileForAddon,
+ manuallyInstall,
+ manuallyUninstall,
+ overrideBuiltIns,
+ promiseAddonEvent,
+ promiseCompleteAllInstalls,
+ promiseCompleteInstall,
+ promiseConsoleOutput,
+ promiseFindAddonUpdates,
+ promiseInstallAllFiles,
+ promiseInstallFile,
+ promiseRestartManager,
+ promiseSetExtensionModifiedTime,
+ promiseShutdownManager,
+ promiseStartupManager,
+ promiseWebExtensionStartup,
+ promiseWriteProxyFileToDir,
+ registerDirectory,
+ setExtensionModifiedTime,
+ writeFilesToZip,
+} = AddonTestUtils;
+
+// WebExtension wrapper for ease of testing
+ExtensionTestUtils.init(this);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+XPCOMUtils.defineLazyGetter(
+ this,
+ "BOOTSTRAP_REASONS",
+ () => AddonManagerPrivate.BOOTSTRAP_REASONS
+);
+
+function getReasonName(reason) {
+ for (let key of Object.keys(BOOTSTRAP_REASONS)) {
+ if (BOOTSTRAP_REASONS[key] == reason) {
+ return key;
+ }
+ }
+ throw new Error("This shouldn't happen.");
+}
+
+Object.defineProperty(this, "gAppInfo", {
+ get() {
+ return AddonTestUtils.appInfo;
+ },
+});
+
+Object.defineProperty(this, "gAddonStartup", {
+ get() {
+ return AddonTestUtils.addonStartup.clone();
+ },
+});
+
+Object.defineProperty(this, "gInternalManager", {
+ get() {
+ return AddonTestUtils.addonIntegrationService.QueryInterface(
+ Ci.nsITimerCallback
+ );
+ },
+});
+
+Object.defineProperty(this, "gProfD", {
+ get() {
+ return AddonTestUtils.profileDir.clone();
+ },
+});
+
+Object.defineProperty(this, "gTmpD", {
+ get() {
+ return AddonTestUtils.tempDir.clone();
+ },
+});
+
+Object.defineProperty(this, "gUseRealCertChecks", {
+ get() {
+ return AddonTestUtils.useRealCertChecks;
+ },
+ set(val) {
+ AddonTestUtils.useRealCertChecks = val;
+ },
+});
+
+Object.defineProperty(this, "TEST_UNPACKED", {
+ get() {
+ return AddonTestUtils.testUnpacked;
+ },
+ set(val) {
+ AddonTestUtils.testUnpacked = val;
+ },
+});
+
+const promiseAddonByID = AddonManager.getAddonByID;
+const promiseAddonsByIDs = AddonManager.getAddonsByIDs;
+const promiseAddonsByTypes = AddonManager.getAddonsByTypes;
+
+var gPort = null;
+
+var BootstrapMonitor = {
+ started: new Map(),
+ stopped: new Map(),
+ installed: new Map(),
+ uninstalled: new Map(),
+
+ init() {
+ this.onEvent = this.onEvent.bind(this);
+
+ AddonTestUtils.on("addon-manager-shutdown", this.onEvent);
+ AddonTestUtils.on("bootstrap-method", this.onEvent);
+ },
+
+ shutdownCheck() {
+ equal(
+ this.started.size,
+ 0,
+ "Should have no add-ons that were started but not shutdown"
+ );
+ },
+
+ onEvent(msg, data) {
+ switch (msg) {
+ case "addon-manager-shutdown":
+ this.shutdownCheck();
+ break;
+ case "bootstrap-method":
+ this.onBootstrapMethod(data.method, data.params, data.reason);
+ break;
+ }
+ },
+
+ onBootstrapMethod(method, params, reason) {
+ let { id } = params;
+
+ info(
+ `Bootstrap method ${method} ${reason} for ${params.id} version ${params.version}`
+ );
+
+ if (method !== "install") {
+ this.checkInstalled(id);
+ }
+
+ switch (method) {
+ case "install":
+ this.checkNotInstalled(id);
+ this.installed.set(id, { reason, params });
+ this.uninstalled.delete(id);
+ break;
+ case "startup":
+ this.checkNotStarted(id);
+ this.started.set(id, { reason, params });
+ this.stopped.delete(id);
+ break;
+ case "shutdown":
+ this.checkMatches("shutdown", "startup", params, this.started.get(id));
+ this.checkStarted(id);
+ this.stopped.set(id, { reason, params });
+ this.started.delete(id);
+ break;
+ case "uninstall":
+ this.checkMatches(
+ "uninstall",
+ "install",
+ params,
+ this.installed.get(id)
+ );
+ this.uninstalled.set(id, { reason, params });
+ this.installed.delete(id);
+ break;
+ case "update":
+ this.checkMatches("update", "install", params, this.installed.get(id));
+ this.installed.set(id, { reason, params, method });
+ break;
+ }
+ },
+
+ clear(id) {
+ this.installed.delete(id);
+ this.started.delete(id);
+ this.stopped.delete(id);
+ this.uninstalled.delete(id);
+ },
+
+ checkMatches(method, lastMethod, params, { params: lastParams } = {}) {
+ ok(
+ lastParams,
+ `Expecting matching ${lastMethod} call for add-on ${params.id} ${method} call`
+ );
+
+ if (method == "update") {
+ equal(
+ params.oldVersion,
+ lastParams.version,
+ "params.oldVersion should match last call"
+ );
+ } else {
+ equal(
+ params.version,
+ lastParams.version,
+ "params.version should match last call"
+ );
+ }
+
+ if (method !== "update" && method !== "uninstall") {
+ equal(
+ params.resourceURI.spec,
+ lastParams.resourceURI.spec,
+ `params.resourceURI should match last call`
+ );
+
+ ok(
+ params.resourceURI.equals(lastParams.resourceURI),
+ `params.resourceURI should match: "${params.resourceURI.spec}" == "${lastParams.resourceURI.spec}"`
+ );
+ }
+ },
+
+ checkStarted(id, version = undefined) {
+ let started = this.started.get(id);
+ ok(started, `Should have seen startup method call for ${id}`);
+
+ if (version !== undefined) {
+ equal(started.params.version, version, "Expected version number");
+ }
+ return started;
+ },
+
+ checkNotStarted(id) {
+ ok(
+ !this.started.has(id),
+ `Should not have seen startup method call for ${id}`
+ );
+ },
+
+ checkInstalled(id, version = undefined) {
+ const installed = this.installed.get(id);
+ ok(installed, `Should have seen install call for ${id}`);
+
+ if (version !== undefined) {
+ equal(installed.params.version, version, "Expected version number");
+ }
+
+ return installed;
+ },
+
+ checkUpdated(id, version = undefined) {
+ const installed = this.installed.get(id);
+ equal(installed.method, "update", `Should have seen update call for ${id}`);
+
+ if (version !== undefined) {
+ equal(installed.params.version, version, "Expected version number");
+ }
+
+ return installed;
+ },
+
+ checkNotInstalled(id) {
+ ok(
+ !this.installed.has(id),
+ `Should not have seen install method call for ${id}`
+ );
+ },
+};
+
+function isNightlyChannel() {
+ var channel = Services.prefs.getCharPref("app.update.channel", "default");
+
+ return (
+ channel != "aurora" &&
+ channel != "beta" &&
+ channel != "release" &&
+ channel != "esr"
+ );
+}
+
+async function restartWithLocales(locales) {
+ Services.locale.requestedLocales = locales;
+ await promiseRestartManager();
+}
+
+function delay(msec) {
+ return new Promise(resolve => {
+ setTimeout(resolve, msec);
+ });
+}
+
+/**
+ * Returns a map of Addon objects for installed add-ons with the given
+ * IDs. The returned map contains a key for the ID of each add-on that
+ * is found. IDs for add-ons which do not exist are not present in the
+ * map.
+ *
+ * @param {sequence<string>} ids
+ * The list of add-on IDs to get.
+ * @returns {Promise<string, Addon>}
+ * Map of add-ons that were found.
+ */
+async function getAddons(ids) {
+ let addons = new Map();
+ for (let addon of await AddonManager.getAddonsByIDs(ids)) {
+ if (addon) {
+ addons.set(addon.id, addon);
+ }
+ }
+ return addons;
+}
+
+/**
+ * Checks that the given add-on has the given expected properties.
+ *
+ * @param {string} id
+ * The id of the add-on.
+ * @param {Addon?} addon
+ * The add-on object, or null if the add-on does not exist.
+ * @param {object?} expected
+ * An object containing the expected values for properties of the
+ * add-on, or null if the add-on is expected not to exist.
+ */
+function checkAddon(id, addon, expected) {
+ info(`Checking state of addon ${id}`);
+
+ if (expected === null) {
+ ok(!addon, `Addon ${id} should not exist`);
+ } else {
+ ok(addon, `Addon ${id} should exist`);
+ for (let [key, value] of Object.entries(expected)) {
+ if (value instanceof Ci.nsIURI) {
+ equal(
+ addon[key] && addon[key].spec,
+ value.spec,
+ `Expected value of addon.${key}`
+ );
+ } else {
+ deepEqual(addon[key], value, `Expected value of addon.${key}`);
+ }
+ }
+ }
+}
+
+/**
+ * Tests that an add-on does appear in the crash report annotations, if
+ * crash reporting is enabled. The test will fail if the add-on is not in the
+ * annotation.
+ * @param aId
+ * The ID of the add-on
+ * @param aVersion
+ * The version of the add-on
+ */
+function do_check_in_crash_annotation(aId, aVersion) {
+ if (!AppConstants.MOZ_CRASHREPORTER) {
+ return;
+ }
+
+ if (!("Add-ons" in gAppInfo.annotations)) {
+ Assert.ok(false, "Cannot find Add-ons entry in crash annotations");
+ return;
+ }
+
+ let addons = gAppInfo.annotations["Add-ons"].split(",");
+ Assert.ok(
+ addons.includes(
+ `${encodeURIComponent(aId)}:${encodeURIComponent(aVersion)}`
+ )
+ );
+}
+
+/**
+ * Tests that an add-on does not appear in the crash report annotations, if
+ * crash reporting is enabled. The test will fail if the add-on is in the
+ * annotation.
+ * @param aId
+ * The ID of the add-on
+ * @param aVersion
+ * The version of the add-on
+ */
+function do_check_not_in_crash_annotation(aId, aVersion) {
+ if (!AppConstants.MOZ_CRASHREPORTER) {
+ return;
+ }
+
+ if (!("Add-ons" in gAppInfo.annotations)) {
+ Assert.ok(true);
+ return;
+ }
+
+ let addons = gAppInfo.annotations["Add-ons"].split(",");
+ Assert.ok(
+ !addons.includes(
+ `${encodeURIComponent(aId)}:${encodeURIComponent(aVersion)}`
+ )
+ );
+}
+
+function do_get_file_hash(aFile, aAlgorithm) {
+ if (!aAlgorithm) {
+ aAlgorithm = "sha1";
+ }
+
+ let crypto = Cc["@mozilla.org/security/hash;1"].createInstance(
+ Ci.nsICryptoHash
+ );
+ crypto.initWithString(aAlgorithm);
+ let fis = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fis.init(aFile, -1, -1, false);
+ crypto.updateFromStream(fis, aFile.fileSize);
+
+ // return the two-digit hexadecimal code for a byte
+ let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2);
+
+ let binary = crypto.finish(false);
+ let hash = Array.from(binary, c => toHexString(c.charCodeAt(0)));
+ return aAlgorithm + ":" + hash.join("");
+}
+
+/**
+ * Returns an extension uri spec
+ *
+ * @param aProfileDir
+ * The extension install directory
+ * @return a uri spec pointing to the root of the extension
+ */
+function do_get_addon_root_uri(aProfileDir, aId) {
+ let path = aProfileDir.clone();
+ path.append(aId);
+ if (!path.exists()) {
+ path.leafName += ".xpi";
+ return "jar:" + Services.io.newFileURI(path).spec + "!/";
+ }
+ return Services.io.newFileURI(path).spec;
+}
+
+function do_get_expected_addon_name(aId) {
+ if (TEST_UNPACKED) {
+ return aId;
+ }
+ return aId + ".xpi";
+}
+
+/**
+ * Returns the file containing the add-on. For packed add-ons, this is
+ * an XPI file. For unpacked add-ons, it is the add-on's root directory.
+ *
+ * @param {Addon} addon
+ * @returns {nsIFile}
+ */
+function getAddonFile(addon) {
+ let uri = addon.getResourceURI("");
+ if (uri instanceof Ci.nsIJARURI) {
+ uri = uri.JARFile;
+ }
+ return uri.QueryInterface(Ci.nsIFileURL).file;
+}
+
+/**
+ * Check that an array of actual add-ons is the same as an array of
+ * expected add-ons.
+ *
+ * @param aActualAddons
+ * The array of actual add-ons to check.
+ * @param aExpectedAddons
+ * The array of expected add-ons to check against.
+ * @param aProperties
+ * An array of properties to check.
+ */
+function do_check_addons(aActualAddons, aExpectedAddons, aProperties) {
+ Assert.notEqual(aActualAddons, null);
+ Assert.equal(aActualAddons.length, aExpectedAddons.length);
+ for (let i = 0; i < aActualAddons.length; i++) {
+ do_check_addon(aActualAddons[i], aExpectedAddons[i], aProperties);
+ }
+}
+
+/**
+ * Check that the actual add-on is the same as the expected add-on.
+ *
+ * @param aActualAddon
+ * The actual add-on to check.
+ * @param aExpectedAddon
+ * The expected add-on to check against.
+ * @param aProperties
+ * An array of properties to check.
+ */
+function do_check_addon(aActualAddon, aExpectedAddon, aProperties) {
+ Assert.notEqual(aActualAddon, null);
+
+ aProperties.forEach(function (aProperty) {
+ let actualValue = aActualAddon[aProperty];
+ let expectedValue = aExpectedAddon[aProperty];
+
+ // Check that all undefined expected properties are null on actual add-on
+ if (!(aProperty in aExpectedAddon)) {
+ if (actualValue !== undefined && actualValue !== null) {
+ do_throw(
+ "Unexpected defined/non-null property for add-on " +
+ aExpectedAddon.id +
+ " (addon[" +
+ aProperty +
+ "] = " +
+ actualValue.toSource() +
+ ")"
+ );
+ }
+
+ return;
+ } else if (expectedValue && !actualValue) {
+ do_throw(
+ "Missing property for add-on " +
+ aExpectedAddon.id +
+ ": expected addon[" +
+ aProperty +
+ "] = " +
+ expectedValue
+ );
+ return;
+ }
+
+ switch (aProperty) {
+ case "creator":
+ do_check_author(actualValue, expectedValue);
+ break;
+
+ case "developers":
+ Assert.equal(actualValue.length, expectedValue.length);
+ for (let i = 0; i < actualValue.length; i++) {
+ do_check_author(actualValue[i], expectedValue[i]);
+ }
+ break;
+
+ case "screenshots":
+ Assert.equal(actualValue.length, expectedValue.length);
+ for (let i = 0; i < actualValue.length; i++) {
+ do_check_screenshot(actualValue[i], expectedValue[i]);
+ }
+ break;
+
+ case "sourceURI":
+ Assert.equal(actualValue.spec, expectedValue);
+ break;
+
+ case "updateDate":
+ Assert.equal(actualValue.getTime(), expectedValue.getTime());
+ break;
+
+ case "compatibilityOverrides":
+ Assert.equal(actualValue.length, expectedValue.length);
+ for (let i = 0; i < actualValue.length; i++) {
+ do_check_compatibilityoverride(actualValue[i], expectedValue[i]);
+ }
+ break;
+
+ case "icons":
+ do_check_icons(actualValue, expectedValue);
+ break;
+
+ default:
+ if (actualValue !== expectedValue) {
+ do_throw(
+ "Failed for " +
+ aProperty +
+ " for add-on " +
+ aExpectedAddon.id +
+ " (" +
+ actualValue +
+ " === " +
+ expectedValue +
+ ")"
+ );
+ }
+ }
+ });
+}
+
+/**
+ * Check that the actual author is the same as the expected author.
+ *
+ * @param aActual
+ * The actual author to check.
+ * @param aExpected
+ * The expected author to check against.
+ */
+function do_check_author(aActual, aExpected) {
+ Assert.equal(aActual.toString(), aExpected.name);
+ Assert.equal(aActual.name, aExpected.name);
+ Assert.equal(aActual.url, aExpected.url);
+}
+
+/**
+ * Check that the actual screenshot is the same as the expected screenshot.
+ *
+ * @param aActual
+ * The actual screenshot to check.
+ * @param aExpected
+ * The expected screenshot to check against.
+ */
+function do_check_screenshot(aActual, aExpected) {
+ Assert.equal(aActual.toString(), aExpected.url);
+ Assert.equal(aActual.url, aExpected.url);
+ Assert.equal(aActual.width, aExpected.width);
+ Assert.equal(aActual.height, aExpected.height);
+ Assert.equal(aActual.thumbnailURL, aExpected.thumbnailURL);
+ Assert.equal(aActual.thumbnailWidth, aExpected.thumbnailWidth);
+ Assert.equal(aActual.thumbnailHeight, aExpected.thumbnailHeight);
+ Assert.equal(aActual.caption, aExpected.caption);
+}
+
+/**
+ * Check that the actual compatibility override is the same as the expected
+ * compatibility override.
+ *
+ * @param aAction
+ * The actual compatibility override to check.
+ * @param aExpected
+ * The expected compatibility override to check against.
+ */
+function do_check_compatibilityoverride(aActual, aExpected) {
+ Assert.equal(aActual.type, aExpected.type);
+ Assert.equal(aActual.minVersion, aExpected.minVersion);
+ Assert.equal(aActual.maxVersion, aExpected.maxVersion);
+ Assert.equal(aActual.appID, aExpected.appID);
+ Assert.equal(aActual.appMinVersion, aExpected.appMinVersion);
+ Assert.equal(aActual.appMaxVersion, aExpected.appMaxVersion);
+}
+
+function do_check_icons(aActual, aExpected) {
+ for (var size in aExpected) {
+ Assert.equal(aActual[size], aExpected[size]);
+ }
+}
+
+function isThemeInAddonsList(aDir, aId) {
+ return AddonTestUtils.addonsList.hasTheme(aDir, aId);
+}
+
+function isExtensionInBootstrappedList(aDir, aId) {
+ return AddonTestUtils.addonsList.hasExtension(aDir, aId);
+}
+
+/**
+ * Writes a manifest.json manifest into an extension using the properties passed
+ * in a JS object.
+ *
+ * @param aManifest
+ * The data to write
+ * @param aDir
+ * The install directory to add the extension to
+ * @param aId
+ * An optional string to override the default installation aId
+ * @return A file pointing to where the extension was installed
+ */
+function promiseWriteWebManifestForExtension(aData, aDir, aId) {
+ let files = {
+ "manifest.json": JSON.stringify(aData),
+ };
+ if (!aId) {
+ aId =
+ aData?.browser_specific_settings?.gecko?.id ||
+ aData?.applications?.gecko?.id;
+ }
+ return AddonTestUtils.promiseWriteFilesToExtension(aDir.path, aId, files);
+}
+
+function hasFlag(aBits, aFlag) {
+ return (aBits & aFlag) != 0;
+}
+
+class EventChecker {
+ constructor(options) {
+ this.expectedEvents = options.addonEvents || {};
+ this.expectedInstalls = options.installEvents || null;
+ this.ignorePlugins = options.ignorePlugins || false;
+
+ this.finished = new Promise(resolve => {
+ this.resolveFinished = resolve;
+ });
+
+ AddonManager.addAddonListener(this);
+ if (this.expectedInstalls) {
+ AddonManager.addInstallListener(this);
+ }
+ }
+
+ cleanup() {
+ AddonManager.removeAddonListener(this);
+ if (this.expectedInstalls) {
+ AddonManager.removeInstallListener(this);
+ }
+ }
+
+ checkValue(prop, value, flagName) {
+ if (Array.isArray(flagName)) {
+ let names = flagName.map(name => `AddonManager.${name}`);
+
+ Assert.ok(
+ flagName.map(name => AddonManager[name]).includes(value),
+ `${prop} value \`${value}\` should be one of [${names.join(", ")}`
+ );
+ } else {
+ Assert.equal(
+ value,
+ AddonManager[flagName],
+ `${prop} should have value AddonManager.${flagName}`
+ );
+ }
+ }
+
+ checkFlag(prop, value, flagName) {
+ Assert.equal(
+ value & AddonManager[flagName],
+ AddonManager[flagName],
+ `${prop} should have flag AddonManager.${flagName}`
+ );
+ }
+
+ checkNoFlag(prop, value, flagName) {
+ Assert.ok(
+ !(value & AddonManager[flagName]),
+ `${prop} should not have flag AddonManager.${flagName}`
+ );
+ }
+
+ checkComplete() {
+ if (this.expectedInstalls && this.expectedInstalls.length) {
+ return;
+ }
+
+ if (Object.values(this.expectedEvents).some(events => events.length)) {
+ return;
+ }
+
+ info("Test complete");
+ this.cleanup();
+ this.resolveFinished();
+ }
+
+ ensureComplete() {
+ this.cleanup();
+
+ for (let [id, events] of Object.entries(this.expectedEvents)) {
+ Assert.equal(
+ events.length,
+ 0,
+ `Should have no remaining events for ${id}`
+ );
+ }
+ if (this.expectedInstalls) {
+ Assert.deepEqual(
+ this.expectedInstalls,
+ [],
+ "Should have no remaining install events"
+ );
+ }
+ }
+
+ // Add-on listener events
+ getExpectedEvent(aId) {
+ if (!(aId in this.expectedEvents)) {
+ return null;
+ }
+
+ let events = this.expectedEvents[aId];
+ Assert.ok(!!events.length, `Should be expecting events for ${aId}`);
+
+ return events.shift();
+ }
+
+ checkAddonEvent(event, addon, details = {}) {
+ info(`Got event "${event}" for add-on ${addon.id}`);
+
+ if ("requiresRestart" in details) {
+ Assert.equal(
+ details.requiresRestart,
+ false,
+ "requiresRestart should always be false"
+ );
+ }
+
+ let expected = this.getExpectedEvent(addon.id);
+ if (!expected) {
+ return undefined;
+ }
+
+ Assert.equal(
+ expected.event,
+ event,
+ `Expecting event "${expected.event}" got "${event}"`
+ );
+
+ for (let prop of ["properties"]) {
+ if (prop in expected) {
+ Assert.deepEqual(
+ expected[prop],
+ details[prop],
+ `Expected value for ${prop}`
+ );
+ }
+ }
+
+ this.checkComplete();
+
+ if ("returnValue" in expected) {
+ return expected.returnValue;
+ }
+ return undefined;
+ }
+
+ onPropertyChanged(addon, properties) {
+ return this.checkAddonEvent("onPropertyChanged", addon, { properties });
+ }
+
+ onEnabling(addon, requiresRestart) {
+ let result = this.checkAddonEvent("onEnabling", addon, { requiresRestart });
+
+ this.checkNoFlag("addon.permissions", addon.permissions, "PERM_CAN_ENABLE");
+
+ return result;
+ }
+
+ onEnabled(addon) {
+ let result = this.checkAddonEvent("onEnabled", addon);
+
+ this.checkNoFlag("addon.permissions", addon.permissions, "PERM_CAN_ENABLE");
+
+ return result;
+ }
+
+ onDisabling(addon, requiresRestart) {
+ let result = this.checkAddonEvent("onDisabling", addon, {
+ requiresRestart,
+ });
+
+ this.checkNoFlag(
+ "addon.permissions",
+ addon.permissions,
+ "PERM_CAN_DISABLE"
+ );
+ return result;
+ }
+
+ onDisabled(addon) {
+ let result = this.checkAddonEvent("onDisabled", addon);
+
+ this.checkNoFlag(
+ "addon.permissions",
+ addon.permissions,
+ "PERM_CAN_DISABLE"
+ );
+
+ return result;
+ }
+
+ onInstalling(addon, requiresRestart) {
+ return this.checkAddonEvent("onInstalling", addon, { requiresRestart });
+ }
+
+ onInstalled(addon) {
+ return this.checkAddonEvent("onInstalled", addon);
+ }
+
+ onUninstalling(addon, requiresRestart) {
+ return this.checkAddonEvent("onUninstalling", addon);
+ }
+
+ onUninstalled(addon) {
+ return this.checkAddonEvent("onUninstalled", addon);
+ }
+
+ onOperationCancelled(addon) {
+ return this.checkAddonEvent("onOperationCancelled", addon);
+ }
+
+ // Install listener events.
+ checkInstall(event, install, details = {}) {
+ // Lazy initialization of the plugin host means we can get spurious
+ // install events for plugins. If we're not looking for plugin
+ // installs, ignore them completely. If we *are* looking for plugin
+ // installs, the onus is on the individual test to ensure it waits
+ // for the plugin host to have done its initial work.
+ if (this.ignorePlugins && install.type == "plugin") {
+ info(`Ignoring install event for plugin ${install.id}`);
+ return undefined;
+ }
+ info(`Got install event "${event}"`);
+
+ let expected = this.expectedInstalls.shift();
+ Assert.ok(expected, "Should be expecting install event");
+
+ Assert.equal(
+ expected.event,
+ event,
+ "Should be expecting onExternalInstall event"
+ );
+
+ if ("state" in details) {
+ this.checkValue("install.state", install.state, details.state);
+ }
+
+ this.checkComplete();
+
+ if ("callback" in expected) {
+ expected.callback(install);
+ }
+
+ if ("returnValue" in expected) {
+ return expected.returnValue;
+ }
+ return undefined;
+ }
+
+ onNewInstall(install) {
+ let result = this.checkInstall("onNewInstall", install, {
+ state: ["STATE_DOWNLOADED", "STATE_DOWNLOAD_FAILED", "STATE_AVAILABLE"],
+ });
+
+ if (install.state != AddonManager.STATE_DOWNLOAD_FAILED) {
+ Assert.equal(install.error, 0, "Should have no error");
+ } else {
+ Assert.notEqual(install.error, 0, "Should have error");
+ }
+
+ return result;
+ }
+
+ onDownloadStarted(install) {
+ return this.checkInstall("onDownloadStarted", install, {
+ state: "STATE_DOWNLOADING",
+ error: 0,
+ });
+ }
+
+ onDownloadEnded(install) {
+ return this.checkInstall("onDownloadEnded", install, {
+ state: "STATE_DOWNLOADED",
+ error: 0,
+ });
+ }
+
+ onDownloadFailed(install) {
+ return this.checkInstall("onDownloadFailed", install, {
+ state: "STATE_FAILED",
+ });
+ }
+
+ onDownloadCancelled(install) {
+ return this.checkInstall("onDownloadCancelled", install, {
+ state: "STATE_CANCELLED",
+ error: 0,
+ });
+ }
+
+ onInstallStarted(install) {
+ return this.checkInstall("onInstallStarted", install, {
+ state: "STATE_INSTALLING",
+ error: 0,
+ });
+ }
+
+ onInstallEnded(install, newAddon) {
+ return this.checkInstall("onInstallEnded", install, {
+ state: "STATE_INSTALLED",
+ error: 0,
+ });
+ }
+
+ onInstallFailed(install) {
+ return this.checkInstall("onInstallFailed", install, {
+ state: "STATE_FAILED",
+ });
+ }
+
+ onInstallCancelled(install) {
+ // If the install was cancelled by a listener returning false from
+ // onInstallStarted, then the state will revert to STATE_DOWNLOADED.
+ return this.checkInstall("onInstallCancelled", install, {
+ state: ["STATE_CANCELED", "STATE_DOWNLOADED"],
+ error: 0,
+ });
+ }
+
+ onExternalInstall(addon, existingAddon, requiresRestart) {
+ if (this.ignorePlugins && addon.type == "plugin") {
+ info(`Ignoring install event for plugin ${addon.id}`);
+ return undefined;
+ }
+ let expected = this.expectedInstalls.shift();
+ Assert.ok(expected, "Should be expecting install event");
+
+ Assert.equal(
+ expected.event,
+ "onExternalInstall",
+ "Should be expecting onExternalInstall event"
+ );
+ Assert.ok(!requiresRestart, "Should never require restart");
+
+ this.checkComplete();
+ if ("returnValue" in expected) {
+ return expected.returnValue;
+ }
+ return undefined;
+ }
+}
+
+/**
+ * Run the giving callback function, and expect the given set of add-on
+ * and install listener events to be emitted, and returns a promise
+ * which resolves when they have all been observed.
+ *
+ * If `callback` returns a promise, all events are expected to be
+ * observed by the time the promise resolves. If not, simply waits for
+ * all events to be observed before resolving the returned promise.
+ *
+ * @param {object} details
+ * @param {function} callback
+ * @returns {Promise}
+ */
+/* exported expectEvents */
+async function expectEvents(details, callback) {
+ let checker = new EventChecker(details);
+
+ try {
+ let result = callback();
+
+ if (
+ result &&
+ typeof result === "object" &&
+ typeof result.then === "function"
+ ) {
+ result = await result;
+ checker.ensureComplete();
+ } else {
+ await checker.finished;
+ }
+
+ return result;
+ } catch (e) {
+ do_throw(e);
+ return undefined;
+ }
+}
+
+const EXTENSIONS_DB = "extensions.json";
+var gExtensionsJSON = gProfD.clone();
+gExtensionsJSON.append(EXTENSIONS_DB);
+
+async function promiseInstallWebExtension(aData) {
+ let addonFile = createTempWebExtensionFile(aData);
+
+ let { addon } = await promiseInstallFile(addonFile);
+ return addon;
+}
+
+// By default use strict compatibility
+Services.prefs.setBoolPref("extensions.strictCompatibility", true);
+
+// Ensure signature checks are enabled by default
+Services.prefs.setBoolPref(PREF_XPI_SIGNATURES_REQUIRED, true);
+
+Services.prefs.setBoolPref("extensions.experiments.enabled", true);
+
+// Copies blocklistFile (an nsIFile) to gProfD/blocklist.xml.
+function copyBlocklistToProfile(blocklistFile) {
+ var dest = gProfD.clone();
+ dest.append("blocklist.xml");
+ if (dest.exists()) {
+ dest.remove(false);
+ }
+ blocklistFile.copyTo(gProfD, "blocklist.xml");
+ dest.lastModifiedTime = Date.now();
+}
+
+async function mockGfxBlocklistItemsFromDisk(path) {
+ Cu.importGlobalProperties(["fetch"]);
+ let response = await fetch(Services.io.newFileURI(do_get_file(path)).spec);
+ let json = await response.json();
+ return mockGfxBlocklistItems(json);
+}
+
+async function mockGfxBlocklistItems(items) {
+ const { generateUUID } = Services.uuid;
+ const { BlocklistPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/Blocklist.sys.mjs"
+ );
+ const client = RemoteSettings("gfx", {
+ bucketName: "blocklists",
+ });
+ const records = items.map(item => {
+ if (item.id && item.last_modified) {
+ return item;
+ }
+ return {
+ id: generateUUID().toString().replace(/[{}]/g, ""),
+ last_modified: Date.now(),
+ ...item,
+ };
+ });
+ const collectionTimestamp = Math.max(...records.map(r => r.last_modified));
+ await client.db.importChanges({}, collectionTimestamp, records, {
+ clear: true,
+ });
+ let rv = await BlocklistPrivate.GfxBlocklistRS.checkForEntries();
+ return rv;
+}
+
+/**
+ * Change the schema version of the JSON extensions database
+ */
+async function changeXPIDBVersion(aNewVersion) {
+ let json = await IOUtils.readJSON(gExtensionsJSON.path);
+ json.schemaVersion = aNewVersion;
+ await IOUtils.writeJSON(gExtensionsJSON.path, json);
+}
+
+async function setInitialState(addon, initialState) {
+ if (initialState.userDisabled) {
+ await addon.disable();
+ } else if (initialState.userDisabled === false) {
+ await addon.enable();
+ }
+}
+
+async function setupBuiltinExtension(extensionData, location = "ext-test") {
+ 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(location, base);
+}
+
+async function installBuiltinExtension(extensionData, waitForStartup = true) {
+ await setupBuiltinExtension(extensionData);
+
+ let id =
+ extensionData.manifest?.browser_specific_settings?.gecko?.id ||
+ extensionData.manifest?.applications?.gecko?.id;
+ let wrapper = ExtensionTestUtils.expectExtension(id);
+ await AddonManager.installBuiltinAddon("resource://ext-test/");
+ if (waitForStartup) {
+ await wrapper.awaitStartup();
+ }
+ return wrapper;
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/head_amremotesettings.js b/toolkit/mozapps/extensions/test/xpcshell/head_amremotesettings.js
new file mode 100644
index 0000000000..36741736fa
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_amremotesettings.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+});
+
+async function setAndEmitFakeRemoteSettingsData(
+ data,
+ expectClientInitialized = true
+) {
+ const { AMRemoteSettings } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+ );
+ let client;
+ if (expectClientInitialized) {
+ ok(AMRemoteSettings.client, "Got a remote settings client");
+ ok(AMRemoteSettings.onSync, "Got a remote settings 'sync' event handler");
+ client = AMRemoteSettings.client;
+ } else {
+ // No client is expected to exist, and so we create one to inject the expected data
+ // into the RemoteSettings db.
+ client = new RemoteSettings(AMRemoteSettings.RS_COLLECTION);
+ }
+
+ await client.db.clear();
+ if (data.length) {
+ await client.db.importChanges({}, Date.now(), data);
+ }
+ await client.emit("sync", { data: {} });
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/head_cert_handling.js b/toolkit/mozapps/extensions/test/xpcshell/head_cert_handling.js
new file mode 100644
index 0000000000..08c41a8c7e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_cert_handling.js
@@ -0,0 +1,33 @@
+// Helpers for handling certs.
+// These are taken from
+// https://searchfox.org/mozilla-central/rev/36aa22c7ea92bd3cf7910774004fff7e63341cf5/security/manager/ssl/tests/unit/head_psm.js
+// but we don't want to drag that file in here because
+// - it conflicts with `head_addons.js`.
+// - it has a lot of extra code we don't need.
+// So dupe relevant code here.
+
+// This file will be included along with head_addons.js, use its globals.
+/* import-globals-from head_addons.js */
+
+"use strict";
+
+function readFile(file) {
+ let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fstream.init(file, -1, 0, 0);
+ let available = fstream.available();
+ let data =
+ available > 0 ? NetUtil.readInputStreamToString(fstream, available) : "";
+ fstream.close();
+ return data;
+}
+
+function loadCertChain(prefix, names) {
+ let chain = [];
+ for (let name of names) {
+ let filename = `${prefix}_${name}.pem`;
+ chain.push(readFile(do_get_file(filename)));
+ }
+ return chain;
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/head_compat.js b/toolkit/mozapps/extensions/test/xpcshell/head_compat.js
new file mode 100644
index 0000000000..70e084d307
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_compat.js
@@ -0,0 +1,49 @@
+//
+// This file provides helpers for tests of addons that use strictCompatibility.
+// Since WebExtensions cannot opt out of strictCompatibility, we add a
+// simple extension loader that lets tests directly set AddonInternal
+// properties (including strictCompatibility)
+//
+
+/* import-globals-from head_addons.js */
+
+const MANIFEST = "compat_manifest.json";
+
+AddonManager.addExternalExtensionLoader({
+ name: "compat-test",
+ manifestFile: MANIFEST,
+ async loadManifest(pkg) {
+ // XPIDatabase.jsm gets unloaded in AddonTestUtils when the
+ // addon manager is restarted. Work around that by just importing
+ // it every time we need to create an AddonInternal.
+ const { AddonInternal } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIDatabase.jsm"
+ );
+ let addon = new AddonInternal();
+ let manifest = JSON.parse(await pkg.readString(MANIFEST));
+ Object.assign(addon, manifest);
+ return addon;
+ },
+ loadScope(addon, file) {
+ return {
+ install() {},
+ uninstall() {},
+ startup() {},
+ shutdonw() {},
+ };
+ },
+});
+
+const DEFAULTS = {
+ defaultLocale: {},
+ locales: [],
+ targetPlatforms: [],
+ type: "extension",
+ version: "1.0",
+};
+
+function createAddon(manifest) {
+ return AddonTestUtils.createTempXPIFile({
+ [MANIFEST]: Object.assign({}, DEFAULTS, manifest),
+ });
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/head_sideload.js b/toolkit/mozapps/extensions/test/xpcshell/head_sideload.js
new file mode 100644
index 0000000000..8ff3f2f072
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_sideload.js
@@ -0,0 +1,76 @@
+// Any copyright is dedicated to the Public Domain.
+// http://creativecommons.org/publicdomain/zero/1.0/
+
+/* import-globals-from head_addons.js */
+
+// Enable all scopes.
+Services.prefs.setIntPref("extensions.enabledScopes", AddonManager.SCOPE_ALL);
+// Setting this to all enables the same behavior as before disabling sideloading.
+// We reset this later after doing some legacy sideloading.
+Services.prefs.setIntPref("extensions.sideloadScopes", AddonManager.SCOPE_ALL);
+// AddonTestUtils sets this to zero, we need the default value.
+Services.prefs.clearUserPref("extensions.autoDisableScopes");
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+function getID(n) {
+ return `${n}@tests.mozilla.org`;
+}
+function initialVersion(n) {
+ return `${n}.0`;
+}
+
+// Setup some common extension locations, one in each scope.
+
+// SCOPE_SYSTEM
+const globalDir = gProfD.clone();
+globalDir.append("app-system-share");
+globalDir.append(gAppInfo.ID);
+registerDirectory("XRESysSExtPD", globalDir.parent);
+
+// SCOPE_USER
+const userDir = gProfD.clone();
+userDir.append("app-system-user");
+userDir.append(gAppInfo.ID);
+registerDirectory("XREUSysExt", userDir.parent);
+
+// SCOPE_APPLICATION
+const addonAppDir = gProfD.clone();
+addonAppDir.append("app-global");
+addonAppDir.append("extensions");
+registerDirectory("XREAddonAppDir", addonAppDir.parent);
+
+// SCOPE_PROFILE
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const scopeDirectories = {
+ global: globalDir,
+ user: userDir,
+ app: addonAppDir,
+ profile: profileDir,
+};
+
+const scopeToDir = new Map([
+ [AddonManager.SCOPE_SYSTEM, globalDir],
+ [AddonManager.SCOPE_USER, userDir],
+ [AddonManager.SCOPE_APPLICATION, addonAppDir],
+ [AddonManager.SCOPE_PROFILE, profileDir],
+]);
+
+async function createWebExtension(id, version, dir) {
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+ await AddonTestUtils.manuallyInstall(xpi, dir);
+}
+
+function check_startup_changes(aType, aIds) {
+ let changes = AddonManager.getStartupChanges(aType);
+ changes = changes.filter(aEl => /@tests.mozilla.org$/.test(aEl));
+
+ Assert.deepEqual([...aIds].sort(), changes.sort());
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/head_system_addons.js b/toolkit/mozapps/extensions/test/xpcshell/head_system_addons.js
new file mode 100644
index 0000000000..4cb591dd56
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_system_addons.js
@@ -0,0 +1,472 @@
+/* import-globals-from head_addons.js */
+
+const PREF_SYSTEM_ADDON_SET = "extensions.systemAddonSet";
+const PREF_SYSTEM_ADDON_UPDATE_URL = "extensions.systemAddon.update.url";
+const PREF_SYSTEM_ADDON_UPDATE_ENABLED =
+ "extensions.systemAddon.update.enabled";
+
+// See bug 1507255
+Services.prefs.setBoolPref("media.gmp-manager.updateEnabled", true);
+
+function root(server) {
+ let { primaryScheme, primaryHost, primaryPort } = server.identity;
+ return `${primaryScheme}://${primaryHost}:${primaryPort}/data`;
+}
+
+XPCOMUtils.defineLazyGetter(this, "testserver", () => {
+ let server = new HttpServer();
+ server.start();
+ Services.prefs.setCharPref(
+ PREF_SYSTEM_ADDON_UPDATE_URL,
+ `${root(server)}/update.xml`
+ );
+ return server;
+});
+
+async function serveSystemUpdate(xml, perform_update) {
+ testserver.registerPathHandler("/data/update.xml", (request, response) => {
+ response.write(xml);
+ });
+
+ try {
+ await perform_update();
+ } finally {
+ testserver.registerPathHandler("/data/update.xml", null);
+ }
+}
+
+// Runs an update check making it use the passed in xml string. Uses the direct
+// call to the update function so we get rejections on failure.
+async function installSystemAddons(xml, waitIDs = []) {
+ info("Triggering system add-on update check.");
+
+ await serveSystemUpdate(
+ xml,
+ async function () {
+ let { XPIProvider } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIProvider.jsm"
+ );
+ await Promise.all([
+ XPIProvider.updateSystemAddons(),
+ ...waitIDs.map(id => promiseWebExtensionStartup(id)),
+ ]);
+ },
+ testserver
+ );
+}
+
+// Runs a full add-on update check which will in some cases do a system add-on
+// update check. Always succeeds.
+async function updateAllSystemAddons(xml) {
+ info("Triggering full add-on update check.");
+
+ await serveSystemUpdate(
+ xml,
+ function () {
+ return new Promise(resolve => {
+ Services.obs.addObserver(function observer() {
+ Services.obs.removeObserver(
+ observer,
+ "addons-background-update-complete"
+ );
+
+ resolve();
+ }, "addons-background-update-complete");
+
+ // Trigger the background update timer handler
+ gInternalManager.notify(null);
+ });
+ },
+ testserver
+ );
+}
+
+// Builds an update.xml file for an update check based on the data passed.
+function buildSystemAddonUpdates(addons) {
+ let xml = `<?xml version="1.0" encoding="UTF-8"?>\n\n<updates>\n`;
+ if (addons) {
+ xml += ` <addons>\n`;
+ for (let addon of addons) {
+ if (addon.xpi) {
+ testserver.registerFile(`/data/${addon.path}`, addon.xpi);
+ }
+
+ xml += ` <addon id="${addon.id}" URL="${root(testserver)}/${
+ addon.path
+ }" version="${addon.version}"`;
+ if (addon.hashFunction) {
+ xml += ` hashFunction="${addon.hashFunction}"`;
+ }
+ if (addon.hashValue) {
+ xml += ` hashValue="${addon.hashValue}"`;
+ }
+ xml += `/>\n`;
+ }
+ xml += ` </addons>\n`;
+ }
+ xml += `</updates>\n`;
+
+ return xml;
+}
+
+let _systemXPIs = new Map();
+function getSystemAddonXPI(num, version) {
+ let key = `${num}:${version}`;
+ if (!_systemXPIs.has(key)) {
+ _systemXPIs.set(
+ key,
+ AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ name: `System Add-on ${num}`,
+ version,
+ browser_specific_settings: {
+ gecko: {
+ id: `system${num}@tests.mozilla.org`,
+ },
+ },
+ },
+ })
+ );
+ }
+ return _systemXPIs.get(key);
+}
+
+async function initSystemAddonDirs() {
+ let hiddenSystemAddonDir = FileUtils.getDir(
+ "ProfD",
+ ["sysfeatures", "hidden"],
+ true
+ );
+ let system1_1 = await getSystemAddonXPI(1, "1.0");
+ system1_1.copyTo(hiddenSystemAddonDir, "system1@tests.mozilla.org.xpi");
+
+ let system2_1 = await getSystemAddonXPI(2, "1.0");
+ system2_1.copyTo(hiddenSystemAddonDir, "system2@tests.mozilla.org.xpi");
+
+ let prefilledSystemAddonDir = FileUtils.getDir(
+ "ProfD",
+ ["sysfeatures", "prefilled"],
+ true
+ );
+ let system2_2 = await getSystemAddonXPI(2, "2.0");
+ system2_2.copyTo(prefilledSystemAddonDir, "system2@tests.mozilla.org.xpi");
+ let system3_2 = await getSystemAddonXPI(3, "2.0");
+ system3_2.copyTo(prefilledSystemAddonDir, "system3@tests.mozilla.org.xpi");
+}
+
+/**
+ * Returns current system add-on update directory (stored in pref).
+ */
+function getCurrentSystemAddonUpdatesDir() {
+ const updatesDir = FileUtils.getDir("ProfD", ["features"], false);
+ let dir = updatesDir.clone();
+ let set = JSON.parse(Services.prefs.getCharPref(PREF_SYSTEM_ADDON_SET));
+ dir.append(set.directory);
+ return dir;
+}
+
+/**
+ * Removes all files from system add-on update directory.
+ */
+function clearSystemAddonUpdatesDir() {
+ const updatesDir = FileUtils.getDir("ProfD", ["features"], false);
+ // Delete any existing directories
+ if (updatesDir.exists()) {
+ updatesDir.remove(true);
+ }
+
+ Services.prefs.clearUserPref(PREF_SYSTEM_ADDON_SET);
+}
+
+registerCleanupFunction(() => {
+ clearSystemAddonUpdatesDir();
+});
+
+/**
+ * Installs a known set of add-ons into the system add-on update directory.
+ */
+async function buildPrefilledUpdatesDir() {
+ clearSystemAddonUpdatesDir();
+
+ // Build the test set
+ let dir = FileUtils.getDir("ProfD", ["features", "prefilled"], true);
+
+ let xpi = await getSystemAddonXPI(2, "2.0");
+ xpi.copyTo(dir, "system2@tests.mozilla.org.xpi");
+
+ xpi = await getSystemAddonXPI(3, "2.0");
+ xpi.copyTo(dir, "system3@tests.mozilla.org.xpi");
+
+ // Mark these in the past so the startup file scan notices when files have changed properly
+ FileUtils.getFile("ProfD", [
+ "features",
+ "prefilled",
+ "system2@tests.mozilla.org.xpi",
+ ]).lastModifiedTime -= 10000;
+ FileUtils.getFile("ProfD", [
+ "features",
+ "prefilled",
+ "system3@tests.mozilla.org.xpi",
+ ]).lastModifiedTime -= 10000;
+
+ Services.prefs.setCharPref(
+ PREF_SYSTEM_ADDON_SET,
+ JSON.stringify({
+ schema: 1,
+ directory: dir.leafName,
+ addons: {
+ "system2@tests.mozilla.org": {
+ version: "2.0",
+ },
+ "system3@tests.mozilla.org": {
+ version: "2.0",
+ },
+ },
+ })
+ );
+}
+
+/**
+ * Check currently installed ssystem add-ons against a set of conditions.
+ *
+ * @param {Array<Object>} conditions - an array of objects of the form { isUpgrade: false, version: null}
+ * @param {nsIFile} distroDir - the system add-on distribution directory (the "features" dir in the app directory)
+ */
+async function checkInstalledSystemAddons(conditions, distroDir) {
+ for (let i = 0; i < conditions.length; i++) {
+ let condition = conditions[i];
+ let id = "system" + (i + 1) + "@tests.mozilla.org";
+ let addon = await promiseAddonByID(id);
+
+ if (!("isUpgrade" in condition) || !("version" in condition)) {
+ throw Error("condition must contain isUpgrade and version");
+ }
+ let isUpgrade = conditions[i].isUpgrade;
+ let version = conditions[i].version;
+
+ let expectedDir = isUpgrade ? getCurrentSystemAddonUpdatesDir() : distroDir;
+
+ if (version) {
+ info(`Checking state of add-on ${id}, expecting version ${version}`);
+
+ // Add-on should be installed
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, version);
+ Assert.ok(addon.isActive);
+ Assert.ok(!addon.foreignInstall);
+ Assert.ok(addon.hidden);
+ Assert.ok(addon.isSystem);
+
+ // Verify the add-ons file is in the right place
+ let file = expectedDir.clone();
+ file.append(id + ".xpi");
+ Assert.ok(file.exists());
+ Assert.ok(file.isFile());
+
+ let uri = addon.getResourceURI();
+ if (uri instanceof Ci.nsIJARURI) {
+ uri = uri.JARFile;
+ }
+
+ Assert.ok(uri instanceof Ci.nsIFileURL);
+ Assert.equal(uri.file.path, file.path);
+
+ if (isUpgrade) {
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_SYSTEM);
+ }
+ } else {
+ info(`Checking state of add-on ${id}, expecting it to be missing`);
+
+ if (isUpgrade) {
+ // Add-on should not be installed
+ Assert.equal(addon, null);
+ }
+ }
+ }
+}
+
+/**
+ * Returns all system add-on updates directories.
+ */
+async function getSystemAddonDirectories() {
+ const updatesDir = FileUtils.getDir("ProfD", ["features"], false);
+ let subdirs = [];
+
+ if (await IOUtils.exists(updatesDir.path)) {
+ for (const child of await IOUtils.getChildren(updatesDir.path)) {
+ const stat = await IOUtils.stat(child);
+ if (stat.type === "directory") {
+ subdirs.push(child);
+ }
+ }
+ }
+
+ return subdirs;
+}
+
+/**
+ * Sets up initial system add-on update conditions.
+ *
+ * @param {Object<function, Array<Object>} setup - an object containing a setup function and an array of objects
+ * of the form {isUpgrade: false, version: null}
+ *
+ * @param {nsIFile} distroDir - the system add-on distribution directory (the "features" dir in the app directory)
+ */
+async function setupSystemAddonConditions(setup, distroDir) {
+ info("Clearing existing database.");
+ Services.prefs.clearUserPref(PREF_SYSTEM_ADDON_SET);
+ distroDir.leafName = "empty";
+
+ let updateList = [];
+ await overrideBuiltIns({ system: updateList });
+ await promiseStartupManager();
+ await promiseShutdownManager();
+
+ info("Setting up conditions.");
+ await setup.setup();
+
+ if (distroDir) {
+ if (distroDir.path.endsWith("hidden")) {
+ updateList = ["system1@tests.mozilla.org", "system2@tests.mozilla.org"];
+ } else if (distroDir.path.endsWith("prefilled")) {
+ updateList = ["system2@tests.mozilla.org", "system3@tests.mozilla.org"];
+ }
+ }
+ await overrideBuiltIns({ system: updateList });
+
+ let startupPromises = setup.initialState.map((item, i) =>
+ item.version
+ ? promiseWebExtensionStartup(`system${i + 1}@tests.mozilla.org`)
+ : null
+ );
+ await Promise.all([promiseStartupManager(), ...startupPromises]);
+
+ // Make sure the initial state is correct
+ info("Checking initial state.");
+ await checkInstalledSystemAddons(setup.initialState, distroDir);
+}
+
+/**
+ * Verify state of system add-ons after installation.
+ *
+ * @param {Array<Object>} initialState - an array of objects of the form {isUpgrade: false, version: null}
+ * @param {Array<Object>} finalState - an array of objects of the form {isUpgrade: false, version: null}
+ * @param {Boolean} alreadyUpgraded - whether a restartless upgrade has already been performed.
+ * @param {nsIFile} distroDir - the system add-on distribution directory (the "features" dir in the app directory)
+ */
+async function verifySystemAddonState(
+ initialState,
+ finalState = undefined,
+ alreadyUpgraded = false,
+ distroDir
+) {
+ let expectedDirs = 0;
+
+ // If the initial state was using the profile set then that directory will
+ // still exist.
+
+ if (initialState.some(a => a.isUpgrade)) {
+ expectedDirs++;
+ }
+
+ if (finalState == undefined) {
+ finalState = initialState;
+ } else if (finalState.some(a => a.isUpgrade)) {
+ // If the new state is using the profile then that directory will exist.
+ expectedDirs++;
+ }
+
+ // Since upgrades are restartless now, the previous update dir hasn't been removed.
+ if (alreadyUpgraded) {
+ expectedDirs++;
+ }
+
+ info("Checking final state.");
+
+ let dirs = await getSystemAddonDirectories();
+ Assert.equal(dirs.length, expectedDirs);
+
+ await checkInstalledSystemAddons(...finalState, distroDir);
+
+ // Check that the new state is active after a restart
+ await promiseShutdownManager();
+
+ let updateList = [];
+
+ if (distroDir) {
+ if (distroDir.path.endsWith("hidden")) {
+ updateList = ["system1@tests.mozilla.org", "system2@tests.mozilla.org"];
+ } else if (distroDir.path.endsWith("prefilled")) {
+ updateList = ["system2@tests.mozilla.org", "system3@tests.mozilla.org"];
+ }
+ }
+ await overrideBuiltIns({ system: updateList });
+ await promiseStartupManager();
+ await checkInstalledSystemAddons(finalState, distroDir);
+}
+
+/**
+ * Run system add-on tests and compare the results against a set of expected conditions.
+ *
+ * @param {String} setupName - name of the current setup conditions.
+ * @param {Object<function, Array<Object>} setup - Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ * @param {Array<Object>} test - The test to run. Each test must define an updateList or test. The following
+ * properties are used:
+ * updateList: The set of add-ons the server should respond with.
+ * test: A function to run to perform the update check (replaces
+ * updateList)
+ * fails: An optional regex property, if present the update check is expected to
+ * fail.
+ * finalState: An optional property, the expected final state of system add-ons,
+ * if missing the test condition's initialState is used.
+ * @param {nsIFile} distroDir - the system add-on distribution directory (the "features" dir in the app directory)
+ */
+
+async function execSystemAddonTest(setupName, setup, test, distroDir) {
+ // Initial system addon conditions need system signature
+ AddonTestUtils.usePrivilegedSignatures = "system";
+ await setupSystemAddonConditions(setup, distroDir);
+
+ // The test may define what signature to use when running the test
+ if (test.usePrivilegedSignatures != undefined) {
+ AddonTestUtils.usePrivilegedSignatures = test.usePrivilegedSignatures;
+ }
+
+ function runTest() {
+ if ("test" in test) {
+ return test.test();
+ }
+ let xml = buildSystemAddonUpdates(test.updateList);
+ let ids = (test.updateList || []).map(item => item.id);
+ return installSystemAddons(xml, ids);
+ }
+
+ if (test.fails) {
+ await Assert.rejects(runTest(), test.fails);
+ } else {
+ await runTest();
+ }
+
+ // some tests have a different expected combination of default
+ // and updated add-ons.
+ if (test.finalState && setupName in test.finalState) {
+ await verifySystemAddonState(
+ setup.initialState,
+ test.finalState[setupName],
+ false,
+ distroDir
+ );
+ } else {
+ await verifySystemAddonState(
+ setup.initialState,
+ undefined,
+ false,
+ distroDir
+ );
+ }
+
+ await promiseShutdownManager();
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/head_unpack.js b/toolkit/mozapps/extensions/test/xpcshell/head_unpack.js
new file mode 100644
index 0000000000..909dc1da2f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_unpack.js
@@ -0,0 +1,3 @@
+/* globals Services, TEST_UNPACKED: true */
+/* exported TEST_UNPACKED */
+TEST_UNPACKED = true;
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/head.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/head.js
new file mode 100644
index 0000000000..4007ec6988
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/head.js
@@ -0,0 +1,120 @@
+// Appease eslint.
+/* import-globals-from ../head_addons.js */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+// 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 IS_FOG_RESET_SUPPORTED = !IS_ANDROID_BUILD;
+const DUMMY_TIME = 939420000000; // new Date(1999, 9, 9)
+const DUMMY_STRING = "GleanDummyString";
+let gleanEventCount = 0;
+
+function _resetMetric(gleanMetric) {
+ let value = gleanMetric.testGetValue();
+ if (value === undefined) {
+ return; // Never initialized, nothing to reset.
+ }
+ if (gleanMetric instanceof Ci.nsIGleanDatetime) {
+ gleanMetric.set(DUMMY_TIME * 1000);
+ } else if (gleanMetric instanceof Ci.nsIGleanString) {
+ gleanMetric.set(DUMMY_STRING);
+ } else if (gleanMetric instanceof Ci.nsIGleanEvent) {
+ // NOTE: this doesn't work when there is more than one event;
+ // for now we assume that there is only one: addonBlockChange.
+ // Cannot overwrite, so just store the current list length.
+ gleanEventCount = value.length;
+ } else {
+ throw new Error("Unsupported Glean metric type - cannot reset");
+ }
+}
+
+function testGetValue(gleanMetric) {
+ let value = gleanMetric.testGetValue();
+ if (value === undefined || IS_FOG_RESET_SUPPORTED) {
+ return value;
+ }
+ if (gleanMetric instanceof Ci.nsIGleanDatetime) {
+ return value.getTime() === DUMMY_TIME ? undefined : value;
+ }
+ if (gleanMetric instanceof Ci.nsIGleanString) {
+ return value === DUMMY_STRING ? undefined : value;
+ }
+ if (gleanMetric instanceof Ci.nsIGleanEvent) {
+ // NOTE: this doesn't work when there is more than one event;
+ // for now we assume that there is only one: addonBlockChange.
+ value = value.slice(gleanEventCount);
+ return value.length ? value : undefined;
+ }
+ throw new Error("Unsupported Glean metric type");
+}
+
+function resetBlocklistTelemetry() {
+ if (IS_FOG_RESET_SUPPORTED) {
+ Services.fog.testResetFOG();
+ return;
+ }
+ // TODO bug 1752139: fix testResetFOG and remove workarounds.
+ _resetMetric(Glean.blocklist.addonBlockChange);
+ _resetMetric(Glean.blocklist.lastModifiedRsAddonsMblf);
+ _resetMetric(Glean.blocklist.mlbfSource);
+ _resetMetric(Glean.blocklist.mlbfGenerationTime);
+ _resetMetric(Glean.blocklist.mlbfStashTimeOldest);
+ _resetMetric(Glean.blocklist.mlbfStashTimeNewest);
+}
+
+const MLBF_RECORD = {
+ id: "A blocklist entry that refers to a MLBF file",
+ // Higher than any last_modified in addons-bloomfilters.json:
+ last_modified: Date.now(),
+ attachment: {
+ size: 32,
+ hash: "6af648a5d6ce6dbee99b0aab1780d24d204977a6606ad670d5372ef22fac1052",
+ filename: "does-not-matter.bin",
+ },
+ attachment_type: "bloomfilter-base",
+ generation_time: 1577833200000,
+};
+
+function enable_blocklist_v2_instead_of_useMLBF() {
+ Blocklist.allowDeprecatedBlocklistV2 = true;
+ Services.prefs.setBoolPref("extensions.blocklist.useMLBF", false);
+ // Sanity check: blocklist v2 has been enabled.
+ const { BlocklistPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/Blocklist.sys.mjs"
+ );
+ Assert.equal(
+ Blocklist.ExtensionBlocklist,
+ BlocklistPrivate.ExtensionBlocklistRS,
+ "ExtensionBlocklistRS should have been enabled"
+ );
+}
+
+async function load_mlbf_record_as_blob() {
+ const url = Services.io.newFileURI(
+ do_get_file("../data/mlbf-blocked1-unblocked2.bin")
+ ).spec;
+ Cu.importGlobalProperties(["fetch"]);
+ return (await fetch(url)).blob();
+}
+
+function getExtensionBlocklistMLBF() {
+ // ExtensionBlocklist.Blocklist is an ExtensionBlocklistMLBF if the useMLBF
+ // pref is set to true.
+ const {
+ BlocklistPrivate: { ExtensionBlocklistMLBF },
+ } = ChromeUtils.importESModule("resource://gre/modules/Blocklist.sys.mjs");
+ if (Blocklist.allowDeprecatedBlocklistV2) {
+ Assert.ok(
+ Services.prefs.getBoolPref("extensions.blocklist.useMLBF", false),
+ "blocklist.useMLBF should be true"
+ );
+ }
+ return ExtensionBlocklistMLBF;
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_android_blocklist_dump.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_android_blocklist_dump.js
new file mode 100644
index 0000000000..c0221c446b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_android_blocklist_dump.js
@@ -0,0 +1,120 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Blocklist v3 will be enabled on release in bug 1824863.
+// TODO bug 1824863: Remove this when blocklist v3 is enabled.
+const IS_USING_BLOCKLIST_V3 = AppConstants.NIGHTLY_BUILD;
+
+// When bug 1639050 is fixed, this whole test can be removed as it is already
+// covered by test_blocklist_mlbf_dump.js.
+
+// A known blocked version from bug 1626602.
+// Same as in test_blocklist_mlbf_dump.js.
+const blockedAddon = {
+ id: "{6f62927a-e380-401a-8c9e-c485b7d87f0d}",
+ version: "9.2.0",
+ signedDate: new Date(1588098908496), // 2020-04-28 (dummy date)
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+};
+
+// A known blocked version from bug 1681884, blocklist v3 only but not v2,
+// i.e. not listed in services/settings/dumps/blocklists/addons.json.
+const blockedAddonV3only = {
+ id: "{011f65f0-7143-470a-83ca-20ec4297f3f4}",
+ version: "1.0",
+ // omiting signedDate/signedState: in blocklist v2 those don't matter.
+ // In v3 those do matter, so if blocklist v3 were to be enabled, then
+ // the test would fail.
+};
+
+// A known add-on that is not blocked, as of writing. It is likely not going
+// to be blocked because it does not have any executable code.
+// Same as in test_blocklist_mlbf_dump.js.
+const nonBlockedAddon = {
+ id: "disable-ctrl-q-and-cmd-q@robwu.nl",
+ version: "1",
+ signedDate: new Date(1482430349000), // 2016-12-22 (actual signing time).
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+};
+
+add_task(
+ { skip_if: () => IS_USING_BLOCKLIST_V3 },
+ async function verify_blocklistv2_dump_first_run() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddon),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "A add-on that is known to be on the v2 blocklist should be blocked"
+ );
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddonV3only),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "An add-on that is not part of the v2 blocklist should not be blocked"
+ );
+
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(nonBlockedAddon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "A known non-blocked add-on should not be blocked"
+ );
+ }
+);
+
+add_task(
+ { skip_if: () => !IS_USING_BLOCKLIST_V3 },
+ async function verify_a_known_blocked_add_on_is_not_detected_as_blocked_at_first_run() {
+ const MLBF_LOAD_RESULTS = [];
+ const MLBF_LOAD_ATTEMPTS = [];
+ const onLoadAttempts = record => MLBF_LOAD_ATTEMPTS.push(record);
+ const onLoadResult = promise => MLBF_LOAD_RESULTS.push(promise);
+ spyOnExtensionBlocklistMLBF(onLoadAttempts, onLoadResult);
+
+ // The addons blocklist data is not packaged and will be downloaded after install
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "A known blocked add-on should not be blocked at first"
+ );
+
+ await Assert.rejects(
+ MLBF_LOAD_RESULTS[0],
+ /DownloadError: Could not download addons-mlbf.bin/,
+ "Should not find any packaged attachment"
+ );
+
+ MLBF_LOAD_ATTEMPTS.length = 0;
+ MLBF_LOAD_RESULTS.length = 0;
+
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Blocklist is still not populated"
+ );
+ Assert.deepEqual(
+ MLBF_LOAD_ATTEMPTS,
+ [],
+ "MLBF is not fetched again after the first lookup"
+ );
+ }
+);
+
+function spyOnExtensionBlocklistMLBF(onLoadAttempts, onLoadResult) {
+ const ExtensionBlocklistMLBF = getExtensionBlocklistMLBF();
+ // Tapping into the internals of ExtensionBlocklistMLBF._fetchMLBF to observe
+ const originalFetchMLBF = ExtensionBlocklistMLBF._fetchMLBF;
+ ExtensionBlocklistMLBF._fetchMLBF = async function (record) {
+ onLoadAttempts(record);
+ let promise = originalFetchMLBF.apply(this, arguments);
+ onLoadResult(promise);
+ return promise;
+ };
+
+ registerCleanupFunction(
+ () => (ExtensionBlocklistMLBF._fetchMLBF = originalFetchMLBF)
+ );
+
+ return ExtensionBlocklistMLBF;
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_addonBlockURL.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_addonBlockURL.js
new file mode 100644
index 0000000000..b11d1329cd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_addonBlockURL.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// useMLBF=true case is covered by test_blocklist_mlbf.js
+enable_blocklist_v2_instead_of_useMLBF();
+
+const BLOCKLIST_DATA = [
+ {
+ id: "foo",
+ guid: "myfoo",
+ versionRange: [
+ {
+ severity: "3",
+ },
+ ],
+ },
+ {
+ blockID: "bar",
+ // we'll get a uuid as an `id` property from loadBlocklistRawData
+ guid: "mybar",
+ versionRange: [
+ {
+ severity: "3",
+ },
+ ],
+ },
+];
+
+const BASE_BLOCKLIST_INFOURL = Services.prefs.getStringPref(
+ "extensions.blocklist.detailsURL"
+);
+
+/*
+ * Check that add-on blocklist URLs are correctly exposed
+ * based on either blockID or id properties on the entries
+ * in remote settings.
+ */
+add_task(async function blocklistURL_check() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ await promiseStartupManager();
+ await AddonTestUtils.loadBlocklistRawData({ extensions: BLOCKLIST_DATA });
+
+ let entry = await Blocklist.getAddonBlocklistEntry({
+ id: "myfoo",
+ version: "1.0",
+ });
+ Assert.equal(entry.url, BASE_BLOCKLIST_INFOURL + "foo.html");
+
+ entry = await Blocklist.getAddonBlocklistEntry({
+ id: "mybar",
+ version: "1.0",
+ });
+ Assert.equal(entry.url, BASE_BLOCKLIST_INFOURL + "bar.html");
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_appversion.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_appversion.js
new file mode 100644
index 0000000000..e8d03f088b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_appversion.js
@@ -0,0 +1,293 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+// useMLBF=true does not offer special support for filtering by application ID.
+// The same functionality is offered through filter_expression, which is tested
+// by services/settings/test/unit/test_remote_settings_jexl_filters.js and
+// test_blocklistchange.js.
+enable_blocklist_v2_instead_of_useMLBF();
+
+var ADDONS = [
+ {
+ id: "test_bug449027_1@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 1",
+ version: "5",
+ start: false,
+ appBlocks: false,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_2@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 2",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_3@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 3",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_4@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 4",
+ version: "5",
+ start: false,
+ appBlocks: false,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_5@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 5",
+ version: "5",
+ start: false,
+ appBlocks: false,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_6@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 6",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_7@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 7",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_8@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 8",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_9@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 9",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_10@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 10",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_11@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 11",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_12@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 12",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_13@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 13",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_14@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 14",
+ version: "5",
+ start: false,
+ appBlocks: false,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_15@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 15",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+ {
+ id: "test_bug449027_16@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 16",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+ {
+ id: "test_bug449027_17@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 17",
+ version: "5",
+ start: false,
+ appBlocks: false,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_18@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 18",
+ version: "5",
+ start: false,
+ appBlocks: false,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_19@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 19",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+ {
+ id: "test_bug449027_20@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 20",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+ {
+ id: "test_bug449027_21@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 21",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+ {
+ id: "test_bug449027_22@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 22",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+ {
+ id: "test_bug449027_23@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 23",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+ {
+ id: "test_bug449027_24@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 24",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+ {
+ id: "test_bug449027_25@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 25",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+];
+
+function createAddon(addon) {
+ return promiseInstallWebExtension({
+ manifest: {
+ name: addon.name,
+ version: addon.version,
+ browser_specific_settings: { gecko: { id: addon.id } },
+ },
+ });
+}
+
+/**
+ * Checks that items are blocklisted correctly according to the current test.
+ * If a lastTest is provided checks that the notification dialog got passed
+ * the newly blocked items compared to the previous test.
+ */
+async function checkState(test, lastTest, callback) {
+ let addons = await AddonManager.getAddonsByIDs(ADDONS.map(a => a.id));
+
+ const bls = Ci.nsIBlocklistService;
+
+ await TestUtils.waitForCondition(() =>
+ ADDONS.every(
+ (addon, i) =>
+ addon[test] == (addons[i].blocklistState == bls.STATE_BLOCKED)
+ )
+ ).catch(() => {
+ /* ignore exceptions; the following test will fail anyway. */
+ });
+
+ for (let [i, addon] of ADDONS.entries()) {
+ var blocked =
+ addons[i].blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED;
+ equal(
+ blocked,
+ addon[test],
+ `Blocklist state should match expected for extension ${addon.id}, test ${test}`
+ );
+ }
+}
+
+add_task(async function test() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ for (let addon of ADDONS) {
+ await createAddon(addon);
+ }
+
+ let addons = await AddonManager.getAddonsByIDs(ADDONS.map(a => a.id));
+ for (var i = 0; i < ADDONS.length; i++) {
+ ok(addons[i], `Addon ${i + 1} should have been correctly installed`);
+ }
+
+ await checkState("start");
+});
+
+/**
+ * Load the toolkit based blocks
+ */
+add_task(async function test_pt2() {
+ await AddonTestUtils.loadBlocklistData(
+ do_get_file("../data/"),
+ "test_bug449027_toolkit"
+ );
+
+ await checkState("toolkitBlocks", "start");
+});
+
+/**
+ * Load the application based blocks
+ */
+add_task(async function test_pt3() {
+ await AddonTestUtils.loadBlocklistData(
+ do_get_file("../data/"),
+ "test_bug449027_app"
+ );
+
+ await checkState("appBlocks", "toolkitBlocks");
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_clients.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_clients.js
new file mode 100644
index 0000000000..2ddb4fe514
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_clients.js
@@ -0,0 +1,228 @@
+const { BlocklistPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/Blocklist.sys.mjs"
+);
+const { Utils: RemoteSettingsUtils } = ChromeUtils.importESModule(
+ "resource://services-settings/Utils.sys.mjs"
+);
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+
+const IS_ANDROID_WITH_BLOCKLIST_V2 =
+ AppConstants.platform == "android" && !AppConstants.NIGHTLY_BUILD;
+let gBlocklistClients;
+
+async function clear_state() {
+ RemoteSettings.enablePreviewMode(undefined);
+
+ for (let { client } of gBlocklistClients) {
+ // Remove last server times.
+ Services.prefs.clearUserPref(client.lastCheckTimePref);
+
+ // Clear local DB.
+ await client.db.clear();
+ }
+}
+
+add_task(async function setup() {
+ AddonTestUtils.createAppInfo(
+ "XPCShell",
+ "xpcshell@tests.mozilla.org",
+ "1",
+ ""
+ );
+
+ // This will initialize the remote settings clients for blocklists.
+ BlocklistPrivate.ExtensionBlocklistRS.ensureInitialized();
+ BlocklistPrivate.GfxBlocklistRS._ensureInitialized();
+
+ // ExtensionBlocklistMLBF is covered by test_blocklist_mlbf_dump.js.
+ gBlocklistClients = [
+ {
+ client: BlocklistPrivate.ExtensionBlocklistRS._client,
+ expectHasDump: IS_ANDROID_WITH_BLOCKLIST_V2,
+ },
+ {
+ client: BlocklistPrivate.GfxBlocklistRS._client,
+ expectHasDump: true,
+ },
+ ];
+
+ await promiseStartupManager();
+});
+
+add_task(
+ async function test_initial_dump_is_loaded_as_synced_when_collection_is_empty() {
+ for (let { client, expectHasDump } of gBlocklistClients) {
+ Assert.equal(
+ await RemoteSettingsUtils.hasLocalDump(
+ client.bucketName,
+ client.collectionName
+ ),
+ expectHasDump,
+ `Expected initial remote settings dump for ${client.collectionName}`
+ );
+ }
+ }
+);
+add_task(clear_state);
+
+add_task(async function test_data_is_filtered_for_target() {
+ const initial = [
+ {
+ guid: "foo",
+ matchName: "foo",
+ versionRange: [
+ {
+ targetApplication: [],
+ maxVersion: "*",
+ minVersion: "0",
+ severity: "1",
+ },
+ ],
+ },
+ ];
+ const noMatchingTarget = [
+ {
+ guid: "foo",
+ matchName: "foo",
+ versionRange: [
+ {
+ targetApplication: [{ guid: "Foo" }],
+ maxVersion: "*",
+ minVersion: "0",
+ severity: "3",
+ },
+ ],
+ },
+ {
+ guid: "foo",
+ matchName: "foo",
+ versionRange: [
+ {
+ targetApplication: [{ guid: "XPCShell", maxVersion: "0.1" }],
+ maxVersion: "*",
+ minVersion: "0",
+ severity: "1",
+ },
+ ],
+ },
+ ];
+ const oneMatch = [
+ {
+ guid: "foo",
+ matchName: "foo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: "XPCShell",
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ const records = initial.concat(noMatchingTarget).concat(oneMatch);
+
+ for (let { client } of gBlocklistClients) {
+ // Initialize the collection with some data
+ for (const record of records) {
+ await client.db.create(record);
+ }
+
+ const internalData = await client.db.list();
+ Assert.equal(internalData.length, records.length);
+ let filtered = await client.get({ syncIfEmpty: false });
+ Assert.equal(filtered.length, 2); // only two matches.
+ }
+});
+add_task(clear_state);
+
+add_task(
+ async function test_entries_are_filtered_when_jexl_filter_expression_is_present() {
+ const records = [
+ {
+ guid: "foo",
+ matchName: "foo",
+ willMatch: true,
+ },
+ {
+ guid: "foo",
+ matchName: "foo",
+ willMatch: true,
+ filter_expression: null,
+ },
+ {
+ guid: "foo",
+ matchName: "foo",
+ willMatch: true,
+ filter_expression: "1 == 1",
+ },
+ {
+ guid: "foo",
+ matchName: "foo",
+ willMatch: false,
+ filter_expression: "1 == 2",
+ },
+ {
+ guid: "foo",
+ matchName: "foo",
+ willMatch: true,
+ filter_expression: "1 == 1",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: "some-guid",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: "foo",
+ matchName: "foo",
+ willMatch: false, // jexl prevails over versionRange.
+ filter_expression: "1 == 2",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: "xpcshell@tests.mozilla.org",
+ minVersion: "0",
+ maxVersion: "*",
+ },
+ ],
+ },
+ ],
+ },
+ ];
+ for (let { client } of gBlocklistClients) {
+ for (const record of records) {
+ await client.db.create(record);
+ }
+ const list = await client.get({
+ loadDumpIfNewer: false,
+ syncIfEmpty: false,
+ });
+ equal(list.length, 4);
+ ok(list.every(e => e.willMatch));
+ }
+ }
+);
+add_task(clear_state);
+
+add_task(async function test_bucketname_changes_when_preview_mode_is_enabled() {
+ for (const { client } of gBlocklistClients) {
+ equal(client.bucketName, "blocklists");
+ }
+
+ RemoteSettings.enablePreviewMode(true);
+
+ for (const { client } of gBlocklistClients) {
+ equal(client.bucketName, "blocklists-preview", client.identifier);
+ }
+});
+add_task(clear_state);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_gfx.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_gfx.js
new file mode 100644
index 0000000000..2b243ec650
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_gfx.js
@@ -0,0 +1,113 @@
+const EVENT_NAME = "blocklist-data-gfxItems";
+
+const SAMPLE_GFX_RECORD = {
+ driverVersionComparator: "LESS_THAN_OR_EQUAL",
+ driverVersion: "8.17.12.5896",
+ vendor: "0x10de",
+ blockID: "g36",
+ feature: "DIRECT3D_9_LAYERS",
+ devices: ["0x0a6c", "geforce"],
+ featureStatus: "BLOCKED_DRIVER_VERSION",
+ last_modified: 9999999999999, // High timestamp to prevent load of dump
+ os: "WINNT 6.1",
+ id: "3f947f16-37c2-4e96-d356-78b26363729b",
+ versionRange: { minVersion: 0, maxVersion: "*" },
+};
+
+add_task(async function test_sends_serialized_data() {
+ const expected =
+ "blockID:g36\tdevices:0x0a6c,geforce\tdriverVersion:8.17.12.5896\t" +
+ "driverVersionComparator:LESS_THAN_OR_EQUAL\tfeature:DIRECT3D_9_LAYERS\t" +
+ "featureStatus:BLOCKED_DRIVER_VERSION\tos:WINNT 6.1\tvendor:0x10de\t" +
+ "versionRange:0,*";
+ let received;
+ const observe = (subject, topic, data) => {
+ received = data;
+ };
+ Services.obs.addObserver(observe, EVENT_NAME);
+ await mockGfxBlocklistItems([SAMPLE_GFX_RECORD]);
+ Services.obs.removeObserver(observe, EVENT_NAME);
+
+ equal(received, expected);
+});
+
+add_task(async function test_parsing_skips_devices_with_comma() {
+ let clonedItem = Cu.cloneInto(SAMPLE_GFX_RECORD, this);
+ clonedItem.devices[0] = "0x2,582";
+ let rv = await mockGfxBlocklistItems([clonedItem]);
+ equal(rv[0].devices.length, 1);
+ equal(rv[0].devices[0], "geforce");
+});
+
+add_task(async function test_empty_values_are_ignored() {
+ let received;
+ const observe = (subject, topic, data) => {
+ received = data;
+ };
+ Services.obs.addObserver(observe, EVENT_NAME);
+ let clonedItem = Cu.cloneInto(SAMPLE_GFX_RECORD, this);
+ clonedItem.os = "";
+ await mockGfxBlocklistItems([clonedItem]);
+ ok(!received.includes("os"), "Shouldn't send empty values");
+ Services.obs.removeObserver(observe, EVENT_NAME);
+});
+
+add_task(async function test_empty_devices_are_ignored() {
+ let received;
+ const observe = (subject, topic, data) => {
+ received = data;
+ };
+ Services.obs.addObserver(observe, EVENT_NAME);
+ let clonedItem = Cu.cloneInto(SAMPLE_GFX_RECORD, this);
+ clonedItem.devices = [];
+ await mockGfxBlocklistItems([clonedItem]);
+ ok(!received.includes("devices"), "Shouldn't send empty values");
+ Services.obs.removeObserver(observe, EVENT_NAME);
+});
+
+add_task(async function test_version_range_default_values() {
+ const kTests = [
+ {
+ input: { minVersion: "13.0b2", maxVersion: "42.0" },
+ output: { minVersion: "13.0b2", maxVersion: "42.0" },
+ },
+ {
+ input: { maxVersion: "2.0" },
+ output: { minVersion: "0", maxVersion: "2.0" },
+ },
+ {
+ input: { minVersion: "1.0" },
+ output: { minVersion: "1.0", maxVersion: "*" },
+ },
+ {
+ input: { minVersion: " " },
+ output: { minVersion: "0", maxVersion: "*" },
+ },
+ {
+ input: {},
+ output: { minVersion: "0", maxVersion: "*" },
+ },
+ ];
+ for (let test of kTests) {
+ let parsedEntries = await mockGfxBlocklistItems([
+ { versionRange: test.input },
+ ]);
+ equal(parsedEntries[0].versionRange.minVersion, test.output.minVersion);
+ equal(parsedEntries[0].versionRange.maxVersion, test.output.maxVersion);
+ }
+});
+
+add_task(async function test_blockid_attribute() {
+ const kTests = [
+ { blockID: "g60", vendor: " 0x10de " },
+ { feature: " DIRECT3D_9_LAYERS " },
+ ];
+ for (let test of kTests) {
+ let [rv] = await mockGfxBlocklistItems([test]);
+ if (test.blockID) {
+ equal(rv.blockID, test.blockID);
+ } else {
+ ok(!rv.hasOwnProperty("blockID"), "not expecting a blockID");
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_metadata_filters.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_metadata_filters.js
new file mode 100644
index 0000000000..8f7ecbdf29
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_metadata_filters.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests blocking of extensions by ID, name, creator, homepageURL, updateURL
+// and RegExps for each. See bug 897735.
+
+// useMLBF=true only supports blocking by version+ID, not by other fields.
+enable_blocklist_v2_instead_of_useMLBF();
+
+const BLOCKLIST_DATA = {
+ extensions: [
+ {
+ guid: null,
+ name: "/^Mozilla Corp\\.$/",
+ versionRange: [
+ {
+ severity: "1",
+ targetApplication: [
+ {
+ guid: "xpcshell@tests.mozilla.org",
+ maxVersion: "2.*",
+ minVersion: "1",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: "/block2/",
+ name: "/^Moz/",
+ homepageURL: "/\\.dangerous\\.com/",
+ updateURL: "/\\.dangerous\\.com/",
+ versionRange: [
+ {
+ severity: "3",
+ targetApplication: [
+ {
+ guid: "xpcshell@tests.mozilla.org",
+ maxVersion: "2.*",
+ minVersion: "1",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+};
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+ await promiseStartupManager();
+
+ // Should get blocked by name
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Mozilla Corp.",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: "block1@tests.mozilla.org" } },
+ },
+ });
+
+ // Should get blocked by all the attributes.
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Moz-addon",
+ version: "1.0",
+ homepage_url: "https://www.extension.dangerous.com/",
+ browser_specific_settings: {
+ gecko: {
+ id: "block2@tests.mozilla.org",
+ update_url: "https://www.extension.dangerous.com/update.json",
+ },
+ },
+ },
+ });
+
+ // Fails to get blocked because of a different ID even though other
+ // attributes match against a blocklist entry.
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Moz-addon",
+ version: "1.0",
+ homepage_url: "https://www.extension.dangerous.com/",
+ browser_specific_settings: {
+ gecko: {
+ id: "block3@tests.mozilla.org",
+ update_url: "https://www.extension.dangerous.com/update.json",
+ },
+ },
+ },
+ });
+
+ let [a1, a2, a3] = await AddonManager.getAddonsByIDs([
+ "block1@tests.mozilla.org",
+ "block2@tests.mozilla.org",
+ "block3@tests.mozilla.org",
+ ]);
+ Assert.equal(a1.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ Assert.equal(a2.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ Assert.equal(a3.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+});
+
+add_task(async function test_blocks() {
+ await AddonTestUtils.loadBlocklistRawData(BLOCKLIST_DATA);
+
+ let [a1, a2, a3] = await AddonManager.getAddonsByIDs([
+ "block1@tests.mozilla.org",
+ "block2@tests.mozilla.org",
+ "block3@tests.mozilla.org",
+ ]);
+ Assert.equal(a1.blocklistState, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ Assert.equal(a2.blocklistState, Ci.nsIBlocklistService.STATE_BLOCKED);
+ Assert.equal(a3.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf.js
new file mode 100644
index 0000000000..1f36fc046d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf.js
@@ -0,0 +1,267 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("extensions.blocklist.useMLBF", true);
+
+const ExtensionBlocklistMLBF = getExtensionBlocklistMLBF();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+AddonTestUtils.useRealCertChecks = true;
+
+// A real, signed XPI for use in the test.
+const SIGNED_ADDON_XPI_FILE = do_get_file("../data/webext-implicit-id.xpi");
+const SIGNED_ADDON_ID = "webext_implicit_id@tests.mozilla.org";
+const SIGNED_ADDON_VERSION = "1.0";
+const SIGNED_ADDON_KEY = `${SIGNED_ADDON_ID}:${SIGNED_ADDON_VERSION}`;
+const SIGNED_ADDON_SIGN_TIME = 1459980789000; // notBefore of certificate.
+
+// A real, signed sitepermission XPI for use in the test.
+const SIGNED_SITEPERM_XPI_FILE = do_get_file("webmidi_permission.xpi");
+const SIGNED_SITEPERM_ADDON_ID = "webmidi@test.mozilla.org";
+const SIGNED_SITEPERM_ADDON_VERSION = "1.0.2";
+const SIGNED_SITEPERM_KEY = `${SIGNED_SITEPERM_ADDON_ID}:${SIGNED_SITEPERM_ADDON_VERSION}`;
+const SIGNED_SITEPERM_SIGN_TIME = 1637606460000; // notBefore of certificate.
+
+function mockMLBF({ blocked = [], notblocked = [], generationTime }) {
+ // Mock _fetchMLBF to be able to have a deterministic cascade filter.
+ ExtensionBlocklistMLBF._fetchMLBF = async () => {
+ return {
+ cascadeFilter: {
+ has(blockKey) {
+ if (blocked.includes(blockKey)) {
+ return true;
+ }
+ if (notblocked.includes(blockKey)) {
+ return false;
+ }
+ throw new Error(`Block entry must explicitly be listed: ${blockKey}`);
+ },
+ },
+ generationTime,
+ };
+ };
+}
+
+add_task(async function setup() {
+ await promiseStartupManager();
+ mockMLBF({});
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [MLBF_RECORD],
+ });
+});
+
+// Checks: Initially unblocked, then blocked, then unblocked again.
+add_task(async function signed_xpi_initially_unblocked() {
+ mockMLBF({
+ blocked: [],
+ notblocked: [SIGNED_ADDON_KEY],
+ generationTime: SIGNED_ADDON_SIGN_TIME + 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+
+ await promiseInstallFile(SIGNED_ADDON_XPI_FILE);
+
+ let addon = await promiseAddonByID(SIGNED_ADDON_ID);
+ Assert.equal(addon.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ mockMLBF({
+ blocked: [SIGNED_ADDON_KEY],
+ notblocked: [],
+ generationTime: SIGNED_ADDON_SIGN_TIME + 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+ Assert.equal(addon.blocklistState, Ci.nsIBlocklistService.STATE_BLOCKED);
+ Assert.deepEqual(
+ await Blocklist.getAddonBlocklistEntry(addon),
+ {
+ state: Ci.nsIBlocklistService.STATE_BLOCKED,
+ url: "https://addons.mozilla.org/en-US/xpcshell/blocked-addon/webext_implicit_id@tests.mozilla.org/1.0/",
+ },
+ "Blocked addon should have blocked entry"
+ );
+
+ mockMLBF({
+ blocked: [SIGNED_ADDON_KEY],
+ notblocked: [],
+ // MLBF generationTime is older, so "blocked" entry should not apply.
+ generationTime: SIGNED_ADDON_SIGN_TIME - 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+ Assert.equal(addon.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await addon.uninstall();
+});
+
+// Checks: Initially blocked on install, then unblocked.
+add_task(async function signed_xpi_blocked_on_install() {
+ mockMLBF({
+ blocked: [SIGNED_ADDON_KEY],
+ notblocked: [],
+ generationTime: SIGNED_ADDON_SIGN_TIME + 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+
+ await promiseInstallFile(SIGNED_ADDON_XPI_FILE);
+ let addon = await promiseAddonByID(SIGNED_ADDON_ID);
+ Assert.equal(addon.blocklistState, Ci.nsIBlocklistService.STATE_BLOCKED);
+ Assert.ok(addon.appDisabled, "Blocked add-on is disabled on install");
+
+ mockMLBF({
+ blocked: [],
+ notblocked: [SIGNED_ADDON_KEY],
+ generationTime: SIGNED_ADDON_SIGN_TIME - 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+ Assert.equal(addon.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ Assert.ok(!addon.appDisabled, "Re-enabled after unblock");
+
+ await addon.uninstall();
+});
+
+// An unsigned add-on cannot be blocked.
+add_task(async function unsigned_not_blocked() {
+ const UNSIGNED_ADDON_ID = "not-signed@tests.mozilla.org";
+ const UNSIGNED_ADDON_VERSION = "1.0";
+ const UNSIGNED_ADDON_KEY = `${UNSIGNED_ADDON_ID}:${UNSIGNED_ADDON_VERSION}`;
+ mockMLBF({
+ blocked: [UNSIGNED_ADDON_KEY],
+ notblocked: [],
+ generationTime: SIGNED_ADDON_SIGN_TIME + 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+
+ let unsignedAddonFile = createTempWebExtensionFile({
+ manifest: {
+ version: UNSIGNED_ADDON_VERSION,
+ browser_specific_settings: { gecko: { id: UNSIGNED_ADDON_ID } },
+ },
+ });
+
+ // Unsigned add-ons can generally only be loaded as a temporary install.
+ let [addon] = await Promise.all([
+ AddonManager.installTemporaryAddon(unsignedAddonFile),
+ promiseWebExtensionStartup(UNSIGNED_ADDON_ID),
+ ]);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+ Assert.equal(addon.signedDate, null);
+ Assert.equal(addon.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(addon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Unsigned temporary add-on is not blocked"
+ );
+ await addon.uninstall();
+});
+
+// To make sure that unsigned_not_blocked did not trivially pass, we also check
+// that add-ons can actually be blocked when installed as a temporary add-on.
+add_task(async function signed_temporary() {
+ mockMLBF({
+ blocked: [SIGNED_ADDON_KEY],
+ notblocked: [],
+ generationTime: SIGNED_ADDON_SIGN_TIME + 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+
+ await Assert.rejects(
+ AddonManager.installTemporaryAddon(SIGNED_ADDON_XPI_FILE),
+ /Add-on webext_implicit_id@tests.mozilla.org is not compatible with application version/,
+ "Blocklisted add-on cannot be installed"
+ );
+});
+
+// A privileged add-on cannot be blocked by the MLBF.
+// It can still be blocked by a stash, which is tested in
+// privileged_addon_blocked_by_stash in test_blocklist_mlbf_stashes.js.
+add_task(async function privileged_xpi_not_blocked() {
+ mockMLBF({
+ blocked: ["test@tests.mozilla.org:2.0"],
+ notblocked: [],
+ generationTime: 1546297200000, // 1 jan 2019 = after the cert's notBefore
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+
+ await promiseInstallFile(
+ do_get_file("../data/signing_checks/privileged.xpi")
+ );
+ let addon = await promiseAddonByID("test@tests.mozilla.org");
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_PRIVILEGED);
+ Assert.equal(addon.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ await addon.uninstall();
+});
+
+// Langpacks cannot be blocked via the MLBF on Nightly.
+// It can still be blocked by a stash, which is tested in
+// langpack_blocked_by_stash in test_blocklist_mlbf_stashes.js.
+add_task(async function langpack_not_blocked_on_Nightly() {
+ mockMLBF({
+ blocked: ["langpack-klingon@firefox.mozilla.org:1.0"],
+ notblocked: [],
+ generationTime: 1546297200000, // 1 jan 2019 = after the cert's notBefore
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+
+ await promiseInstallFile(
+ do_get_file("../data/signing_checks/langpack_signed.xpi")
+ );
+ let addon = await promiseAddonByID("langpack-klingon@firefox.mozilla.org");
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_SIGNED);
+ if (AppConstants.NIGHTLY_BUILD) {
+ // Langpacks built for Nightly are currently signed by releng and not
+ // submitted to AMO, so we have to ignore the blocks of the MLBF.
+ Assert.equal(
+ addon.blocklistState,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Langpacks cannot be blocked via the MLBF"
+ );
+ } else {
+ // On non-Nightly, langpacks are submitted through AMO so we will enforce
+ // the MLBF blocklist for them.
+ Assert.equal(addon.blocklistState, Ci.nsIBlocklistService.STATE_BLOCKED);
+ }
+ await addon.uninstall();
+});
+
+// Checks: Signed sitepermission addon, initially blocked on install, then unblocked.
+add_task(async function signed_sitepermission_xpi_blocked_on_install() {
+ mockMLBF({
+ blocked: [SIGNED_SITEPERM_KEY],
+ notblocked: [],
+ generationTime: SIGNED_SITEPERM_SIGN_TIME + 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+
+ await promiseInstallFile(SIGNED_SITEPERM_XPI_FILE);
+ let addon = await promiseAddonByID(SIGNED_SITEPERM_ADDON_ID);
+ // NOTE: if this assertion fails, then SIGNED_SITEPERM_SIGN_TIME has to be
+ // updated accordingly otherwise the addon would not be blocked on install
+ // as this test expects (using the value got from `addon.signedDate.getTime()`)
+ equal(
+ addon.signedDate?.getTime(),
+ SIGNED_SITEPERM_SIGN_TIME,
+ "The addon xpi has the expected signedDate timestamp"
+ );
+ Assert.equal(
+ addon.blocklistState,
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Got the expected STATE_BLOCKED blocklistState"
+ );
+ Assert.ok(addon.appDisabled, "Blocked add-on is disabled on install");
+
+ mockMLBF({
+ blocked: [],
+ notblocked: [SIGNED_SITEPERM_KEY],
+ generationTime: SIGNED_SITEPERM_SIGN_TIME - 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+ Assert.equal(
+ addon.blocklistState,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Got the expected STATE_NOT_BLOCKED blocklistState"
+ );
+ Assert.ok(!addon.appDisabled, "Re-enabled after unblock");
+
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_dump.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_dump.js
new file mode 100644
index 0000000000..3cbd607339
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_dump.js
@@ -0,0 +1,156 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * @fileOverview Verifies that the MLBF dump of the addons blocklist is
+ * correctly registered.
+ */
+
+Services.prefs.setBoolPref("extensions.blocklist.useMLBF", true);
+
+const ExtensionBlocklistMLBF = getExtensionBlocklistMLBF();
+
+// A known blocked version from bug 1626602.
+const blockedAddon = {
+ id: "{6f62927a-e380-401a-8c9e-c485b7d87f0d}",
+ version: "9.2.0",
+ // The following date is the date of the first checked in MLBF. Any MLBF
+ // generated in the future should be generated after this date, to be useful.
+ signedDate: new Date(1588098908496), // 2020-04-28 (dummy date)
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+};
+
+// A known add-on that is not blocked, as of writing. It is likely not going
+// to be blocked because it does not have any executable code.
+const nonBlockedAddon = {
+ id: "disable-ctrl-q-and-cmd-q@robwu.nl",
+ version: "1",
+ signedDate: new Date(1482430349000), // 2016-12-22 (actual signing time).
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+};
+
+async function sha256(arrayBuffer) {
+ Cu.importGlobalProperties(["crypto"]);
+ let hash = await crypto.subtle.digest("SHA-256", arrayBuffer);
+ const toHex = b => b.toString(16).padStart(2, "0");
+ return Array.from(new Uint8Array(hash), toHex).join("");
+}
+
+// A list of { inputRecord, downloadPromise }:
+// - inputRecord is the record that was used for looking up the MLBF.
+// - downloadPromise is the result of trying to download it.
+const observed = [];
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ ExtensionBlocklistMLBF.ensureInitialized();
+
+ // Tapping into the internals of ExtensionBlocklistMLBF._fetchMLBF to observe
+ // MLBF request details.
+
+ // Despite being called "download", this does not actually access the network
+ // when there is a valid dump.
+ const originalImpl = ExtensionBlocklistMLBF._client.attachments.download;
+ ExtensionBlocklistMLBF._client.attachments.download = function (record) {
+ let downloadPromise = originalImpl.apply(this, arguments);
+ observed.push({ inputRecord: record, downloadPromise });
+ return downloadPromise;
+ };
+
+ await promiseStartupManager();
+});
+
+async function verifyBlocklistWorksWithDump() {
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddon),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "A add-on that is known to be on the blocklist should be blocked"
+ );
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(nonBlockedAddon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "A known non-blocked add-on should not be blocked"
+ );
+}
+
+add_task(async function verify_dump_first_run() {
+ await verifyBlocklistWorksWithDump();
+ Assert.equal(observed.length, 1, "expected number of MLBF download requests");
+
+ const { inputRecord, downloadPromise } = observed.pop();
+
+ Assert.ok(inputRecord, "addons-bloomfilters collection dump exists");
+
+ const downloadResult = await downloadPromise;
+
+ // Verify that the "download" result really originates from the local dump.
+ // "dump_match" means that the record exists in the collection and that an
+ // attachment was found.
+ //
+ // If this fails:
+ // - "dump_fallback" means that the MLBF attachment is out of sync with the
+ // collection data.
+ // - undefined could mean that the implementation of Attachments.sys.mjs changed.
+ Assert.equal(
+ downloadResult._source,
+ "dump_match",
+ "MLBF attachment should match the RemoteSettings collection"
+ );
+
+ Assert.equal(
+ await sha256(downloadResult.buffer),
+ inputRecord.attachment.hash,
+ "The content of the attachment should actually matches the record"
+ );
+});
+
+add_task(async function use_dump_fallback_when_collection_is_out_of_sync() {
+ await AddonTestUtils.loadBlocklistRawData({
+ // last_modified higher than any value in addons-bloomfilters.json.
+ extensionsMLBF: [{ last_modified: Date.now() }],
+ });
+ Assert.equal(observed.length, 1, "Expected new download on update");
+
+ const { inputRecord, downloadPromise } = observed.pop();
+ Assert.equal(inputRecord, null, "No MLBF record found");
+
+ const downloadResult = await downloadPromise;
+ Assert.equal(
+ downloadResult._source,
+ "dump_fallback",
+ "should have used fallback despite the absence of a MLBF record"
+ );
+
+ await verifyBlocklistWorksWithDump();
+ Assert.equal(observed.length, 0, "Blocklist uses cached result");
+});
+
+// Verifies that the dump would supersede local data. This can happen after an
+// application upgrade, where the local database contains outdated records from
+// a previous application version.
+add_task(async function verify_dump_supersedes_old_dump() {
+ // Delete in-memory value; otherwise the cached record from the previous test
+ // task would be re-used and nothing would be downloaded.
+ delete ExtensionBlocklistMLBF._mlbfData;
+
+ await AddonTestUtils.loadBlocklistRawData({
+ // last_modified lower than any value in addons-bloomfilters.json.
+ extensionsMLBF: [{ last_modified: 1 }],
+ });
+ Assert.equal(observed.length, 1, "Expected new download on update");
+
+ const { inputRecord, downloadPromise } = observed.pop();
+ Assert.ok(inputRecord, "should have read from addons-bloomfilters dump");
+
+ const downloadResult = await downloadPromise;
+ Assert.equal(
+ downloadResult._source,
+ "dump_match",
+ "Should have replaced outdated collection records with dump"
+ );
+
+ await verifyBlocklistWorksWithDump();
+ Assert.equal(observed.length, 0, "Blocklist uses cached result");
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_fetch.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_fetch.js
new file mode 100644
index 0000000000..92bf61dbde
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_fetch.js
@@ -0,0 +1,231 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * @fileOverview Tests the MLBF and RemoteSettings synchronization logic.
+ */
+
+Services.prefs.setBoolPref("extensions.blocklist.useMLBF", true);
+
+const { Downloader } = ChromeUtils.importESModule(
+ "resource://services-settings/Attachments.sys.mjs"
+);
+
+const ExtensionBlocklistMLBF = getExtensionBlocklistMLBF();
+
+// This test needs to interact with the RemoteSettings client.
+ExtensionBlocklistMLBF.ensureInitialized();
+
+add_task(async function fetch_invalid_mlbf_record() {
+ let invalidRecord = {
+ attachment: { size: 1, hash: "definitely not valid" },
+ generation_time: 1,
+ };
+
+ // _fetchMLBF(invalidRecord) may succeed if there is a MLBF dump packaged with
+ // the application. This test intentionally hides the actual path to get
+ // deterministic results. To check whether the dump is correctly registered,
+ // run test_blocklist_mlbf_dump.js
+
+ // Forget about the packaged attachment.
+ Downloader._RESOURCE_BASE_URL = "invalid://bogus";
+ // NetworkError is expected here. The JSON.parse error could be triggered via
+ // _baseAttachmentsURL < downloadAsBytes < download < download < _fetchMLBF if
+ // the request to services.settings.server ("data:,#remote-settings-dummy/v1")
+ // is fulfilled (but with invalid JSON). That request is not expected to be
+ // fulfilled in the first place, but that is not a concern of this test.
+ // This test passes if _fetchMLBF() rejects when given an invalid record.
+ await Assert.rejects(
+ ExtensionBlocklistMLBF._fetchMLBF(invalidRecord),
+ /NetworkError|SyntaxError: JSON\.parse/,
+ "record not found when there is no packaged MLBF"
+ );
+});
+
+// Other tests can mock _testMLBF, so let's verify that it works as expected.
+add_task(async function fetch_valid_mlbf() {
+ await ExtensionBlocklistMLBF._client.db.saveAttachment(
+ ExtensionBlocklistMLBF.RS_ATTACHMENT_ID,
+ { record: MLBF_RECORD, blob: await load_mlbf_record_as_blob() }
+ );
+
+ const result = await ExtensionBlocklistMLBF._fetchMLBF(MLBF_RECORD);
+ Assert.equal(result.cascadeHash, MLBF_RECORD.attachment.hash, "hash OK");
+ Assert.equal(result.generationTime, MLBF_RECORD.generation_time, "time OK");
+ Assert.ok(result.cascadeFilter.has("@blocked:1"), "item blocked");
+ Assert.ok(!result.cascadeFilter.has("@unblocked:2"), "item not blocked");
+
+ const result2 = await ExtensionBlocklistMLBF._fetchMLBF({
+ attachment: { size: 1, hash: "invalid" },
+ generation_time: Date.now(),
+ });
+ Assert.equal(
+ result2.cascadeHash,
+ MLBF_RECORD.attachment.hash,
+ "The cached MLBF should be used when the attachment is invalid"
+ );
+
+ // The attachment is kept in the database for use by the next test task.
+});
+
+// Test that results of the public API are consistent with the MLBF file.
+add_task(async function public_api_uses_mlbf() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ await promiseStartupManager();
+
+ const blockedAddon = {
+ id: "@blocked",
+ version: "1",
+ signedDate: new Date(0), // a date in the past, before MLBF's generationTime.
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+ };
+ const nonBlockedAddon = {
+ id: "@unblocked",
+ version: "2",
+ signedDate: new Date(0), // a date in the past, before MLBF's generationTime.
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+ };
+
+ await AddonTestUtils.loadBlocklistRawData({ extensionsMLBF: [MLBF_RECORD] });
+
+ Assert.deepEqual(
+ await Blocklist.getAddonBlocklistEntry(blockedAddon),
+ {
+ state: Ci.nsIBlocklistService.STATE_BLOCKED,
+ url: "https://addons.mozilla.org/en-US/xpcshell/blocked-addon/@blocked/1/",
+ },
+ "Blocked addon should have blocked entry"
+ );
+
+ Assert.deepEqual(
+ await Blocklist.getAddonBlocklistEntry(nonBlockedAddon),
+ null,
+ "Non-blocked addon should not be blocked"
+ );
+
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddon),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Blocked entry should have blocked state"
+ );
+
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(nonBlockedAddon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Non-blocked entry should have unblocked state"
+ );
+
+ // Note: Blocklist collection and attachment carries over to the next test.
+});
+
+// Verifies that the metadata (time of validity) of an updated MLBF record is
+// correctly used, even if the MLBF itself has not changed.
+add_task(async function fetch_updated_mlbf_same_hash() {
+ const recordUpdate = {
+ ...MLBF_RECORD,
+ generation_time: MLBF_RECORD.generation_time + 1,
+ };
+ const blockedAddonUpdate = {
+ id: "@blocked",
+ version: "1",
+ signedDate: new Date(recordUpdate.generation_time),
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+ };
+
+ // The blocklist already includes "@blocked:1", but the last specified
+ // generation time is MLBF_RECORD.generation_time. So the addon cannot be
+ // blocked, because the block decision could be a false positive.
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddonUpdate),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Add-on not blocked before blocklist update"
+ );
+
+ await AddonTestUtils.loadBlocklistRawData({ extensionsMLBF: [recordUpdate] });
+ // The MLBF is now known to apply to |blockedAddonUpdate|.
+
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddonUpdate),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Add-on blocked after update"
+ );
+
+ // Note: Blocklist collection and attachment carries over to the next test.
+});
+
+// Checks the remaining cases of database corruption that haven't been handled
+// before.
+add_task(async function handle_database_corruption() {
+ const blockedAddon = {
+ id: "@blocked",
+ version: "1",
+ signedDate: new Date(0), // a date in the past, before MLBF's generationTime.
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+ };
+ async function checkBlocklistWorks() {
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddon),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Add-on should be blocked by the blocklist"
+ );
+ }
+
+ let fetchCount = 0;
+ const originalFetchMLBF = ExtensionBlocklistMLBF._fetchMLBF;
+ ExtensionBlocklistMLBF._fetchMLBF = function () {
+ ++fetchCount;
+ return originalFetchMLBF.apply(this, arguments);
+ };
+
+ // In the fetch_invalid_mlbf_record we checked that a cached / packaged MLBF
+ // attachment is used as a fallback when the record is invalid. Here we also
+ // check that there is a fallback when there is no record at all.
+
+ // Include a dummy record in the list, to prevent RemoteSettings from
+ // importing a JSON dump with unexpected records.
+ await AddonTestUtils.loadBlocklistRawData({ extensionsMLBF: [{}] });
+ Assert.equal(fetchCount, 1, "MLBF read once despite bad record");
+ // When the collection is empty, the last known MLBF should be used anyway.
+ await checkBlocklistWorks();
+ Assert.equal(fetchCount, 1, "MLBF not read again by blocklist query");
+
+ // Now we also remove the cached file...
+ await ExtensionBlocklistMLBF._client.db.saveAttachment(
+ ExtensionBlocklistMLBF.RS_ATTACHMENT_ID,
+ null
+ );
+ Assert.equal(fetchCount, 1, "MLBF not read again after attachment deletion");
+ // Deleting the file shouldn't cause issues because the MLBF is loaded once
+ // and then kept in memory.
+ await checkBlocklistWorks();
+ Assert.equal(fetchCount, 1, "MLBF not read again by blocklist query 2");
+
+ // Force an update while we don't have any blocklist data nor cache.
+ await ExtensionBlocklistMLBF._onUpdate();
+ Assert.equal(fetchCount, 2, "MLBF read again at forced update");
+ // As a fallback, continue to use the in-memory version of the blocklist.
+ await checkBlocklistWorks();
+ Assert.equal(fetchCount, 2, "MLBF not read again by blocklist query 3");
+
+ // Memory gone, e.g. after a browser restart.
+ delete ExtensionBlocklistMLBF._mlbfData;
+ delete ExtensionBlocklistMLBF._stashes;
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Blocklist can't work if all blocklist data is gone"
+ );
+ Assert.equal(fetchCount, 3, "MLBF read again after restart/cleared cache");
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Blocklist can still not work if all blocklist data is gone"
+ );
+ // Ideally, the client packages a dump. But if the client did not package the
+ // dump, then it should not be trying to read the data over and over again.
+ Assert.equal(fetchCount, 3, "MLBF not read again despite absence of MLBF");
+
+ ExtensionBlocklistMLBF._fetchMLBF = originalFetchMLBF;
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_stashes.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_stashes.js
new file mode 100644
index 0000000000..e129efc793
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_stashes.js
@@ -0,0 +1,219 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("extensions.blocklist.useMLBF", true);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+const ExtensionBlocklistMLBF = getExtensionBlocklistMLBF();
+const MLBF_LOAD_ATTEMPTS = [];
+ExtensionBlocklistMLBF._fetchMLBF = async record => {
+ MLBF_LOAD_ATTEMPTS.push(record);
+ return {
+ generationTime: 0,
+ cascadeFilter: {
+ has(blockKey) {
+ if (blockKey === "@onlyblockedbymlbf:1") {
+ return true;
+ }
+ throw new Error("bloom filter should not be used in this test");
+ },
+ },
+ };
+};
+
+async function checkBlockState(addonId, version, expectBlocked) {
+ let addon = {
+ id: addonId,
+ version,
+ // Note: signedDate is missing, so the MLBF does not apply
+ // and we will effectively only test stashing.
+ };
+ let state = await Blocklist.getAddonBlocklistState(addon);
+ if (expectBlocked) {
+ Assert.equal(state, Ci.nsIBlocklistService.STATE_BLOCKED);
+ } else {
+ Assert.equal(state, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ }
+}
+
+add_task(async function setup() {
+ await promiseStartupManager();
+});
+
+// Tests that add-ons can be blocked / unblocked via the stash.
+add_task(async function basic_stash() {
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [
+ {
+ stash_time: 0,
+ stash: {
+ blocked: ["@blocked:1"],
+ unblocked: ["@notblocked:2"],
+ },
+ },
+ ],
+ });
+ await checkBlockState("@blocked", "1", true);
+ await checkBlockState("@notblocked", "2", false);
+ // Not in stash (but unsigned, so shouldn't reach MLBF):
+ await checkBlockState("@blocked", "2", false);
+
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState({
+ id: "@onlyblockedbymlbf",
+ version: "1",
+ signedDate: new Date(0), // = the MLBF's generationTime.
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+ }),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "falls through to MLBF if entry is not found in stash"
+ );
+
+ Assert.deepEqual(MLBF_LOAD_ATTEMPTS, [null], "MLBF attachment not found");
+});
+
+// To complement the privileged_xpi_not_blocked in test_blocklist_mlbf.js,
+// verify that privileged add-ons can still be blocked through stashes.
+add_task(async function privileged_addon_blocked_by_stash() {
+ const system_addon = {
+ id: "@blocked",
+ version: "1",
+ signedDate: new Date(0), // = the MLBF's generationTime.
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ };
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(system_addon),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Privileged add-ons can still be blocked by a stash"
+ );
+
+ system_addon.signedState = AddonManager.SIGNEDSTATE_SYSTEM;
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(system_addon),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Privileged system add-ons can still be blocked by a stash"
+ );
+
+ // For comparison, when an add-on is only blocked by a MLBF, the block
+ // decision is ignored.
+ system_addon.id = "@onlyblockedbymlbf";
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(system_addon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Privileged add-ons cannot be blocked via a MLBF"
+ );
+ // (note that we haven't checked that SIGNEDSTATE_PRIVILEGED is not blocked
+ // via the MLBF, but that is already covered by test_blocklist_mlbf.js ).
+});
+
+// To complement langpack_not_blocked_on_Nightly in test_blocklist_mlbf.js,
+// verify that langpacks can still be blocked through stashes.
+add_task(async function langpack_blocked_by_stash() {
+ const langpack_addon = {
+ id: "@blocked",
+ type: "locale",
+ version: "1",
+ signedDate: new Date(0), // = the MLBF's generationTime.
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+ };
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(langpack_addon),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Langpack add-ons can still be blocked by a stash"
+ );
+
+ // For comparison, when an add-on is only blocked by a MLBF, the block
+ // decision is ignored on Nightly (but blocked on non-Nightly).
+ langpack_addon.id = "@onlyblockedbymlbf";
+ if (AppConstants.NIGHTLY_BUILD) {
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(langpack_addon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Langpack add-ons cannot be blocked via a MLBF on Nightly"
+ );
+ } else {
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(langpack_addon),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Langpack add-ons can be blocked via a MLBF on non-Nightly"
+ );
+ }
+});
+
+// Tests that invalid stash entries are ignored.
+add_task(async function invalid_stashes() {
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [
+ {},
+ { stash: null },
+ { stash: 1 },
+ { stash: {} },
+ { stash: { blocked: ["@broken:1", "@okid:1"] } },
+ { stash: { unblocked: ["@broken:2"] } },
+ // The only correct entry:
+ { stash: { blocked: ["@okid:2"], unblocked: ["@okid:1"] } },
+ { stash: { blocked: ["@broken:1", "@okid:1"] } },
+ { stash: { unblocked: ["@broken:2", "@okid:2"] } },
+ ],
+ });
+ // The valid stash entry should be applied:
+ await checkBlockState("@okid", "1", false);
+ await checkBlockState("@okid", "2", true);
+ // Entries from invalid stashes should be ignored:
+ await checkBlockState("@broken", "1", false);
+ await checkBlockState("@broken", "2", false);
+});
+
+// Blocklist stashes should be processed in the reverse chronological order.
+add_task(async function stash_time_order() {
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [
+ // "@a:1" and "@a:2" are blocked at time 1, but unblocked later.
+ { stash_time: 2, stash: { blocked: [], unblocked: ["@a:1"] } },
+ { stash_time: 1, stash: { blocked: ["@a:1", "@a:2"], unblocked: [] } },
+ { stash_time: 3, stash: { blocked: [], unblocked: ["@a:2"] } },
+
+ // "@b:1" and "@b:2" are unblocked at time 4, but blocked later.
+ { stash_time: 5, stash: { blocked: ["@b:1"], unblocked: [] } },
+ { stash_time: 4, stash: { blocked: [], unblocked: ["@b:1", "@b:2"] } },
+ { stash_time: 6, stash: { blocked: ["@b:2"], unblocked: [] } },
+ ],
+ });
+ await checkBlockState("@a", "1", false);
+ await checkBlockState("@a", "2", false);
+
+ await checkBlockState("@b", "1", true);
+ await checkBlockState("@b", "2", true);
+});
+
+// Attachments with unsupported attachment_type should be ignored.
+add_task(async function mlbf_bloomfilter_full_ignored() {
+ MLBF_LOAD_ATTEMPTS.length = 0;
+
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [{ attachment_type: "bloomfilter-full", attachment: {} }],
+ });
+
+ // Only bloomfilter-base records should be used.
+ // Since there are no such records, we shouldn't find anything.
+ Assert.deepEqual(MLBF_LOAD_ATTEMPTS, [null], "no matching MLBFs found");
+});
+
+// Tests that the most recent MLBF is downloaded.
+add_task(async function mlbf_generation_time_recent() {
+ MLBF_LOAD_ATTEMPTS.length = 0;
+ const records = [
+ { attachment_type: "bloomfilter-base", attachment: {}, generation_time: 2 },
+ { attachment_type: "bloomfilter-base", attachment: {}, generation_time: 3 },
+ { attachment_type: "bloomfilter-base", attachment: {}, generation_time: 1 },
+ ];
+ await AddonTestUtils.loadBlocklistRawData({ extensionsMLBF: records });
+ Assert.equal(
+ MLBF_LOAD_ATTEMPTS[0].generation_time,
+ 3,
+ "expected to load most recent MLBF"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_telemetry.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_telemetry.js
new file mode 100644
index 0000000000..6ad8e9c2ac
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_telemetry.js
@@ -0,0 +1,188 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("extensions.blocklist.useMLBF", true);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+const { Downloader } = ChromeUtils.importESModule(
+ "resource://services-settings/Attachments.sys.mjs"
+);
+
+const { TelemetryController } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryController.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const OLDEST_STASH = { stash: { blocked: [], unblocked: [] }, stash_time: 2e6 };
+const NEWEST_STASH = { stash: { blocked: [], unblocked: [] }, stash_time: 5e6 };
+const RECORDS_WITH_STASHES_AND_MLBF = [MLBF_RECORD, OLDEST_STASH, NEWEST_STASH];
+
+const ExtensionBlocklistMLBF = getExtensionBlocklistMLBF();
+
+function assertTelemetryScalars(expectedScalars) {
+ // On Android, we only report to the Glean system telemetry system.
+ if (IS_ANDROID_BUILD) {
+ info(
+ `Skip assertions on collected samples for ${expectedScalars} on android builds`
+ );
+ return;
+ }
+ let scalars = TelemetryTestUtils.getProcessScalars("parent");
+ for (const scalarName of Object.keys(expectedScalars || {})) {
+ equal(
+ scalars[scalarName],
+ expectedScalars[scalarName],
+ `Got the expected value for ${scalarName} scalar`
+ );
+ }
+}
+
+function toUTC(time) {
+ return new Date(time).toUTCString();
+}
+
+add_task(async function setup() {
+ if (!IS_ANDROID_BUILD) {
+ // 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 TelemetryController.testSetup();
+ await promiseStartupManager();
+
+ // Disable the packaged record and attachment to make sure that the test
+ // will not fall back to the packaged attachments.
+ Downloader._RESOURCE_BASE_URL = "invalid://bogus";
+});
+
+add_task(async function test_initialization() {
+ resetBlocklistTelemetry();
+ ExtensionBlocklistMLBF.ensureInitialized();
+
+ Assert.equal(undefined, testGetValue(Glean.blocklist.mlbfSource));
+ Assert.equal(undefined, testGetValue(Glean.blocklist.mlbfGenerationTime));
+ Assert.equal(undefined, testGetValue(Glean.blocklist.mlbfStashTimeOldest));
+ Assert.equal(undefined, testGetValue(Glean.blocklist.mlbfStashTimeNewest));
+
+ assertTelemetryScalars({
+ // In other parts of this test, this value is not checked any more.
+ // test_blocklist_telemetry.js already checks lastModified_rs_addons_mlbf.
+ "blocklist.lastModified_rs_addons_mlbf": undefined,
+ "blocklist.mlbf_source": undefined,
+ "blocklist.mlbf_generation_time": undefined,
+ "blocklist.mlbf_stash_time_oldest": undefined,
+ "blocklist.mlbf_stash_time_newest": undefined,
+ });
+});
+
+// Test what happens if there is no blocklist data at all.
+add_task(async function test_without_mlbf() {
+ resetBlocklistTelemetry();
+ // Add one (invalid) value to the blocklist, to prevent the RemoteSettings
+ // client from importing the JSON dump (which could potentially cause the
+ // test to fail due to the unexpected imported records).
+ await AddonTestUtils.loadBlocklistRawData({ extensionsMLBF: [{}] });
+ Assert.equal("unknown", testGetValue(Glean.blocklist.mlbfSource));
+
+ Assert.equal(0, testGetValue(Glean.blocklist.mlbfGenerationTime).getTime());
+ Assert.equal(0, testGetValue(Glean.blocklist.mlbfStashTimeOldest).getTime());
+ Assert.equal(0, testGetValue(Glean.blocklist.mlbfStashTimeNewest).getTime());
+
+ assertTelemetryScalars({
+ "blocklist.mlbf_source": "unknown",
+ "blocklist.mlbf_generation_time": "Missing Date",
+ "blocklist.mlbf_stash_time_oldest": "Missing Date",
+ "blocklist.mlbf_stash_time_newest": "Missing Date",
+ });
+});
+
+// Test the telemetry that would be recorded in the common case.
+add_task(async function test_common_good_case_with_stashes() {
+ resetBlocklistTelemetry();
+ // The exact content of the attachment does not matter in this test, as long
+ // as the data is valid.
+ await ExtensionBlocklistMLBF._client.db.saveAttachment(
+ ExtensionBlocklistMLBF.RS_ATTACHMENT_ID,
+ { record: MLBF_RECORD, blob: await load_mlbf_record_as_blob() }
+ );
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: RECORDS_WITH_STASHES_AND_MLBF,
+ });
+ Assert.equal("cache_match", testGetValue(Glean.blocklist.mlbfSource));
+ Assert.equal(
+ MLBF_RECORD.generation_time,
+ testGetValue(Glean.blocklist.mlbfGenerationTime).getTime()
+ );
+ Assert.equal(
+ OLDEST_STASH.stash_time,
+ testGetValue(Glean.blocklist.mlbfStashTimeOldest).getTime()
+ );
+ Assert.equal(
+ NEWEST_STASH.stash_time,
+ testGetValue(Glean.blocklist.mlbfStashTimeNewest).getTime()
+ );
+ assertTelemetryScalars({
+ "blocklist.mlbf_source": "cache_match",
+ "blocklist.mlbf_generation_time": toUTC(MLBF_RECORD.generation_time),
+ "blocklist.mlbf_stash_time_oldest": toUTC(OLDEST_STASH.stash_time),
+ "blocklist.mlbf_stash_time_newest": toUTC(NEWEST_STASH.stash_time),
+ });
+
+ // The records and cached attachment carries over to the next tests.
+});
+
+// Test what happens when there are no stashes in the collection itself.
+add_task(async function test_without_stashes() {
+ resetBlocklistTelemetry();
+ await AddonTestUtils.loadBlocklistRawData({ extensionsMLBF: [MLBF_RECORD] });
+
+ Assert.equal("cache_match", testGetValue(Glean.blocklist.mlbfSource));
+ Assert.equal(
+ MLBF_RECORD.generation_time,
+ testGetValue(Glean.blocklist.mlbfGenerationTime).getTime()
+ );
+
+ Assert.equal(0, testGetValue(Glean.blocklist.mlbfStashTimeOldest).getTime());
+ Assert.equal(0, testGetValue(Glean.blocklist.mlbfStashTimeNewest).getTime());
+
+ assertTelemetryScalars({
+ "blocklist.mlbf_source": "cache_match",
+ "blocklist.mlbf_generation_time": toUTC(MLBF_RECORD.generation_time),
+ "blocklist.mlbf_stash_time_oldest": "Missing Date",
+ "blocklist.mlbf_stash_time_newest": "Missing Date",
+ });
+});
+
+// Test what happens when the collection was inadvertently emptied,
+// but still with a cached mlbf from before.
+add_task(async function test_without_collection_but_cache() {
+ resetBlocklistTelemetry();
+ await AddonTestUtils.loadBlocklistRawData({
+ // Insert a dummy record with a value of last_modified which is higher than
+ // any value of last_modified in addons-bloomfilters.json, to prevent the
+ // blocklist implementation from automatically falling back to the packaged
+ // JSON dump.
+ extensionsMLBF: [{ last_modified: Date.now() }],
+ });
+ Assert.equal("cache_fallback", testGetValue(Glean.blocklist.mlbfSource));
+ Assert.equal(
+ MLBF_RECORD.generation_time,
+ testGetValue(Glean.blocklist.mlbfGenerationTime).getTime()
+ );
+
+ Assert.equal(0, testGetValue(Glean.blocklist.mlbfStashTimeOldest).getTime());
+ Assert.equal(0, testGetValue(Glean.blocklist.mlbfStashTimeNewest).getTime());
+
+ assertTelemetryScalars({
+ "blocklist.mlbf_source": "cache_fallback",
+ "blocklist.mlbf_generation_time": toUTC(MLBF_RECORD.generation_time),
+ "blocklist.mlbf_stash_time_oldest": "Missing Date",
+ "blocklist.mlbf_stash_time_newest": "Missing Date",
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_update.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_update.js
new file mode 100644
index 0000000000..b98d6e345d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_update.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * @fileOverview Checks that the MLBF updating logic works reasonably.
+ */
+
+Services.prefs.setBoolPref("extensions.blocklist.useMLBF", true);
+const ExtensionBlocklistMLBF = getExtensionBlocklistMLBF();
+
+// This test needs to interact with the RemoteSettings client.
+ExtensionBlocklistMLBF.ensureInitialized();
+
+// Multiple internal calls to update should be coalesced and end up with the
+// MLBF attachment from the last update call.
+add_task(async function collapse_multiple_pending_update_requests() {
+ const observed = [];
+
+ // The first step of starting an update is to read from the RemoteSettings
+ // collection. When a non-forced update is requested while another update is
+ // pending, the non-forced update should return/await the previous call
+ // instead of starting a new read/fetch from the RemoteSettings collection.
+ // Add a spy to the RemoteSettings client, so we can verify that the number
+ // of RemoteSettings accesses matches with what we expect.
+ const originalClientGet = ExtensionBlocklistMLBF._client.get;
+ const spyClientGet = (tag, returnValue) => {
+ ExtensionBlocklistMLBF._client.get = async function () {
+ // Record the method call.
+ observed.push(tag);
+ // Clone a valid record and tag it so we can identify it below.
+ let dummyRecord = JSON.parse(JSON.stringify(MLBF_RECORD));
+ dummyRecord.tagged = tag;
+ return [dummyRecord];
+ };
+ };
+
+ // Another significant part of updating is fetching the MLBF attachment.
+ // Add a spy too, so we can check which attachment is being requested.
+ const originalFetchMLBF = ExtensionBlocklistMLBF._fetchMLBF;
+ ExtensionBlocklistMLBF._fetchMLBF = async function (record) {
+ observed.push(`fetchMLBF:${record.tagged}`);
+ throw new Error(`Deliberately ignoring call to MLBF:${record.tagged}`);
+ };
+
+ spyClientGet("initial"); // Very first call = read RS.
+ let update1 = ExtensionBlocklistMLBF._updateMLBF(false);
+ spyClientGet("unexpected update2"); // Non-forced update = reuse update1.
+ let update2 = ExtensionBlocklistMLBF._updateMLBF(false);
+ spyClientGet("forced1"); // forceUpdate=true = supersede previous update.
+ let forcedUpdate1 = ExtensionBlocklistMLBF._updateMLBF(true);
+ spyClientGet("forced2"); // forceUpdate=true = supersede previous update.
+ let forcedUpdate2 = ExtensionBlocklistMLBF._updateMLBF(true);
+
+ let res = await Promise.all([update1, update2, forcedUpdate1, forcedUpdate2]);
+
+ Assert.equal(observed.length, 4, "expected number of observed events");
+ Assert.equal(observed[0], "initial", "First update should request records");
+ Assert.equal(observed[1], "forced1", "Forced update supersedes initial");
+ Assert.equal(observed[2], "forced2", "Forced update supersedes forced1");
+ // We call the _updateMLBF methods immediately after each other. Every update
+ // request starts with an asynchronous operation (looking up the RS records),
+ // so the implementation should return early for all update requests except
+ // for the last one. So we should only observe a fetch for the last request.
+ Assert.equal(observed[3], "fetchMLBF:forced2", "expected fetch result");
+
+ // All update requests should end up with the same result.
+ Assert.equal(res[0], res[1], "update1 == update2");
+ Assert.equal(res[1], res[2], "update2 == forcedUpdate1");
+ Assert.equal(res[2], res[3], "forcedUpdate1 == forcedUpdate2");
+
+ ExtensionBlocklistMLBF._client.get = originalClientGet;
+ ExtensionBlocklistMLBF._fetchMLBF = originalFetchMLBF;
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_osabi.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_osabi.js
new file mode 100644
index 0000000000..243225c6e0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_osabi.js
@@ -0,0 +1,286 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+// useMLBF=true only supports blocking by version+ID, not by OS/ABI.
+enable_blocklist_v2_instead_of_useMLBF();
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const ADDONS = [
+ {
+ id: "test_bug393285_1@tests.mozilla.org",
+ name: "extension 1",
+ version: "1.0",
+
+ // No info in blocklist, shouldn't be blocked
+ notBlocklisted: [
+ ["1", "1.9"],
+ [null, null],
+ ],
+ },
+ {
+ id: "test_bug393285_2@tests.mozilla.org",
+ name: "extension 2",
+ version: "1.0",
+
+ // Should always be blocked
+ blocklisted: [
+ ["1", "1.9"],
+ [null, null],
+ ],
+ },
+ {
+ id: "test_bug393285_3a@tests.mozilla.org",
+ name: "extension 3a",
+ version: "1.0",
+
+ // Only version 1 should be blocked
+ blocklisted: [
+ ["1", "1.9"],
+ [null, null],
+ ],
+ },
+ {
+ id: "test_bug393285_3b@tests.mozilla.org",
+ name: "extension 3b",
+ version: "2.0",
+
+ // Only version 1 should be blocked
+ notBlocklisted: [["1", "1.9"]],
+ },
+ {
+ id: "test_bug393285_4@tests.mozilla.org",
+ name: "extension 4",
+ version: "1.0",
+
+ // Should be blocked for app version 1
+ blocklisted: [
+ ["1", "1.9"],
+ [null, null],
+ ],
+ notBlocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_5@tests.mozilla.org",
+ name: "extension 5",
+ version: "1.0",
+
+ // Not blocklisted because we are a different OS
+ notBlocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_6@tests.mozilla.org",
+ name: "extension 6",
+ version: "1.0",
+
+ // Blocklisted based on OS
+ blocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_7@tests.mozilla.org",
+ name: "extension 7",
+ version: "1.0",
+
+ // Blocklisted based on OS
+ blocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_8@tests.mozilla.org",
+ name: "extension 8",
+ version: "1.0",
+
+ // Not blocklisted because we are a different ABI
+ notBlocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_9@tests.mozilla.org",
+ name: "extension 9",
+ version: "1.0",
+
+ // Blocklisted based on ABI
+ blocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_10@tests.mozilla.org",
+ name: "extension 10",
+ version: "1.0",
+
+ // Blocklisted based on ABI
+ blocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_11@tests.mozilla.org",
+ name: "extension 11",
+ version: "1.0",
+
+ // Doesn't match both os and abi so not blocked
+ notBlocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_12@tests.mozilla.org",
+ name: "extension 12",
+ version: "1.0",
+
+ // Doesn't match both os and abi so not blocked
+ notBlocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_13@tests.mozilla.org",
+ name: "extension 13",
+ version: "1.0",
+
+ // Doesn't match both os and abi so not blocked
+ notBlocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_14@tests.mozilla.org",
+ name: "extension 14",
+ version: "1.0",
+
+ // Matches both os and abi so blocked
+ blocklisted: [["2", "1.9"]],
+ },
+];
+
+const ADDON_IDS = ADDONS.map(a => a.id);
+
+const BLOCKLIST_DATA = [
+ {
+ guid: "test_bug393285_2@tests.mozilla.org",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_3a@tests.mozilla.org",
+ versionRange: [{ maxVersion: "1.0", minVersion: "1.0" }],
+ },
+ {
+ guid: "test_bug393285_3b@tests.mozilla.org",
+ versionRange: [{ maxVersion: "1.0", minVersion: "1.0" }],
+ },
+ {
+ guid: "test_bug393285_4@tests.mozilla.org",
+ versionRange: [
+ {
+ maxVersion: "1.0",
+ minVersion: "1.0",
+ targetApplication: [
+ {
+ guid: "xpcshell@tests.mozilla.org",
+ maxVersion: "1.0",
+ minVersion: "1.0",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: "test_bug393285_5@tests.mozilla.org",
+ os: "Darwin",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_6@tests.mozilla.org",
+ os: "XPCShell",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_7@tests.mozilla.org",
+ os: "Darwin,XPCShell,WINNT",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_8@tests.mozilla.org",
+ xpcomabi: "x86-msvc",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_9@tests.mozilla.org",
+ xpcomabi: "noarch-spidermonkey",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_10@tests.mozilla.org",
+ xpcomabi: "ppc-gcc3,noarch-spidermonkey,x86-msvc",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_11@tests.mozilla.org",
+ os: "Darwin",
+ xpcomabi: "ppc-gcc3,x86-msvc",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_12@tests.mozilla.org",
+ os: "Darwin",
+ xpcomabi: "ppc-gcc3,noarch-spidermonkey,x86-msvc",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_13@tests.mozilla.org",
+ os: "XPCShell",
+ xpcomabi: "ppc-gcc3,x86-msvc",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_14@tests.mozilla.org",
+ os: "XPCShell,WINNT",
+ xpcomabi: "ppc-gcc3,x86-msvc,noarch-spidermonkey",
+ versionRange: [],
+ },
+];
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9");
+ await promiseStartupManager();
+
+ for (let addon of ADDONS) {
+ await promiseInstallWebExtension({
+ manifest: {
+ name: addon.name,
+ version: addon.version,
+ browser_specific_settings: { gecko: { id: addon.id } },
+ },
+ });
+ }
+
+ let addons = await getAddons(ADDON_IDS);
+ for (let id of ADDON_IDS) {
+ equal(
+ addons.get(id).blocklistState,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ `Add-on ${id} should not initially be blocked`
+ );
+ }
+});
+
+add_task(async function test_1() {
+ await AddonTestUtils.loadBlocklistRawData({ extensions: BLOCKLIST_DATA });
+
+ let addons = await getAddons(ADDON_IDS);
+ async function isBlocklisted(addon, appVer, toolkitVer) {
+ let state = await Blocklist.getAddonBlocklistState(
+ addon,
+ appVer,
+ toolkitVer
+ );
+ return state != Services.blocklist.STATE_NOT_BLOCKED;
+ }
+ for (let addon of ADDONS) {
+ let { id } = addon;
+ for (let blocklisted of addon.blocklisted || []) {
+ ok(
+ await isBlocklisted(addons.get(id), ...blocklisted),
+ `Add-on ${id} should be blocklisted in app/platform version ${blocklisted}`
+ );
+ }
+ for (let notBlocklisted of addon.notBlocklisted || []) {
+ ok(
+ !(await isBlocklisted(addons.get(id), ...notBlocklisted)),
+ `Add-on ${id} should not be blocklisted in app/platform version ${notBlocklisted}`
+ );
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_prefs.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_prefs.js
new file mode 100644
index 0000000000..42eb1305c4
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_prefs.js
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests resetting of preferences in blocklist entry when an add-on is blocked.
+// See bug 802434.
+
+// useMLBF=true only supports blocking, not resetting prefs, since extensions
+// cannot set arbitrary prefs any more after the removal of legacy addons.
+enable_blocklist_v2_instead_of_useMLBF();
+
+const BLOCKLIST_DATA = [
+ {
+ guid: "block1@tests.mozilla.org",
+ versionRange: [
+ {
+ severity: "1",
+ targetApplication: [
+ {
+ guid: "xpcshell@tests.mozilla.org",
+ maxVersion: "2.*",
+ minVersion: "1",
+ },
+ ],
+ },
+ ],
+ prefs: ["test.blocklist.pref1", "test.blocklist.pref2"],
+ },
+ {
+ guid: "block2@tests.mozilla.org",
+ versionRange: [
+ {
+ severity: "3",
+ targetApplication: [
+ {
+ guid: "xpcshell@tests.mozilla.org",
+ maxVersion: "2.*",
+ minVersion: "1",
+ },
+ ],
+ },
+ ],
+ prefs: ["test.blocklist.pref3", "test.blocklist.pref4"],
+ },
+];
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+ await promiseStartupManager();
+
+ // Add 2 extensions
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Blocked add-on-1 with to-be-reset prefs",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: "block1@tests.mozilla.org" } },
+ },
+ });
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Blocked add-on-2 with to-be-reset prefs",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: "block2@tests.mozilla.org" } },
+ },
+ });
+
+ // Pre-set the preferences that we expect to get reset.
+ Services.prefs.setIntPref("test.blocklist.pref1", 15);
+ Services.prefs.setIntPref("test.blocklist.pref2", 15);
+ Services.prefs.setBoolPref("test.blocklist.pref3", true);
+ Services.prefs.setBoolPref("test.blocklist.pref4", true);
+
+ // Before blocklist is loaded.
+ let [a1, a2] = await AddonManager.getAddonsByIDs([
+ "block1@tests.mozilla.org",
+ "block2@tests.mozilla.org",
+ ]);
+ Assert.equal(a1.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ Assert.equal(a2.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ Assert.equal(Services.prefs.getIntPref("test.blocklist.pref1"), 15);
+ Assert.equal(Services.prefs.getIntPref("test.blocklist.pref2"), 15);
+ Assert.equal(Services.prefs.getBoolPref("test.blocklist.pref3"), true);
+ Assert.equal(Services.prefs.getBoolPref("test.blocklist.pref4"), true);
+});
+
+add_task(async function test_blocks() {
+ await AddonTestUtils.loadBlocklistRawData({ extensions: BLOCKLIST_DATA });
+
+ // Blocklist changes should have applied and the prefs must be reset.
+ let [a1, a2] = await AddonManager.getAddonsByIDs([
+ "block1@tests.mozilla.org",
+ "block2@tests.mozilla.org",
+ ]);
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.blocklistState, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.blocklistState, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ // All these prefs must be reset to defaults.
+ Assert.equal(Services.prefs.prefHasUserValue("test.blocklist.pref1"), false);
+ Assert.equal(Services.prefs.prefHasUserValue("test.blocklist.pref2"), false);
+ Assert.equal(Services.prefs.prefHasUserValue("test.blocklist.pref3"), false);
+ Assert.equal(Services.prefs.prefHasUserValue("test.blocklist.pref4"), false);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_regexp_split.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_regexp_split.js
new file mode 100644
index 0000000000..f48a6b9d8b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_regexp_split.js
@@ -0,0 +1,225 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// useMLBF=true only supports blocking by version+ID, not by regexp.
+enable_blocklist_v2_instead_of_useMLBF();
+
+const BLOCKLIST_DATA = [
+ {
+ guid: "/^abcd.*/",
+ versionRange: [],
+ expectedType: "RegExp",
+ },
+ {
+ guid: "test@example.com",
+ versionRange: [],
+ expectedType: "string",
+ },
+ {
+ guid: "/^((a)|(b)|(c))$/",
+ versionRange: [],
+ expectedType: "Set",
+ },
+ {
+ guid: "/^((a@b)|(\\{6d9ddd6e-c6ee-46de-ab56-ce9080372b3\\})|(c@d.com))$/",
+ versionRange: [],
+ expectedType: "Set",
+ },
+ // The same as the above, but with escape sequences that disqualify it from
+ // being treated as a set (and a different guid)
+ {
+ guid: "/^((s@t)|(\\{6d9eee6e-c6ee-46de-ab56-ce9080372b3\\})|(c@d\\w.com))$/",
+ versionRange: [],
+ expectedType: "RegExp",
+ },
+ // Also the same, but with other magical regex characters.
+ // (and a different guid)
+ {
+ guid: "/^((u@v)|(\\{6d9fff6e*-c6ee-46de-ab56-ce9080372b3\\})|(c@dee?.com))$/",
+ versionRange: [],
+ expectedType: "RegExp",
+ },
+];
+
+/**
+ * Verify that both IDs being OR'd in a regex work,
+ * and that other regular expressions continue being
+ * used as regular expressions.
+ */
+add_task(async function test_check_matching_works() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9");
+ await promiseStartupManager();
+ await AddonTestUtils.loadBlocklistRawData({
+ extensions: BLOCKLIST_DATA,
+ });
+
+ const { BlocklistPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/Blocklist.sys.mjs"
+ );
+ let parsedEntries = BlocklistPrivate.ExtensionBlocklistRS._entries;
+
+ // Unfortunately, the parsed entries aren't in the same order as the original.
+ function strForTypeOf(val) {
+ if (typeof val == "string") {
+ return "string";
+ }
+ if (val) {
+ return val.constructor.name;
+ }
+ return "other";
+ }
+ for (let type of ["Set", "RegExp", "string"]) {
+ let numberParsed = parsedEntries.filter(parsedEntry => {
+ return type == strForTypeOf(parsedEntry.matches.id);
+ }).length;
+ let expected = BLOCKLIST_DATA.filter(entry => {
+ return type == entry.expectedType;
+ }).length;
+ Assert.equal(
+ numberParsed,
+ expected,
+ type + " should have expected number of entries"
+ );
+ }
+ // Shouldn't block everything.
+ Assert.ok(
+ !(await Blocklist.getAddonBlocklistEntry({ id: "nonsense", version: "1" }))
+ );
+ // Should block IDs starting with abcd
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({ id: "abcde", version: "1" })
+ );
+ // Should block the literal string listed
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({
+ id: "test@example.com",
+ version: "1",
+ })
+ );
+ // Should block the IDs in (a)|(b)|(c)
+ Assert.ok(await Blocklist.getAddonBlocklistEntry({ id: "a", version: "1" }));
+ Assert.ok(await Blocklist.getAddonBlocklistEntry({ id: "b", version: "1" }));
+ Assert.ok(await Blocklist.getAddonBlocklistEntry({ id: "c", version: "1" }));
+ // Should block all the items processed to a set:
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({ id: "a@b", version: "1" })
+ );
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({
+ id: "{6d9ddd6e-c6ee-46de-ab56-ce9080372b3}",
+ version: "1",
+ })
+ );
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({ id: "c@d.com", version: "1" })
+ );
+ // Should block items that remained a regex:
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({ id: "s@t", version: "1" })
+ );
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({
+ id: "{6d9eee6e-c6ee-46de-ab56-ce9080372b3}",
+ version: "1",
+ })
+ );
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({ id: "c@dx.com", version: "1" })
+ );
+
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({ id: "u@v", version: "1" })
+ );
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({
+ id: "{6d9fff6eeeeeeee-c6ee-46de-ab56-ce9080372b3}",
+ version: "1",
+ })
+ );
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({ id: "c@dee.com", version: "1" })
+ );
+});
+
+// We should be checking all properties, not just the first one we come across.
+add_task(async function check_all_properties() {
+ await AddonTestUtils.loadBlocklistRawData({
+ extensions: [
+ {
+ guid: "literal@string.com",
+ creator: "Foo",
+ versionRange: [],
+ },
+ {
+ guid: "/regex.*@regex\\.com/",
+ creator: "Foo",
+ versionRange: [],
+ },
+ {
+ guid: "/^((set@set\\.com)|(anotherset@set\\.com)|(reallyenoughsetsalready@set\\.com))$/",
+ creator: "Foo",
+ versionRange: [],
+ },
+ ],
+ });
+
+ let { Blocklist } = ChromeUtils.importESModule(
+ "resource://gre/modules/Blocklist.sys.mjs"
+ );
+ // Check 'wrong' creator doesn't match.
+ Assert.ok(
+ !(await Blocklist.getAddonBlocklistEntry({
+ id: "literal@string.com",
+ version: "1",
+ creator: { name: "Bar" },
+ }))
+ );
+ Assert.ok(
+ !(await Blocklist.getAddonBlocklistEntry({
+ id: "regexaaaaa@regex.com",
+ version: "1",
+ creator: { name: "Bar" },
+ }))
+ );
+ Assert.ok(
+ !(await Blocklist.getAddonBlocklistEntry({
+ id: "set@set.com",
+ version: "1",
+ creator: { name: "Bar" },
+ }))
+ );
+
+ // Check 'wrong' ID doesn't match.
+ Assert.ok(
+ !(await Blocklist.getAddonBlocklistEntry({
+ id: "someotherid@foo.com",
+ version: "1",
+ creator: { name: "Foo" },
+ }))
+ );
+
+ // Check items matching all filters do match
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({
+ id: "literal@string.com",
+ version: "1",
+ creator: { name: "Foo" },
+ })
+ );
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({
+ id: "regexaaaaa@regex.com",
+ version: "1",
+ creator: { name: "Foo" },
+ })
+ );
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({
+ id: "set@set.com",
+ version: "1",
+ creator: { name: "Foo" },
+ })
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_severities.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_severities.js
new file mode 100644
index 0000000000..fffbb8a51e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_severities.js
@@ -0,0 +1,504 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+// useMLBF=true only supports one type of severity (hard block). The value of
+// appDisabled in the extension blocklist is checked in test_blocklist_mlbf.js.
+enable_blocklist_v2_instead_of_useMLBF();
+
+const URI_EXTENSION_BLOCKLIST_DIALOG =
+ "chrome://mozapps/content/extensions/blocklist.xhtml";
+
+// Workaround for Bug 658720 - URL formatter can leak during xpcshell tests
+const PREF_BLOCKLIST_ITEM_URL = "extensions.blocklist.itemURL";
+Services.prefs.setCharPref(
+ PREF_BLOCKLIST_ITEM_URL,
+ "http://example.com/blocklist/%blockID%"
+);
+
+async function getAddonBlocklistURL(addon) {
+ let entry = await Blocklist.getAddonBlocklistEntry(addon);
+ return entry && entry.url;
+}
+
+var ADDONS = [
+ {
+ // Tests how the blocklist affects a disabled add-on
+ id: "test_bug455906_1@tests.mozilla.org",
+ name: "Bug 455906 Addon Test 1",
+ version: "5",
+ appVersion: "3",
+ },
+ {
+ // Tests how the blocklist affects an enabled add-on
+ id: "test_bug455906_2@tests.mozilla.org",
+ name: "Bug 455906 Addon Test 2",
+ version: "5",
+ appVersion: "3",
+ },
+ {
+ // Tests how the blocklist affects an enabled add-on, to be disabled by the notification
+ id: "test_bug455906_3@tests.mozilla.org",
+ name: "Bug 455906 Addon Test 3",
+ version: "5",
+ appVersion: "3",
+ },
+ {
+ // Tests how the blocklist affects a disabled add-on that was already warned about
+ id: "test_bug455906_4@tests.mozilla.org",
+ name: "Bug 455906 Addon Test 4",
+ version: "5",
+ appVersion: "3",
+ },
+ {
+ // Tests how the blocklist affects an enabled add-on that was already warned about
+ id: "test_bug455906_5@tests.mozilla.org",
+ name: "Bug 455906 Addon Test 5",
+ version: "5",
+ appVersion: "3",
+ },
+ {
+ // Tests how the blocklist affects an already blocked add-on
+ id: "test_bug455906_6@tests.mozilla.org",
+ name: "Bug 455906 Addon Test 6",
+ version: "5",
+ appVersion: "3",
+ },
+ {
+ // Tests how the blocklist affects an incompatible add-on
+ id: "test_bug455906_7@tests.mozilla.org",
+ name: "Bug 455906 Addon Test 7",
+ version: "5",
+ appVersion: "2",
+ },
+ {
+ // Spare add-on used to ensure we get a notification when switching lists
+ id: "dummy_bug455906_1@tests.mozilla.org",
+ name: "Dummy Addon 1",
+ version: "5",
+ appVersion: "3",
+ },
+ {
+ // Spare add-on used to ensure we get a notification when switching lists
+ id: "dummy_bug455906_2@tests.mozilla.org",
+ name: "Dummy Addon 2",
+ version: "5",
+ appVersion: "3",
+ },
+];
+
+var gNotificationCheck = null;
+
+// Don't need the full interface, attempts to call other methods will just
+// throw which is just fine
+var WindowWatcher = {
+ openWindow(parent, url, name, features, windowArguments) {
+ // Should be called to list the newly blocklisted items
+ equal(url, URI_EXTENSION_BLOCKLIST_DIALOG);
+
+ if (gNotificationCheck) {
+ gNotificationCheck(windowArguments.wrappedJSObject);
+ }
+
+ // run the code after the blocklist is closed
+ Services.obs.notifyObservers(null, "addon-blocklist-closed");
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIWindowWatcher"]),
+};
+
+MockRegistrar.register(
+ "@mozilla.org/embedcomp/window-watcher;1",
+ WindowWatcher
+);
+
+function createAddon(addon) {
+ return promiseInstallWebExtension({
+ manifest: {
+ name: addon.name,
+ version: addon.version,
+ browser_specific_settings: {
+ gecko: {
+ id: addon.id,
+ strict_min_version: addon.appVersion,
+ strict_max_version: addon.appVersion,
+ },
+ },
+ },
+ });
+}
+
+const BLOCKLIST_DATA = {
+ start: {
+ // Block 4-6 and a dummy:
+ extensions: [
+ {
+ guid: "test_bug455906_4@tests.mozilla.org",
+ versionRange: [{ severity: "-1" }],
+ },
+ {
+ guid: "test_bug455906_5@tests.mozilla.org",
+ versionRange: [{ severity: "1" }],
+ },
+ {
+ guid: "test_bug455906_6@tests.mozilla.org",
+ versionRange: [{ severity: "2" }],
+ },
+ {
+ guid: "dummy_bug455906_1@tests.mozilla.org",
+ versionRange: [],
+ },
+ ],
+ },
+ warn: {
+ // warn for all test add-ons:
+ extensions: ADDONS.filter(a => a.id.startsWith("test_")).map(a => ({
+ guid: a.id,
+ versionRange: [{ severity: "-1" }],
+ })),
+ },
+ block: {
+ // block all test add-ons:
+ extensions: ADDONS.filter(a => a.id.startsWith("test_")).map(a => ({
+ guid: a.id,
+ blockID: a.id,
+ versionRange: [],
+ })),
+ },
+ empty: {
+ // Block a dummy so there's a change:
+ extensions: [
+ {
+ guid: "dummy_bug455906_2@tests.mozilla.org",
+ versionRange: [],
+ },
+ ],
+ },
+};
+
+async function loadBlocklist(id, callback) {
+ gNotificationCheck = callback;
+
+ await AddonTestUtils.loadBlocklistRawData(BLOCKLIST_DATA[id]);
+}
+
+function create_blocklistURL(blockID) {
+ let url = Services.urlFormatter.formatURLPref(PREF_BLOCKLIST_ITEM_URL);
+ url = url.replace(/%blockID%/g, blockID);
+ return url;
+}
+
+// Before every main test this is the state the add-ons are meant to be in
+async function checkInitialState() {
+ let addons = await AddonManager.getAddonsByIDs(ADDONS.map(a => a.id));
+
+ checkAddonState(addons[0], {
+ userDisabled: true,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[1], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[2], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[3], {
+ userDisabled: true,
+ softDisabled: true,
+ appDisabled: false,
+ });
+ checkAddonState(addons[4], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[5], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+ checkAddonState(addons[6], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+}
+
+function checkAddonState(addon, state) {
+ return checkAddon(addon.id, addon, state);
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "3");
+
+ await promiseStartupManager();
+
+ // Load the initial blocklist into the profile to check add-ons start in the
+ // right state.
+ await AddonTestUtils.loadBlocklistRawData(BLOCKLIST_DATA.start);
+
+ for (let addon of ADDONS) {
+ await createAddon(addon);
+ }
+});
+
+add_task(async function test_1() {
+ // Tests the add-ons were installed and the initial blocklist applied as expected
+
+ let addons = await AddonManager.getAddonsByIDs(ADDONS.map(a => a.id));
+ for (var i = 0; i < ADDONS.length; i++) {
+ ok(addons[i], `Addon ${i + 1} should be installed correctly`);
+ }
+
+ checkAddonState(addons[0], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[1], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[2], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+
+ // Warn add-ons should be soft disabled automatically
+ checkAddonState(addons[3], {
+ userDisabled: true,
+ softDisabled: true,
+ appDisabled: false,
+ });
+ checkAddonState(addons[4], {
+ userDisabled: true,
+ softDisabled: true,
+ appDisabled: false,
+ });
+
+ // Blocked and incompatible should be app disabled only
+ checkAddonState(addons[5], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+ checkAddonState(addons[6], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+
+ // Put the add-ons into the base state
+ await addons[0].disable();
+ await addons[4].enable();
+
+ await promiseRestartManager();
+ await checkInitialState();
+
+ await loadBlocklist("warn", args => {
+ dump("Checking notification pt 2\n");
+ // This test is artificial, we don't notify for add-ons anymore, see
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1257565#c111 . Cleaning this up
+ // should happen but this patchset is too huge as it is so I'm deferring it.
+ equal(args.list.length, 2);
+ });
+
+ await promiseRestartManager();
+ dump("Checking results pt 2\n");
+
+ addons = await AddonManager.getAddonsByIDs(ADDONS.map(a => a.id));
+
+ info("Should have disabled this add-on as requested");
+ checkAddonState(addons[2], {
+ userDisabled: true,
+ softDisabled: true,
+ appDisabled: false,
+ });
+
+ info("The blocked add-on should have changed to soft disabled");
+ checkAddonState(addons[5], {
+ userDisabled: true,
+ softDisabled: true,
+ appDisabled: false,
+ });
+ checkAddonState(addons[6], {
+ userDisabled: true,
+ softDisabled: true,
+ appDisabled: true,
+ });
+
+ info("These should have been unchanged");
+ checkAddonState(addons[0], {
+ userDisabled: true,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ // XXXgijs this is supposed to be not user disabled or soft disabled, but because we don't show
+ // the dialog, it's disabled anyway. Comment out this assertion for now...
+ // checkAddonState(addons[1], {userDisabled: false, softDisabled: false, appDisabled: false});
+ checkAddonState(addons[3], {
+ userDisabled: true,
+ softDisabled: true,
+ appDisabled: false,
+ });
+ checkAddonState(addons[4], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+
+ // Back to starting state
+ await addons[2].enable();
+ await addons[5].enable();
+
+ await promiseRestartManager();
+ await loadBlocklist("start");
+});
+
+add_task(async function test_pt3() {
+ await promiseRestartManager();
+ await checkInitialState();
+
+ await loadBlocklist("block", args => {
+ dump("Checking notification pt 3\n");
+ equal(args.list.length, 3);
+ });
+
+ await promiseRestartManager();
+ dump("Checking results pt 3\n");
+
+ let addons = await AddonManager.getAddonsByIDs(ADDONS.map(a => a.id));
+
+ // All should have gained the blocklist state, user disabled as previously
+ checkAddonState(addons[0], {
+ userDisabled: true,
+ softDisabled: false,
+ appDisabled: true,
+ });
+ checkAddonState(addons[1], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+ checkAddonState(addons[2], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+ checkAddonState(addons[4], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+
+ // Should have gained the blocklist state but no longer be soft disabled
+ checkAddonState(addons[3], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+
+ // Check blockIDs are correct
+ equal(
+ await getAddonBlocklistURL(addons[0]),
+ create_blocklistURL(addons[0].id)
+ );
+ equal(
+ await getAddonBlocklistURL(addons[1]),
+ create_blocklistURL(addons[1].id)
+ );
+ equal(
+ await getAddonBlocklistURL(addons[2]),
+ create_blocklistURL(addons[2].id)
+ );
+ equal(
+ await getAddonBlocklistURL(addons[3]),
+ create_blocklistURL(addons[3].id)
+ );
+ equal(
+ await getAddonBlocklistURL(addons[4]),
+ create_blocklistURL(addons[4].id)
+ );
+
+ // Shouldn't be changed
+ checkAddonState(addons[5], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+ checkAddonState(addons[6], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+
+ // Back to starting state
+ await loadBlocklist("start");
+});
+
+add_task(async function test_pt4() {
+ let addon = await AddonManager.getAddonByID(ADDONS[4].id);
+ await addon.enable();
+
+ await promiseRestartManager();
+ await checkInitialState();
+
+ await loadBlocklist("empty", args => {
+ dump("Checking notification pt 4\n");
+ // See note in other callback - we no longer notify for non-blocked add-ons.
+ ok(false, "Should not get a notification as there are no blocked addons.");
+ });
+
+ await promiseRestartManager();
+ dump("Checking results pt 4\n");
+
+ let addons = await AddonManager.getAddonsByIDs(ADDONS.map(a => a.id));
+ // This should have become unblocked
+ checkAddonState(addons[5], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+
+ // Should get re-enabled
+ checkAddonState(addons[3], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+
+ // No change for anything else
+ checkAddonState(addons[0], {
+ userDisabled: true,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[1], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[2], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[4], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[6], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_statechange_telemetry.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_statechange_telemetry.js
new file mode 100644
index 0000000000..ccaa1868fe
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_statechange_telemetry.js
@@ -0,0 +1,411 @@
+// Verifies that changes to blocklistState are correctly reported to telemetry.
+
+"use strict";
+
+Services.prefs.setBoolPref("extensions.blocklist.useMLBF", true);
+
+// Set min version to 42 because the updater defaults to min version 42.
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42.0", "42.0");
+
+// Use unprivileged signatures because the MLBF-based blocklist does not
+// apply to add-ons with a privileged signature.
+AddonTestUtils.usePrivilegedSignatures = false;
+
+const { Downloader } = ChromeUtils.importESModule(
+ "resource://services-settings/Attachments.sys.mjs"
+);
+
+const { TelemetryController } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryController.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const ExtensionBlocklistMLBF = getExtensionBlocklistMLBF();
+
+const EXT_ID = "maybeblockme@tests.mozilla.org";
+
+// The addon blocked by the bloom filter (referenced by MLBF_RECORD).
+const EXT_BLOCKED_ID = "@blocked";
+const EXT_BLOCKED_VERSION = "1";
+const EXT_BLOCKED_SIGN_TIME = 12345; // Before MLBF_RECORD.generation_time.
+
+// To serve updates.
+const server = AddonTestUtils.createHttpServer();
+const SERVER_BASE_URL = `http://127.0.0.1:${server.identity.primaryPort}`;
+const SERVER_UPDATE_PATH = "/update.json";
+const SERVER_UPDATE_URL = `${SERVER_BASE_URL}${SERVER_UPDATE_PATH}`;
+// update is served via `server` over insecure http.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+async function assertEventDetails(expectedExtras) {
+ if (!IS_ANDROID_BUILD) {
+ const expectedEvents = expectedExtras.map(expectedExtra => {
+ let { object, value, ...extra } = expectedExtra;
+ return ["blocklist", "addonBlockChange", object, value, extra];
+ });
+ await TelemetryTestUtils.assertEvents(expectedEvents, {
+ category: "blocklist",
+ method: "addonBlockChange",
+ });
+ } else {
+ info(
+ `Skip assertions on collected samples for addonBlockChange on android builds`
+ );
+ }
+ assertGleanEventDetails(expectedExtras);
+}
+async function assertGleanEventDetails(expectedExtras) {
+ const snapshot = testGetValue(Glean.blocklist.addonBlockChange);
+ if (expectedExtras.length === 0) {
+ Assert.deepEqual(undefined, snapshot, "Expected zero addonBlockChange");
+ return;
+ }
+ Assert.equal(
+ expectedExtras.length,
+ snapshot?.length,
+ "Number of addonBlockChange records"
+ );
+ for (let i of expectedExtras.keys()) {
+ let actual = snapshot[i].extra;
+ // Glean uses snake_case instead of camelCase.
+ let { blocklistState, ...expected } = expectedExtras[i];
+ expected.blocklist_state = blocklistState;
+ Assert.deepEqual(expected, actual, `Expected addonBlockChange (${i})`);
+ }
+}
+
+// Stage an update on the update server. The add-on must have been created
+// with update_url set to SERVER_UPDATE_URL.
+function setupAddonUpdate(addonId, addonVersion) {
+ let updateXpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: addonVersion,
+ browser_specific_settings: {
+ gecko: { id: addonId, update_url: SERVER_UPDATE_URL },
+ },
+ },
+ });
+ let updateXpiPath = `/update-${addonId}-${addonVersion}.xpi`;
+ server.registerFile(updateXpiPath, updateXpi);
+ AddonTestUtils.registerJSON(server, SERVER_UPDATE_PATH, {
+ addons: {
+ [addonId]: {
+ updates: [
+ {
+ version: addonVersion,
+ update_link: `${SERVER_BASE_URL}${updateXpiPath}`,
+ },
+ ],
+ },
+ },
+ });
+}
+
+async function tryAddonInstall(addonId, addonVersion) {
+ let xpiFile = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: addonVersion,
+ browser_specific_settings: {
+ gecko: { id: addonId, update_url: SERVER_UPDATE_URL },
+ },
+ },
+ });
+ const install = await promiseInstallFile(xpiFile, true);
+ // Passing true to promiseInstallFile means that the xpi may not be installed
+ // if blocked by the blocklist. In that case, |install| may be null.
+ return install?.addon;
+}
+
+add_task(async function setup() {
+ if (!IS_ANDROID_BUILD) {
+ // 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 TelemetryController.testSetup();
+
+ // Disable the packaged record and attachment to make sure that the test
+ // will not fall back to the packaged attachments.
+ Downloader._RESOURCE_BASE_URL = "invalid://bogus";
+
+ await promiseStartupManager();
+});
+
+add_task(async function install_update_not_blocked_is_no_events() {
+ resetBlocklistTelemetry();
+ // Install an add-on that is not blocked. Then update to the next version.
+ let addon = await tryAddonInstall(EXT_ID, "0.1");
+
+ // Version "1" not blocked yet, but will be in the next test task.
+ setupAddonUpdate(EXT_ID, "1");
+ let update = await AddonTestUtils.promiseFindAddonUpdates(addon);
+ await promiseCompleteInstall(update.updateAvailable);
+ addon = await AddonManager.getAddonByID(EXT_ID);
+ equal(addon.version, "1", "Add-on was updated");
+
+ await assertEventDetails([]);
+});
+
+add_task(async function blocklist_update_events() {
+ resetBlocklistTelemetry();
+ const EXT_HOURS_SINCE_INSTALL = 4321;
+ const addon = await AddonManager.getAddonByID(EXT_ID);
+ addon.__AddonInternal__.installDate =
+ addon.installDate.getTime() - 3600000 * EXT_HOURS_SINCE_INSTALL;
+
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [
+ { stash: { blocked: [`${EXT_ID}:1`], unblocked: [] }, stash_time: 123 },
+ { stash: { blocked: [`${EXT_ID}:2`], unblocked: [] }, stash_time: 456 },
+ ],
+ });
+
+ await assertEventDetails([
+ {
+ object: "blocklist_update",
+ value: EXT_ID,
+ blocklistState: "2", // Ci.nsIBlocklistService.STATE_BLOCKED
+ addon_version: "1",
+ signed_date: "0",
+ hours_since: `${EXT_HOURS_SINCE_INSTALL}`,
+ mlbf_last_time: "456",
+ mlbf_generation: "0",
+ mlbf_source: "unknown",
+ },
+ ]);
+});
+
+add_task(async function update_check_blocked_by_stash() {
+ resetBlocklistTelemetry();
+ setupAddonUpdate(EXT_ID, "2");
+ let addon = await AddonManager.getAddonByID(EXT_ID);
+ let update = await AddonTestUtils.promiseFindAddonUpdates(addon);
+ // Blocks in stashes are immediately enforced by update checks.
+ // Blocks stored in MLBFs are only enforced after the package is downloaded,
+ // and that scenario is covered by update_check_blocked_by_stash elsewhere.
+ equal(update.updateAvailable, false, "Update was blocked by stash");
+
+ await assertEventDetails([
+ {
+ object: "addon_update_check",
+ value: EXT_ID,
+ blocklistState: "2", // Ci.nsIBlocklistService.STATE_BLOCKED
+ addon_version: "2",
+ signed_date: "0",
+ hours_since: "-1",
+ mlbf_last_time: "456",
+ mlbf_generation: "0",
+ mlbf_source: "unknown",
+ },
+ ]);
+});
+
+// Any attempt to re-install a blocked add-on should trigger a telemetry
+// event, even though the blocklistState did not change.
+add_task(async function reinstall_blocked_addon() {
+ resetBlocklistTelemetry();
+ let blockedAddon = await AddonManager.getAddonByID(EXT_ID);
+ equal(
+ blockedAddon.blocklistState,
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Addon was initially blocked"
+ );
+
+ let addon = await tryAddonInstall(EXT_ID, "2");
+ ok(!addon, "Add-on install should be blocked by a stash");
+
+ await assertEventDetails([
+ {
+ // Note: installs of existing versions are observed as "addon_install".
+ // Only updates after update checks are tagged as "addon_update".
+ object: "addon_install",
+ value: EXT_ID,
+ blocklistState: "2", // Ci.nsIBlocklistService.STATE_BLOCKED
+ addon_version: "2",
+ signed_date: "0",
+ hours_since: "-1",
+ mlbf_last_time: "456",
+ mlbf_generation: "0",
+ mlbf_source: "unknown",
+ },
+ ]);
+});
+
+// For comparison with the next test task (database_modified), verify that a
+// regular restart without database modifications does not trigger events.
+add_task(async function regular_restart_no_event() {
+ resetBlocklistTelemetry();
+ // Version different/higher than the 42.0 that was passed to createAppInfo at
+ // the start of this test file to force a database rebuild.
+ await promiseRestartManager("90.0");
+ await assertEventDetails([]);
+
+ await promiseRestartManager();
+ await assertEventDetails([]);
+});
+
+add_task(async function database_modified() {
+ resetBlocklistTelemetry();
+ const EXT_HOURS_SINCE_INSTALL = 3;
+ await promiseShutdownManager();
+
+ // Modify the addon database: blocked->not blocked + decrease installDate.
+ let addonDB = await IOUtils.readJSON(gExtensionsJSON.path);
+ let rawAddon = addonDB.addons[0];
+ equal(rawAddon.id, EXT_ID, "Expected entry in addonDB");
+ equal(rawAddon.blocklistState, 2, "Expected STATE_BLOCKED");
+ rawAddon.blocklistState = 0; // STATE_NOT_BLOCKED
+ rawAddon.installDate = Date.now() - 3600000 * EXT_HOURS_SINCE_INSTALL;
+ await IOUtils.writeJSON(gExtensionsJSON.path, addonDB);
+
+ // Bump version to force database rebuild.
+ await promiseStartupManager("91.0");
+ // Shut down because the database reconcilation blocks shutdown, and we want
+ // to be certain that the process has finished before checking the events.
+ await promiseShutdownManager();
+ await assertEventDetails([
+ {
+ object: "addon_db_modified",
+ value: EXT_ID,
+ blocklistState: "2", // Ci.nsIBlocklistService.STATE_BLOCKED
+ addon_version: "1",
+ signed_date: "0",
+ hours_since: `${EXT_HOURS_SINCE_INSTALL}`,
+ mlbf_last_time: "456",
+ mlbf_generation: "0",
+ mlbf_source: "unknown",
+ },
+ ]);
+
+ resetBlocklistTelemetry();
+ await promiseStartupManager();
+ await assertEventDetails([]);
+});
+
+add_task(async function install_replaces_blocked_addon() {
+ resetBlocklistTelemetry();
+ let addon = await tryAddonInstall(EXT_ID, "3");
+ ok(addon, "Update supersedes blocked add-on");
+
+ await assertEventDetails([
+ {
+ object: "addon_install",
+ value: EXT_ID,
+ blocklistState: "0", // Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ addon_version: "3",
+ signed_date: "0",
+ hours_since: "-1",
+ mlbf_last_time: "456",
+ mlbf_generation: "0",
+ mlbf_source: "unknown",
+ },
+ ]);
+});
+
+add_task(async function install_blocked_by_mlbf() {
+ resetBlocklistTelemetry();
+ await ExtensionBlocklistMLBF._client.db.saveAttachment(
+ ExtensionBlocklistMLBF.RS_ATTACHMENT_ID,
+ { record: MLBF_RECORD, blob: await load_mlbf_record_as_blob() }
+ );
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [MLBF_RECORD],
+ });
+
+ AddonTestUtils.certSignatureDate = EXT_BLOCKED_SIGN_TIME;
+ let addon = await tryAddonInstall(EXT_BLOCKED_ID, EXT_BLOCKED_VERSION);
+ AddonTestUtils.certSignatureDate = null;
+
+ ok(!addon, "Add-on install should be blocked by the MLBF");
+
+ await assertEventDetails([
+ {
+ object: "addon_install",
+ value: EXT_BLOCKED_ID,
+ blocklistState: "2", // Ci.nsIBlocklistService.STATE_BLOCKED
+ addon_version: EXT_BLOCKED_VERSION,
+ signed_date: `${EXT_BLOCKED_SIGN_TIME}`,
+ hours_since: "-1",
+ // When there is no stash at all, the MLBF's generation_time is used.
+ mlbf_last_time: `${MLBF_RECORD.generation_time}`,
+ mlbf_generation: `${MLBF_RECORD.generation_time}`,
+ mlbf_source: "cache_match",
+ },
+ ]);
+});
+
+// A limitation of the MLBF-based blocklist is that it needs the add-on package
+// in order to check its signature date.
+// This part of the test verifies that installation of the add-on is blocked,
+// despite the update check tentatively accepting the package.
+// See https://bugzilla.mozilla.org/show_bug.cgi?id=1649896 for rationale.
+add_task(async function update_check_blocked_by_mlbf() {
+ resetBlocklistTelemetry();
+ // Install a version that we can update, lower than EXT_BLOCKED_VERSION.
+ let addon = await tryAddonInstall(EXT_BLOCKED_ID, "0.1");
+
+ setupAddonUpdate(EXT_BLOCKED_ID, EXT_BLOCKED_VERSION);
+ AddonTestUtils.certSignatureDate = EXT_BLOCKED_SIGN_TIME;
+ let update = await AddonTestUtils.promiseFindAddonUpdates(addon);
+ ok(update.updateAvailable, "Update was not blocked by stash");
+
+ await promiseCompleteInstall(update.updateAvailable);
+ AddonTestUtils.certSignatureDate = null;
+
+ addon = await AddonManager.getAddonByID(EXT_BLOCKED_ID);
+ equal(addon.version, EXT_BLOCKED_VERSION, "Add-on was updated");
+ equal(
+ addon.blocklistState,
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Add-on is blocked"
+ );
+ equal(addon.appDisabled, true, "Add-on was disabled because of the block");
+
+ await assertEventDetails([
+ {
+ object: "addon_update",
+ value: EXT_BLOCKED_ID,
+ blocklistState: "2", // Ci.nsIBlocklistService.STATE_BLOCKED
+ addon_version: EXT_BLOCKED_VERSION,
+ signed_date: `${EXT_BLOCKED_SIGN_TIME}`,
+ hours_since: "-1",
+ mlbf_last_time: `${MLBF_RECORD.generation_time}`,
+ mlbf_generation: `${MLBF_RECORD.generation_time}`,
+ mlbf_source: "cache_match",
+ },
+ ]);
+});
+
+add_task(async function update_blocked_to_unblocked() {
+ resetBlocklistTelemetry();
+ // was blocked in update_check_blocked_by_mlbf.
+ let blockedAddon = await AddonManager.getAddonByID(EXT_BLOCKED_ID);
+
+ // 3 is higher than EXT_BLOCKED_VERSION.
+ setupAddonUpdate(EXT_BLOCKED_ID, "3");
+ AddonTestUtils.certSignatureDate = EXT_BLOCKED_SIGN_TIME;
+ let update = await AddonTestUtils.promiseFindAddonUpdates(blockedAddon);
+ ok(update.updateAvailable, "Found an update");
+
+ await promiseCompleteInstall(update.updateAvailable);
+ AddonTestUtils.certSignatureDate = null;
+
+ let addon = await AddonManager.getAddonByID(EXT_BLOCKED_ID);
+ equal(addon.appDisabled, false, "Add-on was re-enabled after unblock");
+ await assertEventDetails([
+ {
+ object: "addon_update",
+ value: EXT_BLOCKED_ID,
+ blocklistState: "0", // Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ addon_version: "3",
+ signed_date: `${EXT_BLOCKED_SIGN_TIME}`,
+ hours_since: "-1",
+ mlbf_last_time: `${MLBF_RECORD.generation_time}`,
+ mlbf_generation: `${MLBF_RECORD.generation_time}`,
+ mlbf_source: "cache_match",
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_targetapp_filter.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_targetapp_filter.js
new file mode 100644
index 0000000000..b48700570e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_targetapp_filter.js
@@ -0,0 +1,392 @@
+const { BlocklistPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/Blocklist.sys.mjs"
+);
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+
+const APP_ID = "xpcshell@tests.mozilla.org";
+const TOOLKIT_ID = "toolkit@mozilla.org";
+
+let client;
+
+async function clear_state() {
+ // Clear local DB.
+ await client.db.clear();
+}
+
+async function createRecords(records) {
+ const withId = records.map((record, i) => ({
+ id: `record-${i}`,
+ ...record,
+ }));
+ // Prevent packaged dump to be loaded with high collection timestamp
+ return client.db.importChanges({}, Date.now(), withId);
+}
+
+function run_test() {
+ AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "58",
+ ""
+ );
+ // This will initialize the remote settings clients for blocklists,
+ // with their specific options etc.
+ BlocklistPrivate.ExtensionBlocklistRS.ensureInitialized();
+ // Obtain one of the instantiated client for our tests.
+ client = RemoteSettings("addons", { bucketName: "blocklists" });
+
+ run_next_test();
+}
+
+add_task(async function test_supports_filter_expressions() {
+ await createRecords([
+ {
+ name: "My Extension",
+ filter_expression: 'env.appinfo.ID == "xpcshell@tests.mozilla.org"',
+ },
+ {
+ name: "My Extension",
+ filter_expression: "1 == 2",
+ },
+ ]);
+
+ const list = await client.get();
+ equal(list.length, 1);
+});
+add_task(clear_state);
+
+add_task(async function test_returns_all_without_target() {
+ await createRecords([
+ {
+ name: "My Extension",
+ },
+ {
+ name: "foopydoo",
+ versionRange: [],
+ },
+ {
+ name: "My Other Extension",
+ versionRange: [
+ {
+ severity: 0,
+ targetApplication: [],
+ },
+ ],
+ },
+ {
+ name: "Java(\\(TM\\))? Plug-in 11\\.(7[6-9]|[8-9]\\d|1([0-6]\\d|70))(\\.\\d+)?([^\\d\\._]|$)",
+ versionRange: [
+ {
+ severity: 0,
+ },
+ ],
+ matchFilename: "libnpjp2\\.so",
+ },
+ {
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [],
+ maxVersion: "1",
+ minVersion: "0",
+ severity: "1",
+ },
+ ],
+ },
+ ]);
+
+ const list = await client.get();
+ equal(list.length, 5);
+});
+add_task(clear_state);
+
+add_task(async function test_returns_without_guid_or_with_matching_guid() {
+ await createRecords([
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [{}],
+ },
+ ],
+ },
+ {
+ willMatch: false,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: "some-guid",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: TOOLKIT_ID,
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ const list = await client.get();
+ info(JSON.stringify(list, null, 2));
+ equal(list.length, 3);
+ ok(list.every(e => e.willMatch));
+});
+add_task(clear_state);
+
+add_task(
+ async function test_returns_without_app_version_or_with_matching_version() {
+ await createRecords([
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ minVersion: "0",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ minVersion: "0",
+ maxVersion: "9999",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: false,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ minVersion: "0",
+ maxVersion: "1",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: TOOLKIT_ID,
+ minVersion: "0",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: TOOLKIT_ID,
+ minVersion: "0",
+ maxVersion: "9999",
+ },
+ ],
+ },
+ ],
+ // We can't test the false case with maxVersion for toolkit, because the toolkit version
+ // is 0 in xpcshell.
+ },
+ ]);
+
+ const list = await client.get();
+ equal(list.length, 5);
+ ok(list.every(e => e.willMatch));
+ }
+);
+add_task(clear_state);
+
+add_task(async function test_multiple_version_and_target_applications() {
+ await createRecords([
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: "other-guid",
+ },
+ ],
+ },
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ minVersion: "0",
+ maxVersion: "*",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: "other-guid",
+ },
+ ],
+ },
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ minVersion: "0",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: false,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ maxVersion: "57.*",
+ },
+ ],
+ },
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ maxVersion: "56.*",
+ },
+ {
+ guid: APP_ID,
+ maxVersion: "57.*",
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ const list = await client.get();
+ equal(list.length, 2);
+ ok(list.every(e => e.willMatch));
+});
+add_task(clear_state);
+
+add_task(async function test_complex_version() {
+ await createRecords([
+ {
+ willMatch: false,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ maxVersion: "57.*",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ maxVersion: "9999.*",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ minVersion: "19.0a1",
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ const list = await client.get();
+ equal(list.length, 2);
+});
+add_task(clear_state);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_telemetry.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_telemetry.js
new file mode 100644
index 0000000000..cf1992b121
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_telemetry.js
@@ -0,0 +1,138 @@
+/* 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",
+ "1",
+ "49"
+);
+
+const { TelemetryController } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryController.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+add_setup({ skip_if: () => IS_ANDROID_BUILD }, function test_setup() {
+ // 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 assertTelemetryScalars(expectedScalars) {
+ if (!IS_ANDROID_BUILD) {
+ let scalars = TelemetryTestUtils.getProcessScalars("parent");
+
+ for (const scalarName of Object.keys(expectedScalars || {})) {
+ equal(
+ scalars[scalarName],
+ expectedScalars[scalarName],
+ `Got the expected value for ${scalarName} scalar`
+ );
+ }
+ } else {
+ info(
+ `Skip assertions on collected samples for ${expectedScalars} on android builds`
+ );
+ }
+}
+
+add_task(async function test_setup() {
+ // Ensure that the telemetry scalar definitions are loaded and the
+ // AddonManager initialized.
+ await TelemetryController.testSetup();
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_blocklist_lastModified_rs_scalars() {
+ resetBlocklistTelemetry();
+ const now = Date.now();
+
+ const lastEntryTimes = {
+ addons: now - 5000,
+ addons_mlbf: now - 4000,
+ };
+
+ const lastEntryTimesUTC = {};
+ const toUTC = t => new Date(t).toUTCString();
+ for (const key of Object.keys(lastEntryTimes)) {
+ lastEntryTimesUTC[key] = toUTC(lastEntryTimes[key]);
+ }
+
+ const {
+ BlocklistPrivate: {
+ BlocklistTelemetry,
+ ExtensionBlocklistMLBF,
+ ExtensionBlocklistRS,
+ },
+ } = ChromeUtils.importESModule("resource://gre/modules/Blocklist.sys.mjs");
+
+ // Return a promise resolved when the recordRSBlocklistLastModified method
+ // has been called (by temporarily replacing the method with a function that
+ // calls the real method and then resolve the promise).
+ function promiseScalarRecorded() {
+ return new Promise(resolve => {
+ let origFn = BlocklistTelemetry.recordRSBlocklistLastModified;
+ BlocklistTelemetry.recordRSBlocklistLastModified = async (...args) => {
+ BlocklistTelemetry.recordRSBlocklistLastModified = origFn;
+ let res = await origFn.apply(BlocklistTelemetry, args);
+ resolve();
+ return res;
+ };
+ });
+ }
+
+ async function fakeRemoteSettingsSync(rsClient, lastModified) {
+ await rsClient.db.importChanges({}, lastModified);
+ await rsClient.emit("sync");
+ }
+
+ assertTelemetryScalars({
+ "blocklist.lastModified_rs_addons_mlbf": undefined,
+ });
+ Assert.equal(
+ undefined,
+ testGetValue(Glean.blocklist.lastModifiedRsAddonsMblf)
+ );
+
+ info("Test RS addon blocklist lastModified scalar");
+
+ await ExtensionBlocklistRS.ensureInitialized();
+ await Promise.all([
+ promiseScalarRecorded(),
+ fakeRemoteSettingsSync(ExtensionBlocklistRS._client, lastEntryTimes.addons),
+ ]);
+
+ assertTelemetryScalars({
+ "blocklist.lastModified_rs_addons_mlbf": undefined,
+ });
+
+ Assert.equal(
+ undefined,
+ testGetValue(Glean.blocklist.lastModifiedRsAddonsMblf)
+ );
+
+ await ExtensionBlocklistMLBF.ensureInitialized();
+ await Promise.all([
+ promiseScalarRecorded(),
+ fakeRemoteSettingsSync(
+ ExtensionBlocklistMLBF._client,
+ lastEntryTimes.addons_mlbf
+ ),
+ ]);
+
+ assertTelemetryScalars({
+ "blocklist.lastModified_rs_addons_mlbf": lastEntryTimesUTC.addons_mlbf,
+ });
+ Assert.equal(
+ new Date(lastEntryTimesUTC.addons_mlbf).getTime(),
+ testGetValue(Glean.blocklist.lastModifiedRsAddonsMblf).getTime()
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklistchange.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklistchange.js
new file mode 100644
index 0000000000..cd80b34ac0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklistchange.js
@@ -0,0 +1,1410 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Checks that changes that cause an add-on to become unblocked or blocked have
+// the right effect
+
+// The tests follow a mostly common pattern. First they start with the add-ons
+// unblocked, then they make a change that causes the add-ons to become blocked
+// then they make a similar change that keeps the add-ons blocked then they make
+// a change that unblocks the add-ons. Some tests skip the initial part and
+// start with add-ons detected as blocked.
+
+// softblock1 is enabled/disabled by the blocklist changes so its softDisabled
+// property should always match its userDisabled property
+
+// softblock2 gets manually enabled then disabled after it becomes blocked so
+// its softDisabled property should never become true after that
+
+// softblock3 does the same as softblock2 however it remains disabled
+
+// softblock4 is disabled while unblocked and so should never have softDisabled
+// set to true and stay userDisabled. This add-on is not used in tests that
+// start with add-ons blocked as it would be identical to softblock3
+
+const URI_EXTENSION_BLOCKLIST_DIALOG =
+ "chrome://mozapps/content/extensions/blocklist.xhtml";
+
+// Allow insecure updates
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+const IS_ANDROID_WITH_BLOCKLIST_V2 =
+ AppConstants.platform == "android" && !AppConstants.NIGHTLY_BUILD;
+
+// This is the initial value of Blocklist.allowDeprecatedBlocklistV2.
+if (IS_ANDROID_WITH_BLOCKLIST_V2) {
+ // test_blocklistchange_v2.js tests blocklist v2, so we should flip the pref
+ // to enable the v3 blocklist on Android.
+ Assert.ok(
+ _TEST_NAME.includes("test_blocklistchange"),
+ `Expected _TEST_NAME to be test_blocklistchange{,_v2}.js`
+ );
+ if (_TEST_NAME.includes("test_blocklistchange.js")) {
+ Assert.equal(
+ Services.prefs.getBoolPref("extensions.blocklist.useMLBF"),
+ false,
+ "Blocklist v3 disabled by default on Android"
+ );
+ Services.prefs.setBoolPref("extensions.blocklist.useMLBF", true);
+ }
+}
+
+// TODO bug 1649906: strip blocklist v2-specific parts of this test.
+// All specific logic is already covered by other test files, but the tests
+// here trigger the logic via higher-level methods, so it may make sense to
+// keep this file even after the removal of blocklist v2.
+const useMLBF = Services.prefs.getBoolPref(
+ "extensions.blocklist.useMLBF",
+ true
+);
+
+var testserver = createHttpServer({ hosts: ["example.com"] });
+
+function permissionPromptHandler(subject, topic, data) {
+ ok(
+ subject?.wrappedJSObject?.info?.resolve,
+ "Got a permission prompt notification as expected"
+ );
+ subject.wrappedJSObject.info.resolve();
+}
+
+Services.obs.addObserver(
+ permissionPromptHandler,
+ "webextension-permission-prompt"
+);
+
+registerCleanupFunction(() => {
+ Services.obs.removeObserver(
+ permissionPromptHandler,
+ "webextension-permission-prompt"
+ );
+});
+
+const XPIS = {};
+
+const ADDON_IDS = [
+ "softblock1@tests.mozilla.org",
+ "softblock2@tests.mozilla.org",
+ "softblock3@tests.mozilla.org",
+ "softblock4@tests.mozilla.org",
+ "hardblock@tests.mozilla.org",
+ "regexpblock@tests.mozilla.org",
+];
+
+const BLOCK_APP = [
+ {
+ guid: "xpcshell@tests.mozilla.org",
+ maxVersion: "2.*",
+ minVersion: "2",
+ },
+];
+// JEXL filter expression that matches BLOCK_APP.
+const BLOCK_APP_FILTER_EXPRESSION = `env.appinfo.ID == "xpcshell@tests.mozilla.org" && env.appinfo.version >= "2" && env.appinfo.version < "3"`;
+
+function softBlockApp(id) {
+ return {
+ guid: `${id}@tests.mozilla.org`,
+ versionRange: [
+ {
+ severity: "1",
+ targetApplication: BLOCK_APP,
+ },
+ ],
+ };
+}
+
+function softBlockAddonChange(id) {
+ return {
+ guid: `${id}@tests.mozilla.org`,
+ versionRange: [
+ {
+ severity: "1",
+ minVersion: "2",
+ maxVersion: "3",
+ },
+ ],
+ };
+}
+
+function softBlockUpdate2(id) {
+ return {
+ guid: `${id}@tests.mozilla.org`,
+ versionRange: [{ severity: "1" }],
+ };
+}
+
+function softBlockManual(id) {
+ return {
+ guid: `${id}@tests.mozilla.org`,
+ versionRange: [
+ {
+ maxVersion: "2",
+ minVersion: "1",
+ severity: "1",
+ },
+ ],
+ };
+}
+
+const BLOCKLIST_DATA = {
+ empty_blocklist: [],
+ app_update: [
+ softBlockApp("softblock1"),
+ softBlockApp("softblock2"),
+ softBlockApp("softblock3"),
+ softBlockApp("softblock4"),
+ {
+ guid: "hardblock@tests.mozilla.org",
+ versionRange: [
+ {
+ targetApplication: BLOCK_APP,
+ },
+ ],
+ },
+ {
+ guid: "/^RegExp/",
+ versionRange: [
+ {
+ severity: "1",
+ targetApplication: BLOCK_APP,
+ },
+ ],
+ },
+ {
+ guid: "/^RegExp/i",
+ versionRange: [
+ {
+ targetApplication: BLOCK_APP,
+ },
+ ],
+ },
+ ],
+ addon_change: [
+ softBlockAddonChange("softblock1"),
+ softBlockAddonChange("softblock2"),
+ softBlockAddonChange("softblock3"),
+ softBlockAddonChange("softblock4"),
+ {
+ guid: "hardblock@tests.mozilla.org",
+ versionRange: [
+ {
+ maxVersion: "3",
+ minVersion: "2",
+ },
+ ],
+ },
+ {
+ _comment:
+ "Two RegExp matches, so test flags work - first shouldn't match.",
+ guid: "/^RegExp/",
+ versionRange: [
+ {
+ maxVersion: "3",
+ minVersion: "2",
+ severity: "1",
+ },
+ ],
+ },
+ {
+ guid: "/^RegExp/i",
+ versionRange: [
+ {
+ maxVersion: "3",
+ minVersion: "2",
+ severity: "2",
+ },
+ ],
+ },
+ ],
+ blocklist_update2: [
+ softBlockUpdate2("softblock1"),
+ softBlockUpdate2("softblock2"),
+ softBlockUpdate2("softblock3"),
+ softBlockUpdate2("softblock4"),
+ {
+ guid: "hardblock@tests.mozilla.org",
+ versionRange: [],
+ },
+ {
+ guid: "/^RegExp/",
+ versionRange: [{ severity: "1" }],
+ },
+ {
+ guid: "/^RegExp/i",
+ versionRange: [],
+ },
+ ],
+ manual_update: [
+ softBlockManual("softblock1"),
+ softBlockManual("softblock2"),
+ softBlockManual("softblock3"),
+ softBlockManual("softblock4"),
+ {
+ guid: "hardblock@tests.mozilla.org",
+ versionRange: [
+ {
+ maxVersion: "2",
+ minVersion: "1",
+ },
+ ],
+ },
+ {
+ guid: "/^RegExp/i",
+ versionRange: [
+ {
+ maxVersion: "2",
+ minVersion: "1",
+ },
+ ],
+ },
+ ],
+};
+
+// Blocklist v3 (useMLBF) only supports hard blocks by guid+version. Version
+// ranges, regexps and soft blocks are not supported. So adjust expectations to
+// ensure that the test passes even if useMLBF=true, by:
+// - soft blocks are converted to hard blocks.
+// - hard blocks are accepted as-is.
+// - regexps blocks are converted to hard blocks.
+// - Version ranges are expanded to cover all known versions.
+if (useMLBF) {
+ for (let [key, blocks] of Object.entries(BLOCKLIST_DATA)) {
+ BLOCKLIST_DATA[key] = [];
+ for (let block of blocks) {
+ let { guid } = block;
+ if (guid.includes("RegExp")) {
+ guid = "regexpblock@tests.mozilla.org";
+ } else if (!guid.startsWith("soft") && !guid.startsWith("hard")) {
+ throw new Error(`Unexpected mock addon ID: ${guid}`);
+ }
+
+ const {
+ minVersion = "1",
+ maxVersion = "3",
+ targetApplication,
+ } = block.versionRange?.[0] || {};
+
+ for (let v = minVersion; v <= maxVersion; ++v) {
+ BLOCKLIST_DATA[key].push({
+ // Assume that IF targetApplication is set, that it is BLOCK_APP.
+ filter_expression: targetApplication && BLOCK_APP_FILTER_EXPRESSION,
+ stash: {
+ // XPI files use version `${v}.0`, update manifests use `${v}`.
+ blocked: [`${guid}:${v}.0`, `${guid}:${v}`],
+ unblocked: [],
+ },
+ });
+ }
+ }
+ }
+}
+
+// XXXgijs: according to https://bugzilla.mozilla.org/show_bug.cgi?id=1257565#c111
+// this code and the related code in Blocklist.jsm (specific to XML blocklist) is
+// dead code and can be removed. See https://bugzilla.mozilla.org/show_bug.cgi?id=1549550 .
+//
+// Don't need the full interface, attempts to call other methods will just
+// throw which is just fine
+var WindowWatcher = {
+ openWindow(parent, url, name, features, openArgs) {
+ // Should be called to list the newly blocklisted items
+ Assert.equal(url, URI_EXTENSION_BLOCKLIST_DIALOG);
+
+ // Simulate auto-disabling any softblocks
+ var list = openArgs.wrappedJSObject.list;
+ list.forEach(function (aItem) {
+ if (!aItem.blocked) {
+ aItem.disable = true;
+ }
+ });
+
+ // run the code after the blocklist is closed
+ Services.obs.notifyObservers(null, "addon-blocklist-closed");
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIWindowWatcher"]),
+};
+
+MockRegistrar.register(
+ "@mozilla.org/embedcomp/window-watcher;1",
+ WindowWatcher
+);
+
+var InstallConfirm = {
+ confirm(aWindow, aUrl, aInstalls) {
+ aInstalls.forEach(function (aInstall) {
+ aInstall.install();
+ });
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["amIWebInstallPrompt"]),
+};
+
+var InstallConfirmFactory = {
+ createInstance: function createInstance(iid) {
+ return InstallConfirm.QueryInterface(iid);
+ },
+};
+
+var registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+registrar.registerFactory(
+ Components.ID("{f0863905-4dde-42e2-991c-2dc8209bc9ca}"),
+ "Fake Install Prompt",
+ "@mozilla.org/addons/web-install-prompt;1",
+ InstallConfirmFactory
+);
+
+function Pload_blocklist(aId) {
+ return AddonTestUtils.loadBlocklistRawData({
+ [useMLBF ? "extensionsMLBF" : "extensions"]: BLOCKLIST_DATA[aId],
+ });
+}
+
+// Does a background update check for add-ons and returns a promise that
+// resolves when any started installs complete
+function Pbackground_update() {
+ return new Promise((resolve, reject) => {
+ let installCount = 0;
+ let backgroundCheckCompleted = false;
+
+ AddonManager.addInstallListener({
+ onNewInstall(aInstall) {
+ installCount++;
+ },
+
+ onInstallEnded(aInstall) {
+ installCount--;
+ // Wait until all started installs have completed
+ if (installCount) {
+ return;
+ }
+
+ AddonManager.removeInstallListener(this);
+
+ // If the background check hasn't yet completed then let that call the
+ // callback when it is done
+ if (!backgroundCheckCompleted) {
+ return;
+ }
+
+ resolve();
+ },
+ });
+
+ Services.obs.addObserver(function observer() {
+ Services.obs.removeObserver(
+ observer,
+ "addons-background-update-complete"
+ );
+ backgroundCheckCompleted = true;
+
+ // If any new installs have started then we'll call the callback once they
+ // are completed
+ if (installCount) {
+ return;
+ }
+
+ resolve();
+ }, "addons-background-update-complete");
+
+ AddonManagerPrivate.backgroundUpdateCheck();
+ });
+}
+
+// Manually updates the test add-ons to the given version
+function Pmanual_update(aVersion) {
+ const names = ["soft1", "soft2", "soft3", "soft4", "hard", "regexp"];
+ return Promise.all(
+ names.map(async name => {
+ let url = `http://example.com/addons/blocklist_${name}_${aVersion}.xpi`;
+ let install = await AddonManager.getInstallForURL(url);
+
+ // installAddonFromAOM() does more checking than install.install().
+ // In particular, it will refuse to install an incompatible addon.
+
+ return new Promise(resolve => {
+ install.addListener({
+ onDownloadCancelled: resolve,
+ onInstallEnded: resolve,
+ });
+
+ AddonManager.installAddonFromAOM(null, null, install);
+ });
+ })
+ );
+}
+
+// Checks that an add-ons properties match expected values
+function check_addon(
+ aAddon,
+ aExpectedVersion,
+ aExpectedUserDisabled,
+ aExpectedSoftDisabled,
+ aExpectedState
+) {
+ if (useMLBF) {
+ if (aAddon.id.startsWith("soft")) {
+ if (aExpectedState === Ci.nsIBlocklistService.STATE_SOFTBLOCKED) {
+ // The whole test file assumes that an add-on is "user-disabled" after
+ // an explicit disable(), or after a soft block (without enable()).
+ // With useMLBF, soft blocks are not supported, so the "user-disabled"
+ // state matches the usual behavior of "userDisabled" (=disable()).
+ aExpectedUserDisabled = aAddon.userDisabled;
+ aExpectedSoftDisabled = false;
+ aExpectedState = Ci.nsIBlocklistService.STATE_BLOCKED;
+ }
+ }
+ }
+
+ Assert.notEqual(aAddon, null);
+ info(
+ "Testing " +
+ aAddon.id +
+ " version " +
+ aAddon.version +
+ " user " +
+ aAddon.userDisabled +
+ " soft " +
+ aAddon.softDisabled +
+ " perms " +
+ aAddon.permissions
+ );
+
+ Assert.equal(aAddon.version, aExpectedVersion);
+ Assert.equal(aAddon.blocklistState, aExpectedState);
+ Assert.equal(aAddon.userDisabled, aExpectedUserDisabled);
+ Assert.equal(aAddon.softDisabled, aExpectedSoftDisabled);
+ if (aAddon.softDisabled) {
+ Assert.ok(aAddon.userDisabled);
+ }
+
+ if (aExpectedState == Ci.nsIBlocklistService.STATE_BLOCKED) {
+ info("blocked, PERM_CAN_ENABLE " + aAddon.id);
+ Assert.ok(!hasFlag(aAddon.permissions, AddonManager.PERM_CAN_ENABLE));
+ info("blocked, PERM_CAN_DISABLE " + aAddon.id);
+ Assert.ok(!hasFlag(aAddon.permissions, AddonManager.PERM_CAN_DISABLE));
+ } else if (aAddon.userDisabled) {
+ info("userDisabled, PERM_CAN_ENABLE " + aAddon.id);
+ Assert.ok(hasFlag(aAddon.permissions, AddonManager.PERM_CAN_ENABLE));
+ info("userDisabled, PERM_CAN_DISABLE " + aAddon.id);
+ Assert.ok(!hasFlag(aAddon.permissions, AddonManager.PERM_CAN_DISABLE));
+ } else {
+ info("other, PERM_CAN_ENABLE " + aAddon.id);
+ Assert.ok(!hasFlag(aAddon.permissions, AddonManager.PERM_CAN_ENABLE));
+ if (aAddon.type != "theme") {
+ info("other, PERM_CAN_DISABLE " + aAddon.id);
+ Assert.ok(hasFlag(aAddon.permissions, AddonManager.PERM_CAN_DISABLE));
+ }
+ }
+ Assert.equal(
+ aAddon.appDisabled,
+ aExpectedState == Ci.nsIBlocklistService.STATE_BLOCKED
+ );
+
+ let willBeActive = aAddon.isActive;
+ if (hasFlag(aAddon.pendingOperations, AddonManager.PENDING_DISABLE)) {
+ willBeActive = false;
+ } else if (hasFlag(aAddon.pendingOperations, AddonManager.PENDING_ENABLE)) {
+ willBeActive = true;
+ }
+
+ if (
+ aExpectedUserDisabled ||
+ aExpectedState == Ci.nsIBlocklistService.STATE_BLOCKED
+ ) {
+ Assert.ok(!willBeActive);
+ } else {
+ Assert.ok(willBeActive);
+ }
+}
+
+async function promiseRestartManagerWithAppChange(version) {
+ await promiseShutdownManager();
+ await promiseStartupManagerWithAppChange(version);
+}
+
+async function promiseStartupManagerWithAppChange(version) {
+ if (version) {
+ AddonTestUtils.appInfo.version = version;
+ }
+ if (useMLBF) {
+ // The old ExtensionBlocklist enforced the app version/ID part of the block
+ // when the blocklist entry is checked.
+ // The new ExtensionBlocklist (with useMLBF=true) does not separately check
+ // the app version/ID, but the underlying data source (Remote Settings)
+ // does offer the ability to filter entries with `filter_expression`.
+ // Force a reload to ensure that the BLOCK_APP_FILTER_EXPRESSION filter in
+ // this test file is checked again against the new version.
+ await Blocklist.ExtensionBlocklist._updateMLBF();
+ }
+ await promiseStartupManager();
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ if (useMLBF) {
+ const { ClientEnvironmentBase } = ChromeUtils.importESModule(
+ "resource://gre/modules/components-utils/ClientEnvironment.sys.mjs"
+ );
+ Object.defineProperty(ClientEnvironmentBase, "appinfo", {
+ configurable: true,
+ get() {
+ return gAppInfo;
+ },
+ });
+ }
+
+ function getxpibasename(id, version) {
+ // pattern used to map ids like softblock1 to soft1
+ let pattern = /^(soft|hard|regexp)block([1-9]*)@/;
+ let match = id.match(pattern);
+ return `blocklist_${match[1]}${match[2]}_${version}`;
+ }
+ for (let id of ADDON_IDS) {
+ for (let version of [1, 2, 3, 4]) {
+ let name = getxpibasename(id, version);
+
+ let xpi = createTempWebExtensionFile({
+ manifest: {
+ name: "Test",
+ version: `${version}.0`,
+ browser_specific_settings: {
+ gecko: {
+ id,
+ // This file is generated below, as updateJson.
+ update_url: `http://example.com/addon_update${version}.json`,
+ },
+ },
+ },
+ });
+
+ // To test updates, individual tasks in this test file start the test by
+ // installing a set of add-ons with version |version| and trigger an
+ // update check, from XPIS.${nameprefix}${version} (version = 1, 2, 3)
+ if (version != 4) {
+ XPIS[name] = xpi;
+ }
+
+ // update_url above points to a test manifest that references the next
+ // version. The xpi is made available on the server, so that the test
+ // can verify that the blocklist works as intended (i.e. update to newer
+ // version is blocked).
+ // There is nothing that updates to version 1, only to versions 2, 3, 4.
+ if (version != 1) {
+ testserver.registerFile(`/addons/${name}.xpi`, xpi);
+ }
+ }
+ }
+
+ // For each version that this test file uses, create a test manifest that
+ // references the next version for each id in ADDON_IDS.
+ for (let version of [1, 2, 3]) {
+ let updateJson = { addons: {} };
+ for (let id of ADDON_IDS) {
+ let nextversion = version + 1;
+ let name = getxpibasename(id, nextversion);
+ updateJson.addons[id] = {
+ updates: [
+ {
+ applications: {
+ gecko: {
+ strict_min_version: "0",
+ advisory_max_version: "*",
+ },
+ },
+ version: `${nextversion}.0`,
+ update_link: `http://example.com/addons/${name}.xpi`,
+ },
+ ],
+ };
+ }
+ AddonTestUtils.registerJSON(
+ testserver,
+ `/addon_update${version}.json`,
+ updateJson
+ );
+ }
+
+ await promiseStartupManager();
+
+ await promiseInstallFile(XPIS.blocklist_soft1_1);
+ await promiseInstallFile(XPIS.blocklist_soft2_1);
+ await promiseInstallFile(XPIS.blocklist_soft3_1);
+ await promiseInstallFile(XPIS.blocklist_soft4_1);
+ await promiseInstallFile(XPIS.blocklist_hard_1);
+ await promiseInstallFile(XPIS.blocklist_regexp_1);
+
+ let s4 = await promiseAddonByID("softblock4@tests.mozilla.org");
+ await s4.disable();
+});
+
+// Starts with add-ons unblocked and then switches application versions to
+// change add-ons to blocked and back
+add_task(async function run_app_update_test() {
+ await Pload_blocklist("app_update");
+ await promiseRestartManager();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s2,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+});
+
+add_task(async function app_update_step_2() {
+ await promiseRestartManagerWithAppChange("2");
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await s2.enable();
+ await s2.disable();
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ await s3.enable();
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+});
+
+add_task(async function app_update_step_3() {
+ await promiseRestartManager();
+
+ await promiseRestartManagerWithAppChange("2.5");
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+});
+
+add_task(async function app_update_step_4() {
+ await promiseRestartManagerWithAppChange("1");
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await s1.enable();
+ await s2.enable();
+});
+
+// Starts with add-ons unblocked and then switches application versions to
+// change add-ons to blocked and back. A DB schema change is faked to force a
+// rebuild when the application version changes
+add_task(async function run_app_update_schema_test() {
+ await promiseRestartManager();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s2,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+});
+
+add_task(async function update_schema_2() {
+ await promiseShutdownManager();
+
+ await changeXPIDBVersion(100);
+ gAppInfo.version = "2";
+ await promiseStartupManagerWithAppChange();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await s2.enable();
+ await s2.disable();
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ await s3.enable();
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+});
+
+add_task(async function update_schema_3() {
+ await promiseRestartManager();
+
+ await promiseShutdownManager();
+ await changeXPIDBVersion(100);
+ gAppInfo.version = "2.5";
+ await promiseStartupManagerWithAppChange();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+});
+
+add_task(async function update_schema_4() {
+ await promiseShutdownManager();
+
+ await changeXPIDBVersion(100);
+ await promiseStartupManager();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+});
+
+add_task(async function update_schema_5() {
+ await promiseShutdownManager();
+
+ await changeXPIDBVersion(100);
+ gAppInfo.version = "1";
+ await promiseStartupManagerWithAppChange();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await s1.enable();
+ await s2.enable();
+});
+
+// Starts with add-ons unblocked and then loads new blocklists to change add-ons
+// to blocked and back again.
+add_task(async function run_blocklist_update_test() {
+ await Pload_blocklist("empty_blocklist");
+ await promiseRestartManager();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s2,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await Pload_blocklist("blocklist_update2");
+ await promiseRestartManager();
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await s2.enable();
+ await s2.disable();
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ await s3.enable();
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+
+ await promiseRestartManager();
+
+ await Pload_blocklist("blocklist_update2");
+ await promiseRestartManager();
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await Pload_blocklist("empty_blocklist");
+ await promiseRestartManager();
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await s1.enable();
+ await s2.enable();
+});
+
+// Starts with add-ons unblocked and then new versions are installed outside of
+// the app to change them to blocked and back again.
+add_task(async function run_addon_change_test() {
+ await Pload_blocklist("addon_change");
+ await promiseRestartManager();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s2,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+});
+
+add_task(async function run_addon_change_2() {
+ await promiseInstallFile(XPIS.blocklist_soft1_2);
+ await promiseInstallFile(XPIS.blocklist_soft2_2);
+ await promiseInstallFile(XPIS.blocklist_soft3_2);
+ await promiseInstallFile(XPIS.blocklist_soft4_2);
+ await promiseInstallFile(XPIS.blocklist_hard_2);
+ await promiseInstallFile(XPIS.blocklist_regexp_2);
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "2.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "2.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, "2.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s4, "2.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "2.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "2.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await s2.enable();
+ await s2.disable();
+ check_addon(s2, "2.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ await s3.enable();
+ check_addon(
+ s3,
+ "2.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+});
+
+add_task(async function run_addon_change_3() {
+ await promiseInstallFile(XPIS.blocklist_soft1_3);
+ await promiseInstallFile(XPIS.blocklist_soft2_3);
+ await promiseInstallFile(XPIS.blocklist_soft3_3);
+ await promiseInstallFile(XPIS.blocklist_soft4_3);
+ await promiseInstallFile(XPIS.blocklist_hard_3);
+ await promiseInstallFile(XPIS.blocklist_regexp_3);
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "3.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "3.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(
+ s3,
+ "3.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+ check_addon(s4, "3.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "3.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "3.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+});
+
+add_task(async function run_addon_change_4() {
+ await promiseInstallFile(XPIS.blocklist_soft1_1);
+ await promiseInstallFile(XPIS.blocklist_soft2_1);
+ await promiseInstallFile(XPIS.blocklist_soft3_1);
+ await promiseInstallFile(XPIS.blocklist_soft4_1);
+ await promiseInstallFile(XPIS.blocklist_hard_1);
+ await promiseInstallFile(XPIS.blocklist_regexp_1);
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await s1.enable();
+ await s2.enable();
+});
+
+// Add-ons are initially unblocked then attempts to upgrade to blocked versions
+// in the background which should fail
+add_task(async function run_background_update_test() {
+ await promiseRestartManager();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s2,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await Pbackground_update();
+ await promiseRestartManager();
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s2,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+});
+
+// Starts with add-ons blocked and then new versions are detected and installed
+// automatically for unblocked versions.
+add_task(async function run_background_update_2_test() {
+ await promiseInstallFile(XPIS.blocklist_soft1_3);
+ await promiseInstallFile(XPIS.blocklist_soft2_3);
+ await promiseInstallFile(XPIS.blocklist_soft3_3);
+ await promiseInstallFile(XPIS.blocklist_soft4_3);
+ await promiseInstallFile(XPIS.blocklist_hard_3);
+ await promiseInstallFile(XPIS.blocklist_regexp_3);
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "3.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "3.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, "3.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "3.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "3.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await s2.enable();
+ await s2.disable();
+ check_addon(s2, "3.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ await s3.enable();
+ check_addon(
+ s3,
+ "3.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+
+ await Pbackground_update();
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "4.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s2, "4.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(
+ s3,
+ "4.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(h, "4.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "4.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await s1.enable();
+ await s2.enable();
+ await s4.disable();
+});
+
+// The next test task (run_manual_update_test) was written to expect version 1,
+// but after the previous test, version 4 of the add-ons were installed.
+add_task(async function reset_addons_to_version_1_instead_of_4() {
+ await promiseInstallFile(XPIS.blocklist_soft1_1);
+ await promiseInstallFile(XPIS.blocklist_soft2_1);
+ await promiseInstallFile(XPIS.blocklist_soft3_1);
+ await promiseInstallFile(XPIS.blocklist_soft4_1);
+ await promiseInstallFile(XPIS.blocklist_hard_1);
+ await promiseInstallFile(XPIS.blocklist_regexp_1);
+});
+
+// Starts with add-ons blocked and then simulates the user upgrading them to
+// unblocked versions.
+add_task(async function run_manual_update_test() {
+ await Pload_blocklist("manual_update");
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await s2.enable();
+ await s2.disable();
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ await s3.enable();
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+
+ await Pmanual_update("2");
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ // With useMLBF, s1/s2/s3 are hard blocks, so they cannot update.
+ const sv2 = useMLBF ? "1.0" : "2.0";
+ check_addon(s1, sv2, true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, sv2, true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, sv2, false, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s4, sv2, true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ // Can't manually update to a hardblocked add-on
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await Pmanual_update("3");
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "3.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s2, "3.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(
+ s3,
+ "3.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "3.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "3.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "3.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+});
+
+// Starts with add-ons blocked and then new versions are installed outside of
+// the app to change them to unblocked.
+add_task(async function run_manual_update_2_test() {
+ let addons = await promiseAddonsByIDs(ADDON_IDS);
+ await Promise.all(addons.map(addon => addon.uninstall()));
+
+ await promiseInstallFile(XPIS.blocklist_soft1_1);
+ await promiseInstallFile(XPIS.blocklist_soft2_1);
+ await promiseInstallFile(XPIS.blocklist_soft3_1);
+ await promiseInstallFile(XPIS.blocklist_soft4_1);
+ await promiseInstallFile(XPIS.blocklist_hard_1);
+ await promiseInstallFile(XPIS.blocklist_regexp_1);
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await s2.enable();
+ await s2.disable();
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ await s3.enable();
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+
+ await Pmanual_update("2");
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ // With useMLBF, s1/s2/s3 are hard blocks, so they cannot update.
+ const sv2 = useMLBF ? "1.0" : "2.0";
+ check_addon(s1, sv2, true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, sv2, true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, sv2, false, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ // Can't manually update to a hardblocked add-on
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await Pmanual_update("3");
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "3.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s2, "3.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(
+ s3,
+ "3.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(h, "3.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "3.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await s1.enable();
+ await s2.enable();
+ await s4.disable();
+});
+
+// Uses the API to install blocked add-ons from the local filesystem
+add_task(async function run_local_install_test() {
+ let addons = await promiseAddonsByIDs(ADDON_IDS);
+ await Promise.all(addons.map(addon => addon.uninstall()));
+
+ await promiseInstallAllFiles([
+ XPIS.blocklist_soft1_1,
+ XPIS.blocklist_soft2_1,
+ XPIS.blocklist_soft3_1,
+ XPIS.blocklist_soft4_1,
+ XPIS.blocklist_hard_1,
+ XPIS.blocklist_regexp_1,
+ ]);
+
+ let installs = await AddonManager.getAllInstalls();
+ // Should have finished all installs without needing to restart
+ Assert.equal(installs.length, 0);
+
+ let [s1, s2, s3 /* s4 */, , h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklistchange_v2.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklistchange_v2.js
new file mode 100644
index 0000000000..d884438def
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklistchange_v2.js
@@ -0,0 +1,13 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// useMLBF=true doesn't support soft blocks, regexps or version ranges.
+// Flip the useMLBF preference to make sure that the test_blocklistchange.js
+// test works with and without this pref (blocklist v2 and blocklist v3).
+enable_blocklist_v2_instead_of_useMLBF();
+
+Services.scriptloader.loadSubScript(
+ Services.io.newFileURI(do_get_file("test_blocklistchange.js")).spec,
+ this
+);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Device.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Device.js
new file mode 100644
index 0000000000..9b1d84b77d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Device.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which differs only on device ID, but otherwise
+// exactly matches the blacklist entry, is not blocked.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x9876");
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x9876");
+ break;
+ case "Darwin":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x9876");
+ gfxInfo.spoofOSVersion(0xa0900);
+ break;
+ case "Android":
+ gfxInfo.spoofVendorID("abcd");
+ gfxInfo.spoofDeviceID("aabb");
+ gfxInfo.spoofDriverVersion("5");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_CANVAS2D_ACCELERATION
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_DriverNew.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_DriverNew.js
new file mode 100644
index 0000000000..a1bcde5566
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_DriverNew.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a new-enough driver bypasses the blacklist, even if the rest of
+// the attributes match the blacklist entry.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.2202");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ // We don't support driver versions on Linux.
+ do_test_finished();
+ return;
+ case "Darwin":
+ // We don't support driver versions on Darwin.
+ do_test_finished();
+ return;
+ case "Android":
+ gfxInfo.spoofVendorID("abcd");
+ gfxInfo.spoofDeviceID("wxyz");
+ gfxInfo.spoofDriverVersion("6");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_DriverNew.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_DriverNew.js
new file mode 100644
index 0000000000..ec74d813ae
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_DriverNew.js
@@ -0,0 +1,112 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which is newer than the equal
+// blacklist entry is allowed.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xdcdc");
+ gfxInfo.spoofDeviceID("0x1234");
+ // test_gfxBlacklist.json has several entries targeting "os": "All"
+ // ("All" meaning "All Windows"), with several combinations of
+ // "driverVersion" / "driverVersionMax" / "driverVersionComparator".
+ gfxInfo.spoofDriverVersion("8.52.322.1112");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ // We don't support driver versions on Linux.
+ // XXX don't we? Seems like we do since bug 1294232 with the change in
+ // https://hg.mozilla.org/mozilla-central/diff/8962b8d9b7a6/widget/GfxInfoBase.cpp
+ // To update this test, we'd have to update test_gfxBlacklist.json in a
+ // way similar to how bug 1714673 was resolved for Android.
+ do_test_finished();
+ return;
+ case "Darwin":
+ // We don't support driver versions on OS X.
+ do_test_finished();
+ return;
+ case "Android":
+ gfxInfo.spoofVendorID("dcdc");
+ gfxInfo.spoofDeviceID("uiop");
+ gfxInfo.spoofDriverVersion("6");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "15.0", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_11_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_OPENGL_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_11_ANGLE);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_HARDWARE_VIDEO_DECODING
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_WEBRTC_HW_ACCELERATION_H264
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_WEBRTC_HW_ACCELERATION_DECODE
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_WEBRTC_HW_ACCELERATION_ENCODE
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_WEBGL_ANGLE);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_CANVAS2D_ACCELERATION
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_DriverOld.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_DriverOld.js
new file mode 100644
index 0000000000..ff887a92eb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_DriverOld.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which is older than the equal
+// blacklist entry is correctly allowed.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xdcdc");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.1110");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ // We don't support driver versions on Linux.
+ do_test_finished();
+ return;
+ case "Darwin":
+ // We don't support driver versions on Darwin.
+ do_test_finished();
+ return;
+ case "Android":
+ gfxInfo.spoofVendorID("dcdc");
+ gfxInfo.spoofDeviceID("uiop");
+ gfxInfo.spoofDriverVersion("4");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_OK.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_OK.js
new file mode 100644
index 0000000000..1eef119663
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_OK.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which exactly matches the equal
+// blacklist entry is successfully blocked.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xdcdc");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.1111");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ // We don't support driver versions on Linux.
+ do_test_finished();
+ return;
+ case "Darwin":
+ // We don't support driver versions on Darwin.
+ do_test_finished();
+ return;
+ case "Android":
+ gfxInfo.spoofVendorID("dcdc");
+ gfxInfo.spoofDeviceID("uiop");
+ gfxInfo.spoofDriverVersion("5");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_GTE_DriverOld.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_GTE_DriverOld.js
new file mode 100644
index 0000000000..182c825ffb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_GTE_DriverOld.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which is lower than the greater-than-or-equal
+// blacklist entry is allowed.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xabab");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ // We don't support driver versions on Linux.
+ do_test_finished();
+ return;
+ case "Darwin":
+ // We don't support driver versions on Darwin.
+ do_test_finished();
+ return;
+ case "Android":
+ gfxInfo.spoofVendorID("abab");
+ gfxInfo.spoofDeviceID("ghjk");
+ gfxInfo.spoofDriverVersion("6");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_GTE_OK.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_GTE_OK.js
new file mode 100644
index 0000000000..2cc3686007
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_GTE_OK.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which exactly matches the greater-than-or-equal
+// blacklist entry is successfully blocked.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xabab");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.2202");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ // We don't support driver versions on Linux.
+ // XXX don't we? Seems like we do since bug 1294232 with the change in
+ // https://hg.mozilla.org/mozilla-central/diff/8962b8d9b7a6/widget/GfxInfoBase.cpp
+ do_test_finished();
+ return;
+ case "Darwin":
+ // We don't support driver versions on Darwin.
+ do_test_finished();
+ return;
+ case "Android":
+ gfxInfo.spoofVendorID("abab");
+ gfxInfo.spoofDeviceID("ghjk");
+ gfxInfo.spoofDriverVersion("7");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_No_Comparison.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_No_Comparison.js
new file mode 100644
index 0000000000..169cdc5e62
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_No_Comparison.js
@@ -0,0 +1,69 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which exactly matches the blacklist entry is
+// successfully blocked.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x6666");
+
+ // Spoof the OS version so it matches the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ break;
+ case "Darwin":
+ gfxInfo.spoofOSVersion(0xa0900);
+ break;
+ case "Android":
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var driverVersion = gfxInfo.adapterDriverVersion;
+ if (driverVersion) {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DEVICE);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_WEBRENDER);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DEVICE);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ }
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OK.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OK.js
new file mode 100644
index 0000000000..04d766e027
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OK.js
@@ -0,0 +1,69 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which exactly matches the blacklist entry is
+// successfully blocked.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ break;
+ case "Darwin":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofOSVersion(0xa0900);
+ break;
+ case "Android":
+ gfxInfo.spoofVendorID("abcd");
+ gfxInfo.spoofDeviceID("asdf");
+ gfxInfo.spoofDriverVersion("5");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OS.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OS.js
new file mode 100644
index 0000000000..ce5a61cb75
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OS.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which differs only on OS version, but otherwise
+// exactly matches the blacklist entry, is not blocked.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ // Windows Vista
+ gfxInfo.spoofOSVersion(0x60000);
+ break;
+ case "Linux":
+ // We don't have any OS versions on Linux, just "Linux".
+ do_test_finished();
+ return;
+ case "Darwin":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofOSVersion(0xa0800);
+ break;
+ case "Android":
+ // On Android, the driver version is used as the OS version (because
+ // there's so many of them).
+ do_test_finished();
+ return;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_match.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_match.js
new file mode 100644
index 0000000000..7a4ec276ee
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_match.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether new OS versions are matched properly.
+// Uses test_gfxBlacklist_OSVersion.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+
+ // Spoof the version of the OS appropriately to test the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ // Windows 8
+ gfxInfo.spoofOSVersion(0x60002);
+ break;
+ case "Linux":
+ // We don't have any OS versions on Linux, just "Linux".
+ do_test_finished();
+ return;
+ case "Darwin":
+ // Mountain Lion
+ gfxInfo.spoofOSVersion(0xa0900);
+ break;
+ case "Android":
+ // On Android, the driver version is used as the OS version (because
+ // there's so many of them).
+ do_test_finished();
+ return;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ if (Services.appinfo.OS == "WINNT") {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+ } else if (Services.appinfo.OS == "Darwin") {
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_OPENGL_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+ }
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist_OSVersion.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_mismatch_DriverVersion.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_mismatch_DriverVersion.js
new file mode 100644
index 0000000000..61dba8db96
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_mismatch_DriverVersion.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether blocklists specifying new OSes correctly don't block if driver
+// versions are appropriately up-to-date.
+// Uses test_gfxBlacklist_OSVersion.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ gfxInfo.spoofDriverVersion("8.52.322.2202");
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+
+ // Spoof the version of the OS appropriately to test the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ // Windows 8
+ gfxInfo.spoofOSVersion(0x60002);
+ break;
+ case "Linux":
+ // We don't have any OS versions on Linux, just "Linux".
+ do_test_finished();
+ return;
+ case "Darwin":
+ gfxInfo.spoofOSVersion(0xa0800);
+ break;
+ case "Android":
+ // On Android, the driver version is used as the OS version (because
+ // there's so many of them).
+ do_test_finished();
+ return;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ if (Services.appinfo.OS == "WINNT") {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ } else if (Services.appinfo.OS == "Darwin") {
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_OPENGL_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ }
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist_OSVersion.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_mismatch_OSVersion.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_mismatch_OSVersion.js
new file mode 100644
index 0000000000..117e2a34ee
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_mismatch_OSVersion.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether old OS versions are not matched when the blacklist contains
+// only new OS versions.
+// Uses test_gfxBlacklist_OSVersion.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+
+ // Spoof the version of the OS appropriately to test the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ // We don't have any OS versions on Linux, just "Linux".
+ do_test_finished();
+ return;
+ case "Darwin":
+ // Lion
+ gfxInfo.spoofOSVersion(0xa0800);
+ break;
+ case "Android":
+ // On Android, the driver version is used as the OS version (because
+ // there's so many of them).
+ do_test_finished();
+ return;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ if (Services.appinfo.OS == "WINNT") {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ } else if (Services.appinfo.OS == "Darwin") {
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_OPENGL_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ }
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist_OSVersion.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Vendor.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Vendor.js
new file mode 100644
index 0000000000..37bc0d3c89
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Vendor.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which differs only on vendor, but otherwise
+// exactly matches the blacklist entry, is not blocked.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xdcba");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ gfxInfo.spoofVendorID("0xdcba");
+ gfxInfo.spoofDeviceID("0x1234");
+ break;
+ case "Darwin":
+ gfxInfo.spoofVendorID("0xdcba");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofOSVersion(0xa0900);
+ break;
+ case "Android":
+ gfxInfo.spoofVendorID("dcba");
+ gfxInfo.spoofDeviceID("asdf");
+ gfxInfo.spoofDriverVersion("5");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Version.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Version.js
new file mode 100644
index 0000000000..9a6a904465
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Version.js
@@ -0,0 +1,190 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which exactly matches the blocklist entry is
+// successfully blocked.
+// Uses test_gfxBlacklist_AllOS.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Save OS in variable since createAppInfo below will change it to "xpcshell".
+ const OS = Services.appinfo.OS;
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ break;
+ case "Darwin":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofOSVersion(0xa0900);
+ break;
+ case "Android":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("5");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "15.0", "8");
+ await promiseStartupManager();
+
+ function checkBlocklist() {
+ var failureId = {};
+ var status;
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_DIRECT2D,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+ Assert.equal(failureId.value, "FEATURE_FAILURE_DL_BLOCKLIST_g1");
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+ Assert.equal(failureId.value, "FEATURE_FAILURE_DL_BLOCKLIST_g2");
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_DIRECT3D_10_LAYERS,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ Assert.equal(failureId.value, "");
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_DIRECT3D_10_1_LAYERS,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ Assert.equal(failureId.value, "");
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_OPENGL_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_WEBGL_OPENGL,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+ Assert.equal(failureId.value, "FEATURE_FAILURE_DL_BLOCKLIST_g11");
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_WEBGL_ANGLE,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+ Assert.equal(failureId.value, "FEATURE_FAILURE_DL_BLOCKLIST_NO_ID");
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_WEBGL2, failureId);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+ Assert.equal(failureId.value, "FEATURE_FAILURE_DL_BLOCKLIST_NO_ID");
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_STAGEFRIGHT,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_WEBRTC_HW_ACCELERATION_H264,
+ failureId
+ );
+ if (OS == "Android" && status != Ci.nsIGfxInfo.FEATURE_STATUS_OK) {
+ // Hardware acceleration for H.264 varies by device.
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DEVICE);
+ Assert.equal(failureId.value, "FEATURE_FAILURE_WEBRTC_H264");
+ } else {
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ }
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_WEBRTC_HW_ACCELERATION_ENCODE,
+ failureId
+ );
+ if (OS == "Android" && status != Ci.nsIGfxInfo.FEATURE_STATUS_OK) {
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DEVICE);
+ Assert.equal(failureId.value, "FEATURE_FAILURE_WEBRTC_ENCODE");
+ } else {
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ }
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_WEBRTC_HW_ACCELERATION_DECODE,
+ failureId
+ );
+ if (OS == "Android" && status != Ci.nsIGfxInfo.FEATURE_STATUS_OK) {
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DEVICE);
+ Assert.equal(failureId.value, "FEATURE_FAILURE_WEBRTC_DECODE");
+ } else {
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ }
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_DIRECT3D_11_LAYERS,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_HARDWARE_VIDEO_DECODING,
+ failureId
+ );
+ if (OS == "Linux" && status != Ci.nsIGfxInfo.FEATURE_STATUS_OK) {
+ // Linux test suite is running on SW OpenGL backend and we disable
+ // HW video decoding there.
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_PLATFORM_TEST);
+ Assert.equal(
+ failureId.value,
+ "FEATURE_FAILURE_VIDEO_DECODING_TEST_FAILED"
+ );
+ } else {
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ }
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_DIRECT3D_11_ANGLE,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_DX_INTEROP2,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlocklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist_AllOS.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_prefs.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_prefs.js
new file mode 100644
index 0000000000..34e92b0e80
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_prefs.js
@@ -0,0 +1,124 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether the blacklist successfully adds and removes the prefs that store
+// its decisions when the remote blacklist is changed.
+// Uses test_gfxBlacklist.json and test_gfxBlacklist2.json
+
+// Performs the initial setup
+async function run_test() {
+ try {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+ } catch (e) {
+ do_test_finished();
+ return;
+ }
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ break;
+ case "Darwin":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofOSVersion(0xa0900);
+ break;
+ case "Android":
+ gfxInfo.spoofVendorID("abcd");
+ gfxInfo.spoofDeviceID("asdf");
+ gfxInfo.spoofDriverVersion("5");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function blacklistAdded(aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(ensureBlacklistSet);
+ }
+ function ensureBlacklistSet() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ Assert.equal(
+ Services.prefs.getIntPref("gfx.blacklist.direct2d"),
+ Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION
+ );
+
+ Services.obs.removeObserver(blacklistAdded, "blocklist-data-gfxItems");
+ Services.obs.addObserver(blacklistRemoved, "blocklist-data-gfxItems");
+ mockGfxBlocklistItems([
+ {
+ os: "WINNT 6.1",
+ vendor: "0xabcd",
+ devices: ["0x2783", "0x2782"],
+ feature: " DIRECT2D ",
+ featureStatus: " BLOCKED_DRIVER_VERSION ",
+ driverVersion: " 8.52.322.2202 ",
+ driverVersionComparator: " LESS_THAN ",
+ },
+ {
+ os: "WINNT 6.0",
+ vendor: "0xdcba",
+ devices: ["0x2783", "0x1234", "0x2782"],
+ feature: " DIRECT3D_9_LAYERS ",
+ featureStatus: " BLOCKED_DRIVER_VERSION ",
+ driverVersion: " 8.52.322.2202 ",
+ driverVersionComparator: " LESS_THAN ",
+ },
+ ]);
+ }
+
+ function blacklistRemoved(aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(ensureBlacklistUnset);
+ }
+ function ensureBlacklistUnset() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ var exists = false;
+ try {
+ Services.prefs.getIntPref("gfx.blacklist.direct2d");
+ exists = true;
+ } catch (e) {}
+
+ Assert.ok(!exists);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(blacklistAdded, "blocklist-data-gfxItems");
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_softblocked.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_softblocked.js
new file mode 100644
index 0000000000..edf53183d0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_softblocked.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// useMLBF=true only supports hard blocks, not soft blocks.
+enable_blocklist_v2_instead_of_useMLBF();
+
+// Tests that an appDisabled add-on that becomes softBlocked remains disabled
+// when becoming appEnabled
+add_task(async function test_softblock() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ await promiseStartupManager();
+
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Softblocked add-on",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "softblock1@tests.mozilla.org",
+ strict_min_version: "2",
+ strict_max_version: "3",
+ },
+ },
+ },
+ });
+ let s1 = await promiseAddonByID("softblock1@tests.mozilla.org");
+
+ // Make sure to mark it as previously enabled.
+ await s1.enable();
+
+ Assert.ok(!s1.softDisabled);
+ Assert.ok(s1.appDisabled);
+ Assert.ok(!s1.isActive);
+
+ await AddonTestUtils.loadBlocklistRawData({
+ extensions: [
+ {
+ guid: "softblock1@tests.mozilla.org",
+ versionRange: [
+ {
+ severity: "1",
+ },
+ ],
+ },
+ ],
+ });
+
+ Assert.ok(s1.softDisabled);
+ Assert.ok(s1.appDisabled);
+ Assert.ok(!s1.isActive);
+
+ AddonTestUtils.appInfo.platformVersion = "2";
+ await promiseRestartManager("2");
+
+ s1 = await promiseAddonByID("softblock1@tests.mozilla.org");
+
+ Assert.ok(s1.softDisabled);
+ Assert.ok(!s1.appDisabled);
+ Assert.ok(!s1.isActive);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/xpcshell.ini b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/xpcshell.ini
new file mode 100644
index 0000000000..f13a7645c2
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/xpcshell.ini
@@ -0,0 +1,67 @@
+[DEFAULT]
+tags = addons blocklist
+head = head.js ../head_addons.js
+firefox-appdir = browser
+support-files =
+ ../data/**
+ ../../xpinstall/webmidi_permission.xpi
+
+[test_android_blocklist_dump.js]
+run-if = os == "android"
+[test_blocklist_addonBlockURL.js]
+[test_blocklist_appversion.js]
+skip-if = os == "android" && verify # times out
+[test_blocklist_clients.js]
+tags = remote-settings
+[test_blocklist_gfx.js]
+[test_blocklist_metadata_filters.js]
+[test_blocklist_mlbf.js]
+[test_blocklist_mlbf_dump.js]
+skip-if = os == "android" # blocklist v3 is not bundled with Android builds, see test_android_blocklist_dump.js instead.
+[test_blocklist_mlbf_fetch.js]
+[test_blocklist_mlbf_stashes.js]
+[test_blocklist_mlbf_telemetry.js]
+skip-if =
+ appname == "thunderbird" # Data irrelevant to Thunderbird. Bug 1641400.
+[test_blocklist_mlbf_update.js]
+[test_blocklist_osabi.js]
+skip-if = os == "android" && verify # times out
+[test_blocklist_prefs.js]
+[test_blocklist_regexp_split.js]
+[test_blocklist_severities.js]
+[test_blocklist_statechange_telemetry.js]
+skip-if =
+ appname == "thunderbird" # Data irrelevant to Thunderbird. Bug 1641400.
+[test_blocklist_targetapp_filter.js]
+tags = remote-settings
+[test_blocklist_telemetry.js]
+tags = remote-settings
+skip-if =
+ appname == "thunderbird" # Data irrelevant to Thunderbird. Bug 1641400.
+[test_blocklistchange.js]
+# Times out during parallel runs on desktop
+requesttimeoutfactor = 2
+skip-if = os == "android" && verify # times out because it takes too much time to run the full test
+[test_blocklistchange_v2.js]
+# Times out during parallel runs on desktop
+requesttimeoutfactor = 2
+skip-if = os == "android" && verify # times out in chaos mode on Android because several minutes are spent waiting at https://hg.mozilla.org/mozilla-central/file/3350b680/toolkit/mozapps/extensions/Blocklist.jsm#l698
+[test_gfxBlacklist_Device.js]
+[test_gfxBlacklist_DriverNew.js]
+[test_gfxBlacklist_Equal_DriverNew.js]
+[test_gfxBlacklist_Equal_DriverOld.js]
+[test_gfxBlacklist_Equal_OK.js]
+[test_gfxBlacklist_GTE_DriverOld.js]
+[test_gfxBlacklist_GTE_OK.js]
+[test_gfxBlacklist_No_Comparison.js]
+[test_gfxBlacklist_OK.js]
+[test_gfxBlacklist_OS.js]
+[test_gfxBlacklist_OSVersion_match.js]
+[test_gfxBlacklist_OSVersion_mismatch_DriverVersion.js]
+[test_gfxBlacklist_OSVersion_mismatch_OSVersion.js]
+[test_gfxBlacklist_Vendor.js]
+[test_gfxBlacklist_Version.js]
+[test_gfxBlacklist_prefs.js]
+# Bug 1248787 - consistently fails
+skip-if = true
+[test_softblocked.js]
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_AbuseReporter.js b/toolkit/mozapps/extensions/test/xpcshell/test_AbuseReporter.js
new file mode 100644
index 0000000000..23420bd911
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AbuseReporter.js
@@ -0,0 +1,904 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const { AbuseReporter, AbuseReportError } = ChromeUtils.importESModule(
+ "resource://gre/modules/AbuseReporter.sys.mjs"
+);
+
+const { ClientID } = ChromeUtils.importESModule(
+ "resource://gre/modules/ClientID.sys.mjs"
+);
+const { TelemetryController } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryController.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const APPNAME = "XPCShell";
+const APPVERSION = "1";
+const ADDON_ID = "test-addon@tests.mozilla.org";
+const ADDON_ID2 = "test-addon2@tests.mozilla.org";
+const FAKE_INSTALL_INFO = {
+ source: "fake-Install:Source",
+ method: "fake:install method",
+};
+const PREF_REQUIRED_LOCALE = "intl.locale.requested";
+const REPORT_OPTIONS = { reportEntryPoint: "menu" };
+const TELEMETRY_EVENTS_FILTERS = {
+ category: "addonsManager",
+ method: "report",
+};
+
+const FAKE_AMO_DETAILS = {
+ name: {
+ "en-US": "fake name",
+ "it-IT": "fake it-IT name",
+ },
+ current_version: { version: "1.0" },
+ type: "extension",
+ is_recommended: true,
+};
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
+
+const server = createHttpServer({ hosts: ["test.addons.org"] });
+
+// Mock abuse report API endpoint.
+let apiRequestHandler;
+server.registerPathHandler("/api/report/", (request, response) => {
+ const stream = request.bodyInputStream;
+ const buffer = NetUtil.readInputStream(stream, stream.available());
+ const data = new TextDecoder().decode(buffer);
+ apiRequestHandler({ data, request, response });
+});
+
+// Mock addon details API endpoint.
+const amoAddonDetailsMap = new Map();
+server.registerPrefixHandler("/api/addons/addon/", (request, response) => {
+ const addonId = request.path.split("/").pop();
+ if (!amoAddonDetailsMap.has(addonId)) {
+ response.setStatusLine(request.httpVersion, 404, "Not Found");
+ response.write(JSON.stringify({ detail: "Not found." }));
+ } else {
+ response.setStatusLine(request.httpVersion, 200, "Success");
+ response.write(JSON.stringify(amoAddonDetailsMap.get(addonId)));
+ }
+});
+
+function getProperties(obj, propNames) {
+ return propNames.reduce((acc, el) => {
+ acc[el] = obj[el];
+ return acc;
+ }, {});
+}
+
+function handleSubmitRequest({ request, response }) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/json", false);
+ response.write("{}");
+}
+
+function clearAbuseReportState() {
+ // Clear the timestamp of the last submission.
+ AbuseReporter._lastReportTimestamp = null;
+}
+
+async function installTestExtension(overrideOptions = {}) {
+ const extOptions = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: ADDON_ID } },
+ name: "Test Extension",
+ },
+ useAddonManager: "permanent",
+ amInstallTelemetryInfo: FAKE_INSTALL_INFO,
+ ...overrideOptions,
+ };
+
+ const extension = ExtensionTestUtils.loadExtension(extOptions);
+ await extension.startup();
+
+ const addon = await AddonManager.getAddonByID(ADDON_ID);
+
+ return { extension, addon };
+}
+
+async function assertRejectsAbuseReportError(promise, errorType, errorInfo) {
+ let error;
+
+ await Assert.rejects(
+ promise,
+ err => {
+ // Log the actual error to make investigating test failures easier.
+ Cu.reportError(err);
+ error = err;
+ return err instanceof AbuseReportError;
+ },
+ `Got an AbuseReportError`
+ );
+
+ equal(error.errorType, errorType, "Got the expected errorType");
+ equal(error.errorInfo, errorInfo, "Got the expected errorInfo");
+ ok(
+ error.message.includes(errorType),
+ "errorType should be included in the error message"
+ );
+ if (errorInfo) {
+ ok(
+ error.message.includes(errorInfo),
+ "errorInfo should be included in the error message"
+ );
+ }
+}
+
+async function assertBaseReportData({ reportData, addon }) {
+ // Report properties related to addon metadata.
+ equal(reportData.addon, ADDON_ID, "Got expected 'addon'");
+ equal(
+ reportData.addon_version,
+ addon.version,
+ "Got expected 'addon_version'"
+ );
+ equal(
+ reportData.install_date,
+ addon.installDate.toISOString(),
+ "Got expected 'install_date' in ISO format"
+ );
+ equal(
+ reportData.addon_install_origin,
+ addon.sourceURI.spec,
+ "Got expected 'addon_install_origin'"
+ );
+ equal(
+ reportData.addon_install_source,
+ "fake_install_source",
+ "Got expected 'addon_install_source'"
+ );
+ equal(
+ reportData.addon_install_method,
+ "fake_install_method",
+ "Got expected 'addon_install_method'"
+ );
+ equal(
+ reportData.addon_signature,
+ "privileged",
+ "Got expected 'addon_signature'"
+ );
+
+ // Report properties related to the environment.
+ equal(
+ reportData.client_id,
+ await ClientID.getClientIdHash(),
+ "Got the expected 'client_id'"
+ );
+ equal(reportData.app, APPNAME.toLowerCase(), "Got expected 'app'");
+ equal(reportData.appversion, APPVERSION, "Got expected 'appversion'");
+ equal(
+ reportData.lang,
+ Services.locale.appLocaleAsBCP47,
+ "Got expected 'lang'"
+ );
+ equal(
+ reportData.operating_system,
+ AppConstants.platform,
+ "Got expected 'operating_system'"
+ );
+ equal(
+ reportData.operating_system_version,
+ Services.sysinfo.getProperty("version"),
+ "Got expected 'operating_system_version'"
+ );
+}
+
+add_task(async function test_setup() {
+ Services.prefs.setCharPref(
+ "extensions.abuseReport.url",
+ "http://test.addons.org/api/report/"
+ );
+
+ Services.prefs.setCharPref(
+ "extensions.abuseReport.amoDetailsURL",
+ "http://test.addons.org/api/addons/addon"
+ );
+
+ 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;
+ });
+
+ // Register a fake it-IT locale (used to test localized AMO details in some
+ // of the test case defined in this test file).
+ L10nRegistry.getInstance().registerSources([
+ L10nFileSource.createMock(
+ "mock",
+ "app",
+ ["it-IT", "fr-FR"],
+ "resource://fake/locales/{locale}",
+ []
+ ),
+ ]);
+});
+
+add_task(async function test_addon_report_data() {
+ info("Verify report property for a privileged extension");
+ const { addon, extension } = await installTestExtension();
+ const data = await AbuseReporter.getReportData(addon);
+ await assertBaseReportData({ reportData: data, addon });
+ await extension.unload();
+
+ info("Verify 'addon_signature' report property for non privileged extension");
+ AddonTestUtils.usePrivilegedSignatures = false;
+ const { addon: addon2, extension: extension2 } = await installTestExtension();
+ const data2 = await AbuseReporter.getReportData(addon2);
+ equal(
+ data2.addon_signature,
+ "signed",
+ "Got expected 'addon_signature' for non privileged extension"
+ );
+ await extension2.unload();
+
+ info("Verify 'addon_install_method' report property on temporary install");
+ const { addon: addon3, extension: extension3 } = await installTestExtension({
+ useAddonManager: "temporary",
+ });
+ const data3 = await AbuseReporter.getReportData(addon3);
+ equal(
+ data3.addon_install_source,
+ "temporary_addon",
+ "Got expected 'addon_install_method' on temporary install"
+ );
+ await extension3.unload();
+});
+
+add_task(async function test_report_on_not_installed_addon() {
+ Services.telemetry.clearEvents();
+
+ // Make sure that the AMO addons details API endpoint is going to
+ // return a 404 status for the not installed addon.
+ amoAddonDetailsMap.delete(ADDON_ID);
+
+ await assertRejectsAbuseReportError(
+ AbuseReporter.createAbuseReport(ADDON_ID, REPORT_OPTIONS),
+ "ERROR_ADDON_NOTFOUND"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: REPORT_OPTIONS.reportEntryPoint,
+ value: ADDON_ID,
+ extra: { error_type: "ERROR_AMODETAILS_NOTFOUND" },
+ },
+ {
+ object: REPORT_OPTIONS.reportEntryPoint,
+ value: ADDON_ID,
+ extra: { error_type: "ERROR_ADDON_NOTFOUND" },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+
+ Services.telemetry.clearEvents();
+});
+
+// This tests verifies how the addon installTelemetryInfo values are being
+// normalized into the addon_install_source and addon_install_method
+// expected by the API endpoint.
+add_task(async function test_normalized_addon_install_source_and_method() {
+ async function assertAddonInstallMethod(amInstallTelemetryInfo, expected) {
+ const { addon, extension } = await installTestExtension({
+ amInstallTelemetryInfo,
+ });
+ const {
+ addon_install_method,
+ addon_install_source,
+ addon_install_source_url,
+ } = await AbuseReporter.getReportData(addon);
+
+ Assert.deepEqual(
+ {
+ addon_install_method,
+ addon_install_source,
+ addon_install_source_url,
+ },
+ {
+ addon_install_method: expected.method,
+ addon_install_source: expected.source,
+ addon_install_source_url: expected.sourceURL,
+ },
+ `Got the expected report data for ${JSON.stringify(
+ amInstallTelemetryInfo
+ )}`
+ );
+ await extension.unload();
+ }
+
+ // Array of testcases: the `test` property contains the installTelemetryInfo value
+ // and the `expect` contains the expected normalized values.
+ const TEST_CASES = [
+ // Explicitly verify normalized values on missing telemetry info.
+ {
+ test: null,
+ expect: { source: null, method: null },
+ },
+
+ // Verify expected normalized values for some common install telemetry info.
+ {
+ test: { source: "about:addons", method: "drag-and-drop" },
+ expect: { source: "about_addons", method: "drag_and_drop" },
+ },
+ {
+ test: { source: "amo", method: "amWebAPI" },
+ expect: { source: "amo", method: "amwebapi" },
+ },
+ {
+ test: { source: "app-profile", method: "sideload" },
+ expect: { source: "app_profile", method: "sideload" },
+ },
+ {
+ test: { source: "distribution" },
+ expect: { source: "distribution", method: null },
+ },
+ {
+ test: {
+ method: "installTrigger",
+ source: "test-host",
+ sourceURL: "http://host.triggered.install/example?test=1",
+ },
+ expect: {
+ method: "installtrigger",
+ source: "test_host",
+ sourceURL: "http://host.triggered.install/example?test=1",
+ },
+ },
+ {
+ test: {
+ method: "link",
+ source: "unknown",
+ sourceURL: "https://another.host/installExtension?name=ext1",
+ },
+ expect: {
+ method: "link",
+ source: "unknown",
+ sourceURL: "https://another.host/installExtension?name=ext1",
+ },
+ },
+ ];
+
+ for (const { expect, test } of TEST_CASES) {
+ await assertAddonInstallMethod(test, expect);
+ }
+});
+
+add_task(async function test_report_create_and_submit() {
+ Services.telemetry.clearEvents();
+
+ // Override the test api server request handler, to be able to
+ // intercept the submittions to the test api server.
+ let reportSubmitted;
+ apiRequestHandler = ({ data, request, response }) => {
+ reportSubmitted = JSON.parse(data);
+ handleSubmitRequest({ request, response });
+ };
+
+ const { addon, extension } = await installTestExtension();
+
+ const reportEntryPoint = "menu";
+ const report = await AbuseReporter.createAbuseReport(ADDON_ID, {
+ reportEntryPoint,
+ });
+
+ equal(report.addon, addon, "Got the expected addon property");
+ equal(
+ report.reportEntryPoint,
+ reportEntryPoint,
+ "Got the expected reportEntryPoint"
+ );
+
+ const baseReportData = await AbuseReporter.getReportData(addon);
+ const reportProperties = {
+ message: "test message",
+ reason: "test-reason",
+ };
+
+ info("Submitting report");
+ report.setMessage(reportProperties.message);
+ report.setReason(reportProperties.reason);
+ await report.submit();
+
+ const expectedEntries = Object.entries({
+ report_entry_point: reportEntryPoint,
+ ...baseReportData,
+ ...reportProperties,
+ });
+
+ for (const [expectedKey, expectedValue] of expectedEntries) {
+ equal(
+ reportSubmitted[expectedKey],
+ expectedValue,
+ `Got the expected submitted value for "${expectedKey}"`
+ );
+ }
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: reportEntryPoint,
+ value: ADDON_ID,
+ extra: { addon_type: "extension" },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_error_recent_submit() {
+ Services.telemetry.clearEvents();
+ clearAbuseReportState();
+
+ let reportSubmitted;
+ apiRequestHandler = ({ data, request, response }) => {
+ reportSubmitted = JSON.parse(data);
+ handleSubmitRequest({ request, response });
+ };
+
+ const { extension } = await installTestExtension();
+ const report = await AbuseReporter.createAbuseReport(ADDON_ID, {
+ reportEntryPoint: "uninstall",
+ });
+
+ const { extension: extension2 } = await installTestExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ADDON_ID2 } },
+ name: "Test Extension2",
+ },
+ });
+ const report2 = await AbuseReporter.createAbuseReport(
+ ADDON_ID2,
+ REPORT_OPTIONS
+ );
+
+ // Submit the two reports in fast sequence.
+ report.setReason("reason1");
+ report2.setReason("reason2");
+ await report.submit();
+ await assertRejectsAbuseReportError(report2.submit(), "ERROR_RECENT_SUBMIT");
+ equal(
+ reportSubmitted.reason,
+ "reason1",
+ "Server only received the data from the first submission"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "uninstall",
+ value: ADDON_ID,
+ extra: { addon_type: "extension" },
+ },
+ {
+ object: REPORT_OPTIONS.reportEntryPoint,
+ value: ADDON_ID2,
+ extra: {
+ addon_type: "extension",
+ error_type: "ERROR_RECENT_SUBMIT",
+ },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+
+ await extension.unload();
+ await extension2.unload();
+});
+
+add_task(async function test_submission_server_error() {
+ const { extension } = await installTestExtension();
+
+ async function testErrorCode({
+ responseStatus,
+ responseText = "",
+ expectedErrorType,
+ expectedErrorInfo,
+ expectRequest = true,
+ }) {
+ info(
+ `Test expected AbuseReportError on response status "${responseStatus}"`
+ );
+ Services.telemetry.clearEvents();
+ clearAbuseReportState();
+
+ let requestReceived = false;
+ apiRequestHandler = ({ request, response }) => {
+ requestReceived = true;
+ response.setStatusLine(request.httpVersion, responseStatus, "Error");
+ response.write(responseText);
+ };
+
+ const report = await AbuseReporter.createAbuseReport(
+ ADDON_ID,
+ REPORT_OPTIONS
+ );
+ report.setReason("a-reason");
+ const promiseSubmit = report.submit();
+ if (typeof expectedErrorType === "string") {
+ // Assert a specific AbuseReportError errorType.
+ await assertRejectsAbuseReportError(
+ promiseSubmit,
+ expectedErrorType,
+ expectedErrorInfo
+ );
+ } else {
+ // Assert on a given Error class.
+ await Assert.rejects(promiseSubmit, expectedErrorType);
+ }
+ equal(
+ requestReceived,
+ expectRequest,
+ `${expectRequest ? "" : "Not "}received a request as expected`
+ );
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: REPORT_OPTIONS.reportEntryPoint,
+ value: ADDON_ID,
+ extra: {
+ addon_type: "extension",
+ error_type:
+ typeof expectedErrorType === "string"
+ ? expectedErrorType
+ : "ERROR_UNKNOWN",
+ },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+ }
+
+ await testErrorCode({
+ responseStatus: 500,
+ responseText: "A server error",
+ expectedErrorType: "ERROR_SERVER",
+ expectedErrorInfo: JSON.stringify({
+ status: 500,
+ responseText: "A server error",
+ }),
+ });
+ await testErrorCode({
+ responseStatus: 404,
+ responseText: "Not found error",
+ expectedErrorType: "ERROR_CLIENT",
+ expectedErrorInfo: JSON.stringify({
+ status: 404,
+ responseText: "Not found error",
+ }),
+ });
+ // Test response with unexpected status code.
+ await testErrorCode({
+ responseStatus: 604,
+ responseText: "An unexpected status code",
+ expectedErrorType: "ERROR_UNKNOWN",
+ expectedErrorInfo: JSON.stringify({
+ status: 604,
+ responseText: "An unexpected status code",
+ }),
+ });
+ // Test response status 200 with invalid json data.
+ await testErrorCode({
+ responseStatus: 200,
+ expectedErrorType: /SyntaxError: JSON.parse/,
+ });
+
+ // Test on invalid url.
+ Services.prefs.setCharPref(
+ "extensions.abuseReport.url",
+ "invalid-protocol://abuse-report"
+ );
+ await testErrorCode({
+ expectedErrorType: "ERROR_NETWORK",
+ expectRequest: false,
+ });
+
+ await extension.unload();
+});
+
+add_task(async function set_test_abusereport_url() {
+ Services.prefs.setCharPref(
+ "extensions.abuseReport.url",
+ "http://test.addons.org/api/report/"
+ );
+});
+
+add_task(async function test_submission_aborting() {
+ Services.telemetry.clearEvents();
+ clearAbuseReportState();
+
+ const { extension } = await installTestExtension();
+
+ // override the api request handler with one that is never going to reply.
+ let receivedRequestsCount = 0;
+ let resolvePendingResponses;
+ const waitToReply = new Promise(
+ resolve => (resolvePendingResponses = resolve)
+ );
+
+ const onRequestReceived = new Promise(resolve => {
+ apiRequestHandler = ({ request, response }) => {
+ response.processAsync();
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ receivedRequestsCount++;
+ resolve();
+
+ // Keep the request pending until resolvePendingResponses have been
+ // called.
+ waitToReply.then(() => {
+ response.finish();
+ });
+ };
+ });
+
+ const report = await AbuseReporter.createAbuseReport(
+ ADDON_ID,
+ REPORT_OPTIONS
+ );
+ report.setReason("a-reason");
+ const promiseResult = report.submit();
+
+ await onRequestReceived;
+
+ ok(receivedRequestsCount > 0, "Got the expected number of requests");
+ ok(
+ (await Promise.race([promiseResult, Promise.resolve("pending")])) ===
+ "pending",
+ "Submission fetch request should still be pending"
+ );
+
+ report.abort();
+
+ await assertRejectsAbuseReportError(promiseResult, "ERROR_ABORTED_SUBMIT");
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: REPORT_OPTIONS.reportEntryPoint,
+ value: ADDON_ID,
+ extra: { addon_type: "extension", error_type: "ERROR_ABORTED_SUBMIT" },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+
+ await extension.unload();
+
+ // Unblock pending requests on the server request handler side, so that the
+ // test file can shutdown (otherwise the test run will be stuck after this
+ // task completed).
+ resolvePendingResponses();
+});
+
+add_task(async function test_truncated_string_properties() {
+ const generateString = len => new Array(len).fill("a").join("");
+
+ const LONG_STRINGS_ADDON_ID = "addon-with-long-strings-props@mochi.test";
+ const { extension } = await installTestExtension({
+ manifest: {
+ name: generateString(400),
+ description: generateString(400),
+ browser_specific_settings: { gecko: { id: LONG_STRINGS_ADDON_ID } },
+ },
+ });
+
+ // Override the test api server request handler, to be able to
+ // intercept the properties actually submitted.
+ let reportSubmitted;
+ apiRequestHandler = ({ data, request, response }) => {
+ reportSubmitted = JSON.parse(data);
+ handleSubmitRequest({ request, response });
+ };
+
+ const report = await AbuseReporter.createAbuseReport(
+ LONG_STRINGS_ADDON_ID,
+ REPORT_OPTIONS
+ );
+
+ report.setMessage("fake-message");
+ report.setReason("fake-reason");
+ await report.submit();
+
+ const expected = {
+ addon_name: generateString(255),
+ addon_summary: generateString(255),
+ };
+
+ Assert.deepEqual(
+ {
+ addon_name: reportSubmitted.addon_name,
+ addon_summary: reportSubmitted.addon_summary,
+ },
+ expected,
+ "Got the long strings truncated as expected"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_report_recommended() {
+ const NON_RECOMMENDED_ADDON_ID = "non-recommended-addon@mochi.test";
+ const RECOMMENDED_ADDON_ID = "recommended-addon@mochi.test";
+
+ const now = Date.now();
+ const not_before = new Date(now - 3600000).toISOString();
+ const not_after = new Date(now + 3600000).toISOString();
+
+ const { extension: nonRecommended } = await installTestExtension({
+ manifest: {
+ name: "Fake non recommended addon",
+ browser_specific_settings: { gecko: { id: NON_RECOMMENDED_ADDON_ID } },
+ },
+ });
+
+ const { extension: recommended } = await installTestExtension({
+ manifest: {
+ name: "Fake recommended addon",
+ browser_specific_settings: { gecko: { id: RECOMMENDED_ADDON_ID } },
+ },
+ files: {
+ "mozilla-recommendation.json": {
+ addon_id: RECOMMENDED_ADDON_ID,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ },
+ },
+ });
+
+ // Override the test api server request handler, to be able to
+ // intercept the properties actually submitted.
+ let reportSubmitted;
+ apiRequestHandler = ({ data, request, response }) => {
+ reportSubmitted = JSON.parse(data);
+ handleSubmitRequest({ request, response });
+ };
+
+ async function checkReportedSignature(addonId, expectedAddonSignature) {
+ clearAbuseReportState();
+ const report = await AbuseReporter.createAbuseReport(
+ addonId,
+ REPORT_OPTIONS
+ );
+ report.setMessage("fake-message");
+ report.setReason("fake-reason");
+ await report.submit();
+ equal(
+ reportSubmitted.addon_signature,
+ expectedAddonSignature,
+ `Got the expected addon_signature for ${addonId}`
+ );
+ }
+
+ await checkReportedSignature(NON_RECOMMENDED_ADDON_ID, "signed");
+ await checkReportedSignature(RECOMMENDED_ADDON_ID, "curated");
+
+ await nonRecommended.unload();
+ await recommended.unload();
+});
+
+add_task(async function test_query_amo_details() {
+ async function assertReportOnAMODetails({
+ addonId,
+ addonType = "extension",
+ expectedReport,
+ } = {}) {
+ // Clear last report timestamp and any telemetry event recorded so far.
+ clearAbuseReportState();
+ Services.telemetry.clearEvents();
+
+ const report = await AbuseReporter.createAbuseReport(addonId, {
+ reportEntryPoint: "menu",
+ });
+
+ let reportSubmitted;
+ apiRequestHandler = ({ data, request, response }) => {
+ reportSubmitted = JSON.parse(data);
+ handleSubmitRequest({ request, response });
+ };
+
+ report.setMessage("fake message");
+ report.setReason("reason1");
+ await report.submit();
+
+ Assert.deepEqual(
+ expectedReport,
+ getProperties(reportSubmitted, Object.keys(expectedReport)),
+ "Got the expected report properties"
+ );
+
+ // Telemetry recorded for the successfully submitted report.
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "menu",
+ value: addonId,
+ extra: { addon_type: FAKE_AMO_DETAILS.type },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+
+ clearAbuseReportState();
+ }
+
+ // Add the expected AMO addons details.
+ const addonId = "not-installed-addon@mochi.test";
+ amoAddonDetailsMap.set(addonId, FAKE_AMO_DETAILS);
+
+ // Test on the default en-US locale.
+ Services.prefs.setCharPref(PREF_REQUIRED_LOCALE, "en-US");
+ let locale = Services.locale.appLocaleAsBCP47;
+ equal(locale, "en-US", "Got the expected app locale set");
+
+ let expectedReport = {
+ addon: addonId,
+ addon_name: FAKE_AMO_DETAILS.name[locale],
+ addon_version: FAKE_AMO_DETAILS.current_version.version,
+ addon_install_source: "not_installed",
+ addon_install_method: null,
+ addon_signature: "curated",
+ };
+
+ await assertReportOnAMODetails({ addonId, expectedReport });
+
+ // Test with a non-default locale also available in the AMO details.
+ Services.prefs.setCharPref(PREF_REQUIRED_LOCALE, "it-IT");
+ locale = Services.locale.appLocaleAsBCP47;
+ equal(locale, "it-IT", "Got the expected app locale set");
+
+ expectedReport = {
+ ...expectedReport,
+ addon_name: FAKE_AMO_DETAILS.name[locale],
+ };
+ await assertReportOnAMODetails({ addonId, expectedReport });
+
+ // Test with a non-default locale not available in the AMO details.
+ Services.prefs.setCharPref(PREF_REQUIRED_LOCALE, "fr-FR");
+ locale = Services.locale.appLocaleAsBCP47;
+ equal(locale, "fr-FR", "Got the expected app locale set");
+
+ expectedReport = {
+ ...expectedReport,
+ // Fallbacks on en-US for non available locales.
+ addon_name: FAKE_AMO_DETAILS.name["en-US"],
+ };
+ await assertReportOnAMODetails({ addonId, expectedReport });
+
+ Services.prefs.clearUserPref(PREF_REQUIRED_LOCALE);
+
+ amoAddonDetailsMap.clear();
+});
+
+add_task(async function test_statictheme_normalized_into_type_theme() {
+ const themeId = "not-installed-statictheme@mochi.test";
+ amoAddonDetailsMap.set(themeId, {
+ ...FAKE_AMO_DETAILS,
+ type: "statictheme",
+ });
+
+ const report = await AbuseReporter.createAbuseReport(themeId, REPORT_OPTIONS);
+
+ equal(report.addon.id, themeId, "Got a report for the expected theme id");
+ equal(report.addon.type, "theme", "Got the expected addon type");
+
+ amoAddonDetailsMap.clear();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository.js b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository.js
new file mode 100644
index 0000000000..2e1e37aa3d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository.js
@@ -0,0 +1,316 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests AddonRepository.jsm
+
+var gServer = createHttpServer({ hosts: ["example.com"] });
+
+const PREF_GETADDONS_BROWSEADDONS = "extensions.getAddons.browseAddons";
+const PREF_GETADDONS_BROWSESEARCHRESULTS =
+ "extensions.getAddons.search.browseURL";
+
+const BASE_URL = "http://example.com";
+const DEFAULT_URL = "about:blank";
+
+const ADDONS = [
+ {
+ manifest: {
+ name: "XPI Add-on 1",
+ version: "1.1",
+ browser_specific_settings: {
+ gecko: { id: "test_AddonRepository_1@tests.mozilla.org" },
+ },
+ },
+ },
+ {
+ manifest: {
+ name: "XPI Add-on 2",
+ version: "1.2",
+ theme: {},
+ browser_specific_settings: {
+ gecko: { id: "test_AddonRepository_2@tests.mozilla.org" },
+ },
+ },
+ },
+ {
+ manifest: {
+ name: "XPI Add-on 3",
+ version: "1.3",
+ theme: {},
+ browser_specific_settings: {
+ gecko: { id: "test_AddonRepository_3@tests.mozilla.org" },
+ },
+ },
+ },
+];
+
+// Path to source URI of installing add-on
+const INSTALL_URL2 = "/addons/test_AddonRepository_2.xpi";
+// Path to source URI of non-active add-on (state = STATE_AVAILABLE)
+const INSTALL_URL3 = "/addons/test_AddonRepository_3.xpi";
+
+// Properties of an individual add-on that should be checked
+// Note: name is checked separately
+var ADDON_PROPERTIES = [
+ "id",
+ "type",
+ "version",
+ "creator",
+ "developers",
+ "description",
+ "fullDescription",
+ "iconURL",
+ "icons",
+ "screenshots",
+ "supportURL",
+ "contributionURL",
+ "averageRating",
+ "reviewCount",
+ "reviewURL",
+ "weeklyDownloads",
+ "dailyUsers",
+ "sourceURI",
+ "updateDate",
+];
+
+// Results of getAddonsByIDs
+var GET_RESULTS = [
+ {
+ id: "test1@tests.mozilla.org",
+ type: "extension",
+ version: "1.1",
+ creator: {
+ name: "Test Creator 1",
+ url: BASE_URL + "/creator1.html",
+ },
+ developers: [
+ {
+ name: "Test Developer 1",
+ url: BASE_URL + "/developer1.html",
+ },
+ ],
+ description: "Test Summary 1",
+ fullDescription: "Test Description 1",
+ iconURL: BASE_URL + "/icon1.png",
+ icons: { 32: BASE_URL + "/icon1.png" },
+ screenshots: [
+ {
+ url: BASE_URL + "/full1-1.png",
+ width: 400,
+ height: 300,
+ thumbnailURL: BASE_URL + "/thumbnail1-1.png",
+ thumbnailWidth: 200,
+ thumbnailHeight: 150,
+ caption: "Caption 1 - 1",
+ },
+ {
+ url: BASE_URL + "/full2-1.png",
+ thumbnailURL: BASE_URL + "/thumbnail2-1.png",
+ caption: "Caption 2 - 1",
+ },
+ ],
+ supportURL: BASE_URL + "/support1.html",
+ contributionURL: BASE_URL + "/contribution1.html",
+ averageRating: 4,
+ reviewCount: 1111,
+ reviewURL: BASE_URL + "/review1.html",
+ weeklyDownloads: 3333,
+ sourceURI: BASE_URL + INSTALL_URL2,
+ updateDate: new Date(1265033045000),
+ },
+ {
+ id: "test2@tests.mozilla.org",
+ type: "extension",
+ version: "2.0",
+ icons: {},
+ sourceURI: "http://example.com/addons/bleah.xpi",
+ },
+ {
+ id: "test_AddonRepository_1@tests.mozilla.org",
+ type: "theme",
+ version: "1.4",
+ icons: {},
+ },
+];
+
+// Values for testing AddonRepository.getAddonsByIDs()
+var GET_TEST = {
+ preference: PREF_GETADDONS_BYIDS,
+ preferenceValue: BASE_URL + "/%OS%/%VERSION%/%IDS%",
+ failedIDs: ["test1@tests.mozilla.org"],
+ failedURL: "/XPCShell/1/test1%40tests.mozilla.org",
+ successfulIDs: [
+ "test1@tests.mozilla.org",
+ "test2@tests.mozilla.org",
+ "{00000000-1111-2222-3333-444444444444}",
+ "test_AddonRepository_1@tests.mozilla.org",
+ ],
+ successfulURL:
+ "/XPCShell/1/test1%40tests.mozilla.org%2C" +
+ "test2%40tests.mozilla.org%2C" +
+ "%7B00000000-1111-2222-3333-444444444444%7D%2C" +
+ "test_AddonRepository_1%40tests.mozilla.org",
+ successfulRTAURL:
+ "/XPCShell/1/rta%3AdGVzdDFAdGVzdHMubW96aWxsYS5vcmc%2C" +
+ "test2%40tests.mozilla.org%2C" +
+ "%7B00000000-1111-2222-3333-444444444444%7D%2C" +
+ "test_AddonRepository_1%40tests.mozilla.org",
+};
+
+// Test that actual results and expected results are equal
+function check_results(aActualAddons, aExpectedAddons) {
+ do_check_addons(aActualAddons, aExpectedAddons, ADDON_PROPERTIES);
+
+ // Additional tests
+ aActualAddons.forEach(function check_each_addon(aActualAddon) {
+ // Separately check name so better messages are output when test fails
+ if (aActualAddon.name == "FAIL") {
+ do_throw(aActualAddon.id + " - " + aActualAddon.description);
+ }
+ if (aActualAddon.name != "PASS") {
+ do_throw(aActualAddon.id + " - invalid add-on name " + aActualAddon.name);
+ }
+ });
+}
+
+add_task(async function setup() {
+ // Setup for test
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9");
+
+ let xpis = ADDONS.map(addon => createTempWebExtensionFile(addon));
+
+ // Register other add-on XPI files
+ gServer.registerFile(INSTALL_URL2, xpis[1]);
+ gServer.registerFile(INSTALL_URL3, xpis[2]);
+
+ // Register files used to test search failure
+ gServer.registerFile(
+ GET_TEST.failedURL,
+ do_get_file("data/test_AddonRepository_fail.json")
+ );
+
+ // Register files used to test search success
+ gServer.registerFile(
+ GET_TEST.successfulURL,
+ do_get_file("data/test_AddonRepository_getAddonsByIDs.json")
+ );
+ // Register file for RTA test
+ gServer.registerFile(
+ GET_TEST.successfulRTAURL,
+ do_get_file("data/test_AddonRepository_getAddonsByIDs.json")
+ );
+
+ await promiseStartupManager();
+
+ // Install an add-on so can check that it isn't returned in the results
+ await promiseInstallFile(xpis[0]);
+ await promiseRestartManager();
+
+ // Create an active AddonInstall so can check that it isn't returned in the results
+ let install = await AddonManager.getInstallForURL(BASE_URL + INSTALL_URL2);
+ let promise = promiseCompleteInstall(install);
+ registerCleanupFunction(() => promise);
+
+ // Create a non-active AddonInstall so can check that it is returned in the results
+ await AddonManager.getInstallForURL(BASE_URL + INSTALL_URL3);
+});
+
+// Tests homepageURL and getSearchURL()
+add_task(async function test_1() {
+ function check_urls(aPreference, aGetURL, aTests) {
+ aTests.forEach(function (aTest) {
+ Services.prefs.setCharPref(aPreference, aTest.preferenceValue);
+ Assert.equal(aGetURL(aTest), aTest.expectedURL);
+ });
+ }
+
+ var urlTests = [
+ {
+ preferenceValue: BASE_URL,
+ expectedURL: BASE_URL,
+ },
+ {
+ preferenceValue: BASE_URL + "/%OS%/%VERSION%",
+ expectedURL: BASE_URL + "/XPCShell/1",
+ },
+ ];
+
+ // Extra tests for AddonRepository.getSearchURL();
+ var searchURLTests = [
+ {
+ searchTerms: "test",
+ preferenceValue: BASE_URL + "/search?q=%TERMS%",
+ expectedURL: BASE_URL + "/search?q=test",
+ },
+ {
+ searchTerms: "test search",
+ preferenceValue: BASE_URL + "/%TERMS%",
+ expectedURL: BASE_URL + "/test%20search",
+ },
+ {
+ searchTerms: 'odd=search:with&weird"characters',
+ preferenceValue: BASE_URL + "/%TERMS%",
+ expectedURL: BASE_URL + "/odd%3Dsearch%3Awith%26weird%22characters",
+ },
+ ];
+
+ // Setup tests for homepageURL and getSearchURL()
+ var tests = [
+ {
+ initiallyUndefined: true,
+ preference: PREF_GETADDONS_BROWSEADDONS,
+ urlTests,
+ getURL: () => AddonRepository.homepageURL,
+ },
+ {
+ initiallyUndefined: false,
+ preference: PREF_GETADDONS_BROWSESEARCHRESULTS,
+ urlTests: urlTests.concat(searchURLTests),
+ getURL: function getSearchURL(aTest) {
+ var searchTerms =
+ aTest && aTest.searchTerms ? aTest.searchTerms : "unused terms";
+ return AddonRepository.getSearchURL(searchTerms);
+ },
+ },
+ ];
+
+ tests.forEach(function url_test(aTest) {
+ if (aTest.initiallyUndefined) {
+ // Preference is not defined by default
+ Assert.equal(
+ Services.prefs.getPrefType(aTest.preference),
+ Services.prefs.PREF_INVALID
+ );
+ Assert.equal(aTest.getURL(), DEFAULT_URL);
+ }
+
+ check_urls(aTest.preference, aTest.getURL, aTest.urlTests);
+ });
+});
+
+// Tests failure of AddonRepository.getAddonsByIDs()
+add_task(async function test_getAddonsByID_fails() {
+ Services.prefs.setCharPref(GET_TEST.preference, GET_TEST.preferenceValue);
+
+ await Assert.rejects(
+ AddonRepository.getAddonsByIDs(GET_TEST.failedIDs),
+ /Error: GET.*?failed/
+ );
+});
+
+// Tests success of AddonRepository.getAddonsByIDs()
+add_task(async function test_getAddonsByID_succeeds() {
+ let result = await AddonRepository.getAddonsByIDs(GET_TEST.successfulIDs);
+
+ check_results(result, GET_RESULTS);
+});
+
+// Tests success of AddonRepository.getAddonsByIDs() with rta ID.
+add_task(async function test_getAddonsByID_rta() {
+ let id = `rta:${btoa(GET_TEST.successfulIDs[0])}`.slice(0, -1);
+ GET_TEST.successfulIDs[0] = id;
+ let result = await AddonRepository.getAddonsByIDs(GET_TEST.successfulIDs);
+
+ check_results(result, GET_RESULTS);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache.js b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache.js
new file mode 100644
index 0000000000..5c4e873fef
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache.js
@@ -0,0 +1,714 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests caching in AddonRepository.jsm
+
+var gServer;
+
+const HOST = "example.com";
+const BASE_URL = "http://example.com";
+
+const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
+const PREF_GETADDONS_CACHE_TYPES = "extensions.getAddons.cache.types";
+const GETADDONS_RESULTS = BASE_URL + "/data/test_AddonRepository_cache.json";
+const EMPTY_RESULT = BASE_URL + "/data/test_AddonRepository_empty.json";
+const FAILED_RESULT = BASE_URL + "/data/test_AddonRepository_fail.json";
+
+const FILE_DATABASE = "addons.json";
+
+const ADDONS = [
+ {
+ manifest: {
+ name: "XPI Add-on 1",
+ version: "1.1",
+
+ description: "XPI Add-on 1 - Description",
+ developer: {
+ name: "XPI Add-on 1 - Author",
+ },
+
+ homepage_url: "http://example.com/xpi/1/homepage.html",
+ icons: {
+ 32: "icon.png",
+ },
+
+ options_ui: {
+ page: "options.html",
+ },
+
+ browser_specific_settings: {
+ gecko: { id: "test_AddonRepository_1@tests.mozilla.org" },
+ },
+ },
+ },
+ {
+ manifest: {
+ name: "XPI Add-on 2",
+ version: "1.2",
+ theme: {},
+ browser_specific_settings: {
+ gecko: { id: "test_AddonRepository_2@tests.mozilla.org" },
+ },
+ },
+ },
+ {
+ manifest: {
+ name: "XPI Add-on 3",
+ version: "1.3",
+ icons: {
+ 32: "icon.png",
+ },
+ theme: {},
+ browser_specific_settings: {
+ gecko: { id: "test_AddonRepository_3@tests.mozilla.org" },
+ },
+ },
+ files: {
+ "preview.png": "",
+ },
+ },
+];
+
+const ADDON_IDS = ADDONS.map(
+ addon => addon.manifest.browser_specific_settings.gecko.id
+);
+const ADDON_FILES = ADDONS.map(addon =>
+ AddonTestUtils.createTempWebExtensionFile(addon)
+);
+
+const PREF_ADDON0_CACHE_ENABLED =
+ "extensions." + ADDON_IDS[0] + ".getAddons.cache.enabled";
+const PREF_ADDON1_CACHE_ENABLED =
+ "extensions." + ADDON_IDS[1] + ".getAddons.cache.enabled";
+
+// Properties of an individual add-on that should be checked
+// Note: updateDate is checked separately
+const ADDON_PROPERTIES = [
+ "id",
+ "type",
+ "name",
+ "version",
+ "developers",
+ "description",
+ "fullDescription",
+ "icons",
+ "screenshots",
+ "homepageURL",
+ "supportURL",
+ "optionsURL",
+ "averageRating",
+ "reviewCount",
+ "reviewURL",
+ "weeklyDownloads",
+ "sourceURI",
+];
+
+// The updateDate property is annoying to test for XPI add-ons.
+// However, since we only care about whether the repository value vs. the
+// XPI value is used, we can just test if the property value matches
+// the repository value
+const REPOSITORY_UPDATEDATE = 9;
+
+// Get the URI of a subfile locating directly in the folder of
+// the add-on corresponding to the specified id
+function get_subfile_uri(aId, aFilename) {
+ let file = gProfD.clone();
+ file.append("extensions");
+ return do_get_addon_root_uri(file, aId) + aFilename;
+}
+
+// Expected repository add-ons
+const REPOSITORY_ADDONS = [
+ {
+ id: ADDON_IDS[0],
+ type: "extension",
+ name: "Repo Add-on 1",
+ version: "2.1",
+ developers: [
+ {
+ name: "Repo Add-on 1 - First Developer",
+ url: BASE_URL + "/repo/1/firstDeveloper.html",
+ },
+ {
+ name: "Repo Add-on 1 - Second Developer",
+ url: BASE_URL + "/repo/1/secondDeveloper.html",
+ },
+ ],
+ description: "Repo Add-on 1 - Description\nSecond line",
+ fullDescription: "Repo Add-on 1 - Full Description & some extra",
+ icons: { 32: BASE_URL + "/repo/1/icon.png" },
+ homepageURL: BASE_URL + "/repo/1/homepage.html",
+ supportURL: BASE_URL + "/repo/1/support.html",
+ averageRating: 1,
+ reviewCount: 1111,
+ reviewURL: BASE_URL + "/repo/1/review.html",
+ weeklyDownloads: 3331,
+ sourceURI: BASE_URL + "/repo/1/install.xpi",
+ },
+ {
+ id: ADDON_IDS[1],
+ type: "theme",
+ name: "Repo Add-on 2",
+ version: "2.2",
+ developers: [
+ {
+ name: "Repo Add-on 2 - First Developer",
+ url: BASE_URL + "/repo/2/firstDeveloper.html",
+ },
+ {
+ name: "Repo Add-on 2 - Second Developer",
+ url: BASE_URL + "/repo/2/secondDeveloper.html",
+ },
+ ],
+ description: "Repo Add-on 2 - Description",
+ fullDescription: "Repo Add-on 2 - Full Description",
+ icons: { 32: BASE_URL + "/repo/2/icon.png" },
+ screenshots: [
+ {
+ url: BASE_URL + "/repo/2/firstFull.png",
+ thumbnailURL: BASE_URL + "/repo/2/firstThumbnail.png",
+ caption: "Repo Add-on 2 - First Caption",
+ },
+ {
+ url: BASE_URL + "/repo/2/secondFull.png",
+ thumbnailURL: BASE_URL + "/repo/2/secondThumbnail.png",
+ caption: "Repo Add-on 2 - Second Caption",
+ },
+ ],
+ homepageURL: BASE_URL + "/repo/2/homepage.html",
+ supportURL: BASE_URL + "/repo/2/support.html",
+ averageRating: 2,
+ reviewCount: 1112,
+ reviewURL: BASE_URL + "/repo/2/review.html",
+ weeklyDownloads: 3332,
+ sourceURI: BASE_URL + "/repo/2/install.xpi",
+ },
+ {
+ id: ADDON_IDS[2],
+ type: "theme",
+ name: "Repo Add-on 3",
+ version: "2.3",
+ icons: { 32: BASE_URL + "/repo/3/icon.png" },
+ screenshots: [
+ {
+ url: BASE_URL + "/repo/3/firstFull.png",
+ thumbnailURL: BASE_URL + "/repo/3/firstThumbnail.png",
+ caption: "Repo Add-on 3 - First Caption",
+ },
+ {
+ url: BASE_URL + "/repo/3/secondFull.png",
+ thumbnailURL: BASE_URL + "/repo/3/secondThumbnail.png",
+ caption: "Repo Add-on 3 - Second Caption",
+ },
+ ],
+ },
+];
+
+function extensionURL(id, path) {
+ return WebExtensionPolicy.getByID(id).getURL(path);
+}
+
+// Expected add-ons when not using cache
+const WITHOUT_CACHE = [
+ {
+ id: ADDON_IDS[0],
+ type: "extension",
+ name: "XPI Add-on 1",
+ version: "1.1",
+ authors: [{ name: "XPI Add-on 1 - Author" }],
+ description: "XPI Add-on 1 - Description",
+ get icons() {
+ return { 32: get_subfile_uri(ADDON_IDS[0], "icon.png") };
+ },
+ homepageURL: `${BASE_URL}/xpi/1/homepage.html`,
+ get optionsURL() {
+ return extensionURL(ADDON_IDS[0], "options.html");
+ },
+ sourceURI: NetUtil.newURI(ADDON_FILES[0]).spec,
+ },
+ {
+ id: ADDON_IDS[1],
+ type: "theme",
+ name: "XPI Add-on 2",
+ version: "1.2",
+ sourceURI: NetUtil.newURI(ADDON_FILES[1]).spec,
+ icons: {},
+ },
+ {
+ id: ADDON_IDS[2],
+ type: "theme",
+ name: "XPI Add-on 3",
+ version: "1.3",
+ get icons() {
+ return { 32: get_subfile_uri(ADDON_IDS[2], "icon.png") };
+ },
+ screenshots: [
+ {
+ get url() {
+ return get_subfile_uri(ADDON_IDS[2], "preview.png");
+ },
+ },
+ ],
+ sourceURI: NetUtil.newURI(ADDON_FILES[2]).spec,
+ },
+];
+
+// Expected add-ons when using cache
+const WITH_CACHE = [
+ {
+ id: ADDON_IDS[0],
+ type: "extension",
+ name: "XPI Add-on 1",
+ version: "1.1",
+ developers: [
+ {
+ name: "Repo Add-on 1 - First Developer",
+ url: BASE_URL + "/repo/1/firstDeveloper.html",
+ },
+ {
+ name: "Repo Add-on 1 - Second Developer",
+ url: BASE_URL + "/repo/1/secondDeveloper.html",
+ },
+ ],
+ description: "XPI Add-on 1 - Description",
+ fullDescription: "Repo Add-on 1 - Full Description & some extra",
+ get icons() {
+ return { 32: get_subfile_uri(ADDON_IDS[0], "icon.png") };
+ },
+ homepageURL: BASE_URL + "/xpi/1/homepage.html",
+ supportURL: BASE_URL + "/repo/1/support.html",
+ get optionsURL() {
+ return extensionURL(ADDON_IDS[0], "options.html");
+ },
+ averageRating: 1,
+ reviewCount: 1111,
+ reviewURL: BASE_URL + "/repo/1/review.html",
+ weeklyDownloads: 3331,
+ sourceURI: NetUtil.newURI(ADDON_FILES[0]).spec,
+ },
+ {
+ id: ADDON_IDS[1],
+ type: "theme",
+ name: "XPI Add-on 2",
+ version: "1.2",
+ developers: [
+ {
+ name: "Repo Add-on 2 - First Developer",
+ url: BASE_URL + "/repo/2/firstDeveloper.html",
+ },
+ {
+ name: "Repo Add-on 2 - Second Developer",
+ url: BASE_URL + "/repo/2/secondDeveloper.html",
+ },
+ ],
+ description: "Repo Add-on 2 - Description",
+ fullDescription: "Repo Add-on 2 - Full Description",
+ icons: { 32: BASE_URL + "/repo/2/icon.png" },
+ screenshots: [
+ {
+ url: BASE_URL + "/repo/2/firstFull.png",
+ thumbnailURL: BASE_URL + "/repo/2/firstThumbnail.png",
+ caption: "Repo Add-on 2 - First Caption",
+ },
+ {
+ url: BASE_URL + "/repo/2/secondFull.png",
+ thumbnailURL: BASE_URL + "/repo/2/secondThumbnail.png",
+ caption: "Repo Add-on 2 - Second Caption",
+ },
+ ],
+ homepageURL: BASE_URL + "/repo/2/homepage.html",
+ supportURL: BASE_URL + "/repo/2/support.html",
+ averageRating: 2,
+ reviewCount: 1112,
+ reviewURL: BASE_URL + "/repo/2/review.html",
+ weeklyDownloads: 3332,
+ sourceURI: NetUtil.newURI(ADDON_FILES[1]).spec,
+ },
+ {
+ id: ADDON_IDS[2],
+ type: "theme",
+ name: "XPI Add-on 3",
+ version: "1.3",
+ get iconURL() {
+ return get_subfile_uri(ADDON_IDS[2], "icon.png");
+ },
+ get icons() {
+ return { 32: get_subfile_uri(ADDON_IDS[2], "icon.png") };
+ },
+ screenshots: [
+ {
+ url: BASE_URL + "/repo/3/firstFull.png",
+ thumbnailURL: BASE_URL + "/repo/3/firstThumbnail.png",
+ caption: "Repo Add-on 3 - First Caption",
+ },
+ {
+ url: BASE_URL + "/repo/3/secondFull.png",
+ thumbnailURL: BASE_URL + "/repo/3/secondThumbnail.png",
+ caption: "Repo Add-on 3 - Second Caption",
+ },
+ ],
+ sourceURI: NetUtil.newURI(ADDON_FILES[2]).spec,
+ },
+];
+
+// Expected add-ons when using cache
+const WITH_EXTENSION_CACHE = [
+ {
+ id: ADDON_IDS[0],
+ type: "extension",
+ name: "XPI Add-on 1",
+ version: "1.1",
+ developers: [
+ {
+ name: "Repo Add-on 1 - First Developer",
+ url: BASE_URL + "/repo/1/firstDeveloper.html",
+ },
+ {
+ name: "Repo Add-on 1 - Second Developer",
+ url: BASE_URL + "/repo/1/secondDeveloper.html",
+ },
+ ],
+ description: "XPI Add-on 1 - Description",
+ fullDescription: "Repo Add-on 1 - Full Description & some extra",
+ get icons() {
+ return { 32: get_subfile_uri(ADDON_IDS[0], "icon.png") };
+ },
+ homepageURL: BASE_URL + "/xpi/1/homepage.html",
+ supportURL: BASE_URL + "/repo/1/support.html",
+ get optionsURL() {
+ return extensionURL(ADDON_IDS[0], "options.html");
+ },
+ averageRating: 1,
+ reviewCount: 1111,
+ reviewURL: BASE_URL + "/repo/1/review.html",
+ weeklyDownloads: 3331,
+ sourceURI: NetUtil.newURI(ADDON_FILES[0]).spec,
+ },
+ {
+ id: ADDON_IDS[1],
+ type: "theme",
+ name: "XPI Add-on 2",
+ version: "1.2",
+ sourceURI: NetUtil.newURI(ADDON_FILES[1]).spec,
+ icons: {},
+ },
+ {
+ id: ADDON_IDS[2],
+ type: "theme",
+ name: "XPI Add-on 3",
+ version: "1.3",
+ get iconURL() {
+ return get_subfile_uri(ADDON_IDS[2], "icon.png");
+ },
+ get icons() {
+ return { 32: get_subfile_uri(ADDON_IDS[2], "icon.png") };
+ },
+ screenshots: [
+ {
+ get url() {
+ return get_subfile_uri(ADDON_IDS[2], "preview.png");
+ },
+ },
+ ],
+ sourceURI: NetUtil.newURI(ADDON_FILES[2]).spec,
+ },
+];
+
+var gDBFile = gProfD.clone();
+gDBFile.append(FILE_DATABASE);
+
+/*
+ * Check the actual add-on results against the expected add-on results
+ *
+ * @param aActualAddons
+ * The array of actual add-ons to check
+ * @param aExpectedAddons
+ * The array of expected add-ons to check against
+ * @param aFromRepository
+ * An optional boolean representing if the add-ons are from
+ * the repository
+ */
+function check_results(aActualAddons, aExpectedAddons, aFromRepository) {
+ aFromRepository = !!aFromRepository;
+
+ do_check_addons(aActualAddons, aExpectedAddons, ADDON_PROPERTIES);
+
+ // Separately test updateDate (it should only be equal to the
+ // REPOSITORY values if it is from the repository)
+ aActualAddons.forEach(function (aActualAddon) {
+ if (aActualAddon.updateDate) {
+ let time = aActualAddon.updateDate.getTime();
+ Assert.equal(time === 1000 * REPOSITORY_UPDATEDATE, aFromRepository);
+ }
+ });
+}
+
+/*
+ * Check the add-ons in the cache. This function also tests
+ * AddonRepository.getCachedAddonByID()
+ *
+ * @param aExpectedToFind
+ * An array of booleans representing which REPOSITORY_ADDONS are
+ * expected to be found in the cache
+ * @param aExpectedImmediately
+ * A boolean representing if results from the cache are expected
+ * immediately. Results are not immediate if the cache has not been
+ * initialized yet.
+ * @return Promise{null}
+ * Resolves once the checks are complete
+ */
+function check_cache(aExpectedToFind, aExpectedImmediately) {
+ Assert.equal(aExpectedToFind.length, REPOSITORY_ADDONS.length);
+
+ let lookups = [];
+
+ for (let i = 0; i < REPOSITORY_ADDONS.length; i++) {
+ lookups.push(
+ new Promise((resolve, reject) => {
+ let immediatelyFound = true;
+ let expected = aExpectedToFind[i] ? REPOSITORY_ADDONS[i] : null;
+ // can't Promise-wrap this because we're also testing whether the callback is
+ // sync or async
+ AddonRepository.getCachedAddonByID(
+ REPOSITORY_ADDONS[i].id,
+ function (aAddon) {
+ Assert.equal(immediatelyFound, aExpectedImmediately);
+ if (expected == null) {
+ Assert.equal(aAddon, null);
+ } else {
+ check_results([aAddon], [expected], true);
+ }
+ resolve();
+ }
+ );
+ immediatelyFound = false;
+ })
+ );
+ }
+ return Promise.all(lookups);
+}
+
+/*
+ * Task to check an initialized cache by checking the cache, then restarting the
+ * manager, and checking the cache. This checks that the cache is consistent
+ * across manager restarts.
+ *
+ * @param aExpectedToFind
+ * An array of booleans representing which REPOSITORY_ADDONS are
+ * expected to be found in the cache
+ */
+async function check_initialized_cache(aExpectedToFind) {
+ await check_cache(aExpectedToFind, true);
+ await promiseRestartManager();
+
+ // If cache is disabled, then expect results immediately
+ let cacheEnabled = Services.prefs.getBoolPref(PREF_GETADDONS_CACHE_ENABLED);
+ await check_cache(aExpectedToFind, !cacheEnabled);
+}
+
+add_task(async function setup() {
+ // Setup for test
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9");
+
+ await promiseStartupManager();
+
+ // Install XPI add-ons
+ await promiseInstallAllFiles(ADDON_FILES);
+ await promiseRestartManager();
+
+ gServer = AddonTestUtils.createHttpServer({ hosts: [HOST] });
+ gServer.registerDirectory("/data/", do_get_file("data"));
+});
+
+// Tests AddonRepository.cacheEnabled
+add_task(async function run_test_1() {
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, false);
+ Assert.ok(!AddonRepository.cacheEnabled);
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+ Assert.ok(AddonRepository.cacheEnabled);
+});
+
+// Tests that the cache and database begin as empty
+add_task(async function run_test_2() {
+ Assert.ok(!gDBFile.exists());
+ await check_cache([false, false, false], false);
+ await AddonRepository.flush();
+});
+
+// Tests repopulateCache when the search fails
+add_task(async function run_test_3() {
+ Assert.ok(gDBFile.exists());
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, FAILED_RESULT);
+
+ await AddonRepository.backgroundUpdateCheck();
+ await check_initialized_cache([false, false, false]);
+});
+
+// Tests repopulateCache when search returns no results
+add_task(async function run_test_4() {
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, EMPTY_RESULT);
+
+ await AddonRepository.backgroundUpdateCheck();
+ await check_initialized_cache([false, false, false]);
+});
+
+// Tests repopulateCache when search returns results
+add_task(async function run_test_5() {
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, GETADDONS_RESULTS);
+
+ await AddonRepository.backgroundUpdateCheck();
+ await check_initialized_cache([true, true, true]);
+});
+
+// Tests repopulateCache when caching is disabled for a single add-on
+add_task(async function run_test_5_1() {
+ Services.prefs.setBoolPref(PREF_ADDON0_CACHE_ENABLED, false);
+
+ await AddonRepository.backgroundUpdateCheck();
+
+ // Reset pref for next test
+ Services.prefs.setBoolPref(PREF_ADDON0_CACHE_ENABLED, true);
+
+ await check_initialized_cache([false, true, true]);
+});
+
+// Tests repopulateCache when caching is disabled
+add_task(async function run_test_6() {
+ Assert.ok(gDBFile.exists());
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, false);
+
+ await AddonRepository.backgroundUpdateCheck();
+ // Database should have been deleted
+ Assert.ok(!gDBFile.exists());
+
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+ await check_cache([false, false, false], false);
+ await AddonRepository.flush();
+});
+
+// Tests cacheAddons when the search fails
+add_task(async function run_test_7() {
+ Assert.ok(gDBFile.exists());
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, FAILED_RESULT);
+
+ await AddonRepository.cacheAddons(ADDON_IDS);
+ await check_initialized_cache([false, false, false]);
+});
+
+// Tests cacheAddons when the search returns no results
+add_task(async function run_test_8() {
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, EMPTY_RESULT);
+
+ await AddonRepository.cacheAddons(ADDON_IDS);
+ await check_initialized_cache([false, false, false]);
+});
+
+// Tests cacheAddons for a single add-on when search returns results
+add_task(async function run_test_9() {
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, GETADDONS_RESULTS);
+
+ await AddonRepository.cacheAddons([ADDON_IDS[0]]);
+ await check_initialized_cache([true, false, false]);
+});
+
+// Tests cacheAddons when caching is disabled for a single add-on
+add_task(async function run_test_9_1() {
+ Services.prefs.setBoolPref(PREF_ADDON1_CACHE_ENABLED, false);
+
+ await AddonRepository.cacheAddons(ADDON_IDS);
+
+ // Reset pref for next test
+ Services.prefs.setBoolPref(PREF_ADDON1_CACHE_ENABLED, true);
+
+ await check_initialized_cache([true, false, true]);
+});
+
+// Tests cacheAddons for multiple add-ons, some already in the cache,
+add_task(async function run_test_10() {
+ await AddonRepository.cacheAddons(ADDON_IDS);
+ await check_initialized_cache([true, true, true]);
+});
+
+// Tests cacheAddons when caching is disabled
+add_task(async function run_test_11() {
+ Assert.ok(gDBFile.exists());
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, false);
+
+ await AddonRepository.cacheAddons(ADDON_IDS);
+ Assert.ok(gDBFile.exists());
+
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+ await check_initialized_cache([true, true, true]);
+});
+
+// Tests that XPI add-ons do not use any of the repository properties if
+// caching is disabled, even if there are repository properties available
+add_task(async function run_test_12() {
+ Assert.ok(gDBFile.exists());
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, false);
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, GETADDONS_RESULTS);
+
+ let addons = await promiseAddonsByIDs(ADDON_IDS);
+ check_results(addons, WITHOUT_CACHE);
+});
+
+// Tests that a background update with caching disabled deletes the add-ons
+// database, and that XPI add-ons still do not use any of repository properties
+add_task(async function run_test_13() {
+ Assert.ok(gDBFile.exists());
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, EMPTY_RESULT);
+
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ // Database should have been deleted
+ Assert.ok(!gDBFile.exists());
+
+ let aAddons = await promiseAddonsByIDs(ADDON_IDS);
+ check_results(aAddons, WITHOUT_CACHE);
+});
+
+// Tests that the XPI add-ons have the correct properties if caching is
+// enabled but has no information
+add_task(async function run_test_14() {
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ await AddonRepository.flush();
+ Assert.ok(gDBFile.exists());
+
+ let aAddons = await promiseAddonsByIDs(ADDON_IDS);
+ check_results(aAddons, WITHOUT_CACHE);
+});
+
+// Tests that the XPI add-ons correctly use the repository properties when
+// caching is enabled and the repository information is available
+add_task(async function run_test_15() {
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, GETADDONS_RESULTS);
+
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ let aAddons = await promiseAddonsByIDs(ADDON_IDS);
+ check_results(aAddons, WITH_CACHE);
+});
+
+// Tests that restarting the manager does not change the checked properties
+// on the XPI add-ons (repository properties still exist and are still properly
+// used)
+add_task(async function run_test_16() {
+ await promiseRestartManager();
+
+ let aAddons = await promiseAddonsByIDs(ADDON_IDS);
+ check_results(aAddons, WITH_CACHE);
+});
+
+// Tests that setting a list of types to cache works
+add_task(async function run_test_17() {
+ Services.prefs.setCharPref(
+ PREF_GETADDONS_CACHE_TYPES,
+ "foo,bar,extension,baz"
+ );
+
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ let aAddons = await promiseAddonsByIDs(ADDON_IDS);
+ check_results(aAddons, WITH_EXTENSION_CACHE);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache_locale.js b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache_locale.js
new file mode 100644
index 0000000000..37e60e27dd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache_locale.js
@@ -0,0 +1,217 @@
+"user strict";
+
+const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
+const PREF_METADATA_LASTUPDATE = "extensions.getAddons.cache.lastUpdate";
+Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+let server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+// Use %LOCALE% as the default pref does. It is set from appLocaleAsBCP47.
+Services.prefs.setStringPref(
+ PREF_GETADDONS_BYIDS,
+ "http://example.com/addons.json?guids=%IDS%&locale=%LOCALE%"
+);
+
+const TEST_ADDON_ID = "test_AddonRepository_1@tests.mozilla.org";
+
+const repositoryAddons = {
+ "test_AddonRepository_1@tests.mozilla.org": {
+ name: "Repo Add-on 1",
+ type: "extension",
+ guid: TEST_ADDON_ID,
+ current_version: {
+ version: "2.1",
+ files: [
+ {
+ platform: "all",
+ size: 9,
+ url: "http://example.com/repo/1/install.xpi",
+ },
+ ],
+ },
+ },
+ "langpack-und@test.mozilla.org": {
+ // included only to avoid exceptions in AddonRepository
+ name: "und langpack",
+ type: "language",
+ guid: "langpack-und@test.mozilla.org",
+ current_version: {
+ version: "1.1",
+ files: [
+ {
+ platform: "all",
+ size: 9,
+ url: "http://example.com/repo/1/langpack.xpi",
+ },
+ ],
+ },
+ },
+};
+
+server.registerPathHandler("/addons.json", (request, response) => {
+ let search = new URLSearchParams(request.queryString);
+ let IDs = search.get("guids").split(",");
+ let locale = search.get("locale");
+
+ let repositoryData = {
+ page_size: 25,
+ page_count: 1,
+ count: 0,
+ next: null,
+ previous: null,
+ results: [],
+ };
+ for (let id of IDs) {
+ let data = JSON.parse(JSON.stringify(repositoryAddons[id]));
+ data.summary = `This is an ${locale} addon data object`;
+ data.description = `Full Description ${locale}`;
+ repositoryData.results.push(data);
+ }
+ repositoryData.count = repositoryData.results.length;
+
+ // The request contains the IDs to retreive, but we're just handling the
+ // two test addons so it's static data.
+ response.setHeader("content-type", "application/json");
+ response.write(JSON.stringify(repositoryData));
+});
+
+const ADDONS = [
+ {
+ manifest: {
+ name: "XPI Add-on 1",
+ version: "1.1",
+
+ description: "XPI Add-on 1 - Description",
+ developer: {
+ name: "XPI Add-on 1 - Author",
+ },
+
+ homepage_url: "http://example.com/xpi/1/homepage.html",
+ icons: {
+ 32: "icon.png",
+ },
+
+ options_ui: {
+ page: "options.html",
+ },
+
+ browser_specific_settings: {
+ gecko: { id: TEST_ADDON_ID },
+ },
+ },
+ },
+ {
+ // Necessary to provide the "und" locale
+ manifest: {
+ name: "und Language Pack",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: "langpack-und@test.mozilla.org",
+ },
+ },
+ sources: {
+ browser: {
+ base_path: "browser/",
+ },
+ },
+ langpack_id: "und",
+ languages: {
+ und: {
+ chrome_resources: {
+ global: "chrome/und/locale/und/global/",
+ },
+ version: "20171001190118",
+ },
+ },
+ author: "Mozilla Localization Task Force",
+ description: "Language pack for Testy for und",
+ },
+ },
+];
+const ADDON_FILES = ADDONS.map(addon =>
+ AddonTestUtils.createTempWebExtensionFile(addon)
+);
+
+const REQ_LOC_CHANGE_EVENT = "intl:requested-locales-changed";
+
+function promiseLocaleChanged(requestedLocale) {
+ if (Services.locale.appLocaleAsBCP47 == requestedLocale) {
+ return Promise.resolve();
+ }
+ return new Promise(resolve => {
+ let localeObserver = {
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case REQ_LOC_CHANGE_EVENT:
+ let reqLocs = Services.locale.requestedLocales;
+ equal(reqLocs[0], requestedLocale);
+ Services.obs.removeObserver(localeObserver, REQ_LOC_CHANGE_EVENT);
+ resolve();
+ }
+ },
+ };
+ Services.obs.addObserver(localeObserver, REQ_LOC_CHANGE_EVENT);
+ Services.locale.requestedLocales = [requestedLocale];
+ });
+}
+
+function promiseMetaDataUpdate() {
+ return new Promise(resolve => {
+ let listener = args => {
+ Services.prefs.removeObserver(PREF_METADATA_LASTUPDATE, listener);
+ resolve();
+ };
+
+ Services.prefs.addObserver(PREF_METADATA_LASTUPDATE, listener);
+ });
+}
+
+function promiseLocale(locale) {
+ return Promise.all([promiseLocaleChanged(locale), promiseMetaDataUpdate()]);
+}
+
+add_task(async function setup() {
+ await promiseStartupManager();
+ for (let xpi of ADDON_FILES) {
+ await promiseInstallFile(xpi);
+ }
+});
+
+add_task(async function test_locale_change() {
+ await promiseLocale("en-US");
+ let addon = await AddonRepository.getCachedAddonByID(TEST_ADDON_ID);
+ Assert.ok(addon.description.includes("en-US"), "description is en-us");
+ Assert.ok(
+ addon.fullDescription.includes("en-US"),
+ "fullDescription is en-us"
+ );
+
+ // This pref is a 1s resolution, set it to zero so the
+ // next test can wait on it being updated again.
+ Services.prefs.setIntPref(PREF_METADATA_LASTUPDATE, 0);
+ // Wait for the last update timestamp to be updated.
+ await promiseLocale("und");
+
+ addon = await AddonRepository.getCachedAddonByID(TEST_ADDON_ID);
+
+ Assert.ok(
+ addon.description.includes("und"),
+ `description is ${addon.description}`
+ );
+ Assert.ok(
+ addon.fullDescription.includes("und"),
+ `fullDescription is ${addon.fullDescription}`
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_langpacks.js b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_langpacks.js
new file mode 100644
index 0000000000..e84ce4ea30
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_langpacks.js
@@ -0,0 +1,135 @@
+const PREF_GET_LANGPACKS = "extensions.getAddons.langpacks.url";
+
+let server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+Services.prefs.setStringPref(
+ PREF_GET_LANGPACKS,
+ "http://example.com/langpacks.json"
+);
+
+add_task(async function test_getlangpacks() {
+ function setData(data) {
+ if (typeof data != "string") {
+ data = JSON.stringify(data);
+ }
+
+ server.registerPathHandler("/langpacks.json", (request, response) => {
+ response.setHeader("content-type", "application/json");
+ response.write(data);
+ });
+ }
+
+ const EXPECTED = [
+ {
+ target_locale: "kl",
+ url: "http://example.com/langpack1.xpi",
+ hash: "sha256:0123456789abcdef",
+ },
+ {
+ target_locale: "fo",
+ url: "http://example.com/langpack2.xpi",
+ hash: "sha256:fedcba9876543210",
+ },
+ ];
+
+ setData({
+ results: [
+ // A simple entry
+ {
+ target_locale: EXPECTED[0].target_locale,
+ current_compatible_version: {
+ files: [
+ {
+ platform: "all",
+ url: EXPECTED[0].url,
+ hash: EXPECTED[0].hash,
+ },
+ ],
+ },
+ },
+
+ // An entry with multiple supported platforms
+ {
+ target_locale: EXPECTED[1].target_locale,
+ current_compatible_version: {
+ files: [
+ {
+ platform: "somethingelse",
+ url: "http://example.com/bogus.xpi",
+ hash: "sha256:abcd",
+ },
+ {
+ platform: Services.appinfo.OS.toLowerCase(),
+ url: EXPECTED[1].url,
+ hash: EXPECTED[1].hash,
+ },
+ ],
+ },
+ },
+
+ // An entry with no matching platform
+ {
+ target_locale: "bla",
+ current_compatible_version: {
+ files: [
+ {
+ platform: "unsupportedplatform",
+ url: "http://example.com/bogus2.xpi",
+ hash: "sha256:1234",
+ },
+ ],
+ },
+ },
+ ],
+ });
+
+ let result = await AddonRepository.getAvailableLangpacks();
+ equal(result.length, 2, "Got 2 results");
+
+ deepEqual(result[0], EXPECTED[0], "Got expected result for simple entry");
+ deepEqual(
+ result[1],
+ EXPECTED[1],
+ "Got expected result for multi-platform entry"
+ );
+
+ setData("not valid json");
+ await Assert.rejects(
+ AddonRepository.getAvailableLangpacks(),
+ /SyntaxError/,
+ "Got parse error on invalid JSON"
+ );
+});
+
+// Tests that cookies are not sent with langpack requests.
+add_task(async function test_cookies() {
+ let lastRequest = null;
+ server.registerPathHandler("/langpacks.json", (request, response) => {
+ lastRequest = request;
+ response.write(JSON.stringify({ results: [] }));
+ });
+
+ const COOKIE = "test";
+ let expiration = Date.now() / 1000 + 60 * 60;
+ Services.cookies.add(
+ "example.com",
+ "/",
+ COOKIE,
+ "testing",
+ false,
+ false,
+ false,
+ expiration,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+
+ await AddonRepository.getAvailableLangpacks();
+
+ notEqual(lastRequest, null, "Received langpack request");
+ equal(
+ lastRequest.hasHeader("Cookie"),
+ false,
+ "Langpack request has no cookies"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_paging.js b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_paging.js
new file mode 100644
index 0000000000..0773917d84
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_paging.js
@@ -0,0 +1,91 @@
+// Test that AMO api results that are returned in muliple pages are
+// properly handled.
+add_task(async function test_paged_api() {
+ const MAX_ADDON = 3;
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "2");
+
+ let testserver = createHttpServer();
+ const PORT = testserver.identity.primaryPort;
+
+ const EMPTY_RESPONSE = {
+ next: null,
+ results: [],
+ };
+
+ function name(n) {
+ return `Addon ${n}`;
+ }
+ function id(n) {
+ return `test${n}@tests.mozilla.org`;
+ }
+ function summary(n) {
+ return `Summary for addon ${n}`;
+ }
+ function description(n) {
+ return `Description for addon ${n}`;
+ }
+
+ testserver.registerPathHandler("/empty", (request, response) => {
+ response.setHeader("content-type", "application/json");
+ response.write(JSON.stringify(EMPTY_RESPONSE));
+ });
+
+ testserver.registerPrefixHandler("/addons/", (request, response) => {
+ let [page] = /\d+/.exec(request.path);
+ page = page ? parseInt(page, 10) : 0;
+ page = Math.min(page, MAX_ADDON);
+
+ let result = {
+ next:
+ page == MAX_ADDON
+ ? null
+ : `http://localhost:${PORT}/addons/${page + 1}`,
+ results: [
+ {
+ name: name(page),
+ type: "extension",
+ guid: id(page),
+ summary: summary(page),
+ description: description(page),
+ },
+ ],
+ };
+
+ response.setHeader("content-type", "application/json");
+ response.write(JSON.stringify(result));
+ });
+
+ Services.prefs.setCharPref(
+ PREF_GETADDONS_BYIDS,
+ `http://localhost:${PORT}/addons/0`
+ );
+
+ await promiseStartupManager();
+
+ for (let i = 0; i <= MAX_ADDON; i++) {
+ await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: id(i) } },
+ },
+ });
+ }
+
+ await AddonManagerPrivate.backgroundUpdateCheck();
+
+ let ids = [];
+ for (let i = 0; i <= MAX_ADDON; i++) {
+ ids.push(id(i));
+ }
+ let addons = await AddonRepository.getAddonsByIDs(ids);
+
+ equal(addons.length, MAX_ADDON + 1);
+ for (let i = 0; i <= MAX_ADDON; i++) {
+ equal(addons[i].name, name(i));
+ equal(addons[i].id, id(i));
+ equal(addons[i].description, summary(i));
+ equal(addons[i].fullDescription, description(i));
+ }
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker.js b/toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker.js
new file mode 100644
index 0000000000..b3fca5bba6
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker.js
@@ -0,0 +1,292 @@
+"use strict";
+
+const { ProductAddonChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/ProductAddonChecker.sys.mjs"
+);
+
+const LocalFile = new Components.Constructor(
+ "@mozilla.org/file/local;1",
+ Ci.nsIFile,
+ "initWithPath"
+);
+
+Services.prefs.setBoolPref("media.gmp-manager.updateEnabled", true);
+
+var testserver = new HttpServer();
+testserver.registerDirectory("/data/", do_get_file("data/productaddons"));
+testserver.start();
+var root =
+ testserver.identity.primaryScheme +
+ "://" +
+ testserver.identity.primaryHost +
+ ":" +
+ testserver.identity.primaryPort +
+ "/data/";
+
+/**
+ * Compares binary data of 2 arrays and returns true if they are the same
+ *
+ * @param arr1 The first array to compare
+ * @param arr2 The second array to compare
+ */
+function compareBinaryData(arr1, arr2) {
+ Assert.equal(arr1.length, arr2.length);
+ for (let i = 0; i < arr1.length; i++) {
+ if (arr1[i] != arr2[i]) {
+ info(
+ "Data differs at index " +
+ i +
+ ", arr1: " +
+ arr1[i] +
+ ", arr2: " +
+ arr2[i]
+ );
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Reads a file's data and returns it
+ *
+ * @param file The file to read the data from
+ * @return array of bytes for the data in the file.
+ */
+function getBinaryFileData(file) {
+ let fileStream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+ // Open as RD_ONLY with default permissions.
+ fileStream.init(file, FileUtils.MODE_RDONLY, FileUtils.PERMS_FILE, 0);
+
+ let stream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ stream.setInputStream(fileStream);
+ let bytes = stream.readByteArray(stream.available());
+ fileStream.close();
+ return bytes;
+}
+
+/**
+ * Compares binary data of 2 files and returns true if they are the same
+ *
+ * @param file1 The first file to compare
+ * @param file2 The second file to compare
+ */
+function compareFiles(file1, file2) {
+ return compareBinaryData(getBinaryFileData(file1), getBinaryFileData(file2));
+}
+
+add_task(async function test_404() {
+ await Assert.rejects(
+ ProductAddonChecker.getProductAddonList(root + "404.xml"),
+ /got node name: html/
+ );
+});
+
+add_task(async function test_not_xml() {
+ await Assert.rejects(
+ ProductAddonChecker.getProductAddonList(root + "bad.txt"),
+ /got node name: parsererror/
+ );
+});
+
+add_task(async function test_invalid_xml() {
+ await Assert.rejects(
+ ProductAddonChecker.getProductAddonList(root + "bad.xml"),
+ /got node name: parsererror/
+ );
+});
+
+add_task(async function test_wrong_xml() {
+ await Assert.rejects(
+ ProductAddonChecker.getProductAddonList(root + "bad2.xml"),
+ /got node name: test/
+ );
+});
+
+add_task(async function test_missing() {
+ let addons = await ProductAddonChecker.getProductAddonList(
+ root + "missing.xml"
+ );
+ Assert.equal(addons, null);
+});
+
+add_task(async function test_empty() {
+ let res = await ProductAddonChecker.getProductAddonList(root + "empty.xml");
+ Assert.ok(Array.isArray(res.addons));
+ Assert.equal(res.addons.length, 0);
+});
+
+add_task(async function test_good_xml() {
+ let res = await ProductAddonChecker.getProductAddonList(root + "good.xml");
+ Assert.ok(Array.isArray(res.addons));
+
+ // There are three valid entries in the XML
+ Assert.equal(res.addons.length, 5);
+
+ let addon = res.addons[0];
+ Assert.equal(addon.id, "test1");
+ Assert.equal(addon.URL, "http://example.com/test1.xpi");
+ Assert.equal(addon.hashFunction, undefined);
+ Assert.equal(addon.hashValue, undefined);
+ Assert.equal(addon.version, undefined);
+ Assert.equal(addon.size, undefined);
+
+ addon = res.addons[1];
+ Assert.equal(addon.id, "test2");
+ Assert.equal(addon.URL, "http://example.com/test2.xpi");
+ Assert.equal(addon.hashFunction, "md5");
+ Assert.equal(addon.hashValue, "djhfgsjdhf");
+ Assert.equal(addon.version, undefined);
+ Assert.equal(addon.size, undefined);
+
+ addon = res.addons[2];
+ Assert.equal(addon.id, "test3");
+ Assert.equal(addon.URL, "http://example.com/test3.xpi");
+ Assert.equal(addon.hashFunction, undefined);
+ Assert.equal(addon.hashValue, undefined);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(addon.size, 45);
+
+ addon = res.addons[3];
+ Assert.equal(addon.id, "test4");
+ Assert.equal(addon.URL, undefined);
+ Assert.equal(addon.hashFunction, undefined);
+ Assert.equal(addon.hashValue, undefined);
+ Assert.equal(addon.version, undefined);
+ Assert.equal(addon.size, undefined);
+
+ addon = res.addons[4];
+ Assert.equal(addon.id, undefined);
+ Assert.equal(addon.URL, "http://example.com/test5.xpi");
+ Assert.equal(addon.hashFunction, undefined);
+ Assert.equal(addon.hashValue, undefined);
+ Assert.equal(addon.version, undefined);
+ Assert.equal(addon.size, undefined);
+});
+
+add_task(async function test_download_nourl() {
+ try {
+ let path = await ProductAddonChecker.downloadAddon({});
+
+ await IOUtils.remove(path);
+ do_throw("Should not have downloaded a file with a missing url");
+ } catch (e) {
+ Assert.ok(
+ true,
+ "Should have thrown when downloading a file with a missing url."
+ );
+ }
+});
+
+add_task(async function test_download_missing() {
+ try {
+ let path = await ProductAddonChecker.downloadAddon({
+ URL: root + "nofile.xpi",
+ });
+
+ await IOUtils.remove(path);
+ do_throw("Should not have downloaded a missing file");
+ } catch (e) {
+ Assert.ok(true, "Should have thrown when downloading a missing file.");
+ }
+});
+
+add_task(async function test_download_noverify() {
+ let path = await ProductAddonChecker.downloadAddon({
+ URL: root + "unsigned.xpi",
+ });
+
+ let stat = await IOUtils.stat(path);
+ Assert.ok(!stat.type !== "directory");
+ Assert.equal(stat.size, 452);
+
+ Assert.ok(
+ compareFiles(
+ do_get_file("data/productaddons/unsigned.xpi"),
+ new LocalFile(path)
+ )
+ );
+
+ await IOUtils.remove(path);
+});
+
+add_task(async function test_download_badsize() {
+ try {
+ let path = await ProductAddonChecker.downloadAddon({
+ URL: root + "unsigned.xpi",
+ size: 400,
+ });
+
+ await IOUtils.remove(path);
+ do_throw("Should not have downloaded a file with a bad size");
+ } catch (e) {
+ Assert.ok(
+ true,
+ "Should have thrown when downloading a file with a bad size."
+ );
+ }
+});
+
+add_task(async function test_download_badhashfn() {
+ try {
+ let path = await ProductAddonChecker.downloadAddon({
+ URL: root + "unsigned.xpi",
+ hashFunction: "sha2567",
+ hashValue:
+ "9b9abf7ddfc1a6d7ffc7e0247481dcc202363e4445ad3494fb22036f1698c7f3",
+ });
+
+ await IOUtils.remove(path);
+ do_throw("Should not have downloaded a file with a bad hash function");
+ } catch (e) {
+ Assert.ok(
+ true,
+ "Should have thrown when downloading a file with a bad hash function."
+ );
+ }
+});
+
+add_task(async function test_download_badhash() {
+ try {
+ let path = await ProductAddonChecker.downloadAddon({
+ URL: root + "unsigned.xpi",
+ hashFunction: "sha256",
+ hashValue:
+ "8b9abf7ddfc1a6d7ffc7e0247481dcc202363e4445ad3494fb22036f1698c7f3",
+ });
+
+ await IOUtils.remove(path);
+ do_throw("Should not have downloaded a file with a bad hash");
+ } catch (e) {
+ Assert.ok(
+ true,
+ "Should have thrown when downloading a file with a bad hash."
+ );
+ }
+});
+
+add_task(async function test_download_works() {
+ let path = await ProductAddonChecker.downloadAddon({
+ URL: root + "unsigned.xpi",
+ size: 452,
+ hashFunction: "sha256",
+ hashValue:
+ "9b9abf7ddfc1a6d7ffc7e0247481dcc202363e4445ad3494fb22036f1698c7f3",
+ });
+
+ let stat = await IOUtils.stat(path);
+ Assert.ok(stat.type !== "directory");
+
+ Assert.ok(
+ compareFiles(
+ do_get_file("data/productaddons/unsigned.xpi"),
+ new LocalFile(path)
+ )
+ );
+
+ await IOUtils.remove(path);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker_signatures.js b/toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker_signatures.js
new file mode 100644
index 0000000000..5ae61568ef
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker_signatures.js
@@ -0,0 +1,201 @@
+"use strict";
+
+const { ProductAddonChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/ProductAddonChecker.sys.mjs"
+);
+
+Services.prefs.setBoolPref("media.gmp-manager.updateEnabled", true);
+
+// Setup a test server for content signature tests.
+const signedTestServer = new HttpServer();
+const testDataDir = "data/productaddons/";
+
+// Start the server so we can grab the identity. We need to know this so the
+// server can reference itself in the handlers that will be set up.
+signedTestServer.start();
+const signedBaseUri =
+ signedTestServer.identity.primaryScheme +
+ "://" +
+ signedTestServer.identity.primaryHost +
+ ":" +
+ signedTestServer.identity.primaryPort;
+
+// Setup endpoint to handle x5u lookups correctly.
+const validX5uPath = "/valid_x5u";
+// These certificates are generated using ./mach generate-test-certs <path_to_certspec>
+const validCertChain = loadCertChain(testDataDir + "content_signing", [
+ "aus_ee",
+ "int",
+]);
+signedTestServer.registerPathHandler(validX5uPath, (req, res) => {
+ res.write(validCertChain.join("\n"));
+});
+const validX5uUrl = signedBaseUri + validX5uPath;
+
+// Setup endpoint to handle x5u lookups incorrectly.
+const invalidX5uPath = "/invalid_x5u";
+const invalidCertChain = loadCertChain(testDataDir + "content_signing", [
+ "aus_ee",
+ // This cert chain is missing the intermediate cert!
+]);
+signedTestServer.registerPathHandler(invalidX5uPath, (req, res) => {
+ res.write(invalidCertChain.join("\n"));
+});
+const invalidX5uUrl = signedBaseUri + invalidX5uPath;
+
+// Will hold the XML data from good.xml.
+let goodXml;
+// This sig is generated using the following command at mozilla-central root
+// `cat toolkit/mozapps/extensions/test/xpcshell/data/productaddons/good.xml | ./mach python security/manager/ssl/tests/unit/test_content_signing/pysign.py`
+// If test certificates are regenerated, this signature must also be.
+const goodXmlContentSignature =
+ "7QYnPqFoOlS02BpDdIRIljzmPr6BFwPs1z1y8KJUBlnU7EVG6FbnXmVVt5Op9wDzgvhXX7th8qFJvpPOZs_B_tHRDNJ8SK0HN95BAN15z3ZW2r95SSHmU-fP2JgoNOR3";
+
+const goodXmlPath = "/good.xml";
+// Requests use query strings to test different signature states.
+const validSignatureQuery = "validSignature";
+const invalidSignatureQuery = "invalidSignature";
+const missingSignatureQuery = "missingSignature";
+const incompleteSignatureQuery = "incompleteSignature";
+const badX5uSignatureQuery = "badX5uSignature";
+signedTestServer.registerPathHandler(goodXmlPath, (req, res) => {
+ if (req.queryString == validSignatureQuery) {
+ res.setHeader(
+ "content-signature",
+ `x5u=${validX5uUrl}; p384ecdsa=${goodXmlContentSignature}`
+ );
+ } else if (req.queryString == invalidSignatureQuery) {
+ res.setHeader("content-signature", `x5u=${validX5uUrl}; p384ecdsa=garbage`);
+ } else if (req.queryString == missingSignatureQuery) {
+ // Intentionally don't set the header.
+ } else if (req.queryString == incompleteSignatureQuery) {
+ res.setHeader(
+ "content-signature",
+ `x5u=${validX5uUrl}` // There's no p384ecdsa part!
+ );
+ } else if (req.queryString == badX5uSignatureQuery) {
+ res.setHeader(
+ "content-signature",
+ `x5u=${invalidX5uUrl}; p384ecdsa=${goodXmlContentSignature}`
+ );
+ } else {
+ Assert.ok(
+ false,
+ "Invalid queryString passed to server! Tests shouldn't do that!"
+ );
+ }
+ res.write(goodXml);
+});
+
+// Handle aysnc load of good.xml.
+add_task(async function load_good_xml() {
+ goodXml = await IOUtils.readUTF8(do_get_file(testDataDir + "good.xml").path);
+});
+
+add_task(async function test_valid_content_signature() {
+ try {
+ const res = await ProductAddonChecker.getProductAddonList(
+ signedBaseUri + goodXmlPath + "?" + validSignatureQuery,
+ /*allowNonBuiltIn*/ false,
+ /*allowedCerts*/ false,
+ /*verifyContentSignature*/ true
+ );
+ Assert.ok(true, "Should successfully get addon list");
+
+ // Smoke test the results are as expected.
+ Assert.equal(res.addons[0].id, "test1");
+ Assert.equal(res.addons[1].id, "test2");
+ Assert.equal(res.addons[2].id, "test3");
+ Assert.equal(res.addons[3].id, "test4");
+ Assert.equal(res.addons[4].id, undefined);
+ } catch (e) {
+ Assert.ok(
+ false,
+ `Should successfully get addon list, instead failed with ${e}`
+ );
+ }
+});
+
+add_task(async function test_invalid_content_signature() {
+ try {
+ await ProductAddonChecker.getProductAddonList(
+ signedBaseUri + goodXmlPath + "?" + invalidSignatureQuery,
+ /*allowNonBuiltIn*/ false,
+ /*allowedCerts*/ false,
+ /*verifyContentSignature*/ true
+ );
+ Assert.ok(false, "Should fail to get addon list");
+ } catch (e) {
+ Assert.ok(true, "Should fail to get addon list");
+ // The nsIContentSignatureVerifier will throw an error on this path,
+ // check that we've caught and re-thrown, but don't check the full error
+ // message as it's messy and subject to change.
+ Assert.ok(
+ e.message.startsWith("Content signature validation failed:"),
+ "Should get signature failure message"
+ );
+ }
+});
+
+add_task(async function test_missing_content_signature_header() {
+ try {
+ await ProductAddonChecker.getProductAddonList(
+ signedBaseUri + goodXmlPath + "?" + missingSignatureQuery,
+ /*allowNonBuiltIn*/ false,
+ /*allowedCerts*/ false,
+ /*verifyContentSignature*/ true
+ );
+ Assert.ok(false, "Should fail to get addon list");
+ } catch (e) {
+ Assert.ok(true, "Should fail to get addon list");
+ Assert.equal(
+ e.addonCheckerErr,
+ ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR
+ );
+ Assert.equal(
+ e.message,
+ "Content signature validation failed: missing content signature header"
+ );
+ }
+});
+
+add_task(async function test_incomplete_content_signature_header() {
+ try {
+ await ProductAddonChecker.getProductAddonList(
+ signedBaseUri + goodXmlPath + "?" + incompleteSignatureQuery,
+ /*allowNonBuiltIn*/ false,
+ /*allowedCerts*/ false,
+ /*verifyContentSignature*/ true
+ );
+ Assert.ok(false, "Should fail to get addon list");
+ } catch (e) {
+ Assert.ok(true, "Should fail to get addon list");
+ Assert.equal(
+ e.addonCheckerErr,
+ ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR
+ );
+ Assert.equal(
+ e.message,
+ "Content signature validation failed: missing signature"
+ );
+ }
+});
+
+add_task(async function test_bad_x5u_content_signature_header() {
+ try {
+ await ProductAddonChecker.getProductAddonList(
+ signedBaseUri + goodXmlPath + "?" + badX5uSignatureQuery,
+ /*allowNonBuiltIn*/ false,
+ /*allowedCerts*/ false,
+ /*verifyContentSignature*/ true
+ );
+ Assert.ok(false, "Should fail to get addon list");
+ } catch (e) {
+ Assert.ok(true, "Should fail to get addon list");
+ Assert.equal(
+ e.addonCheckerErr,
+ ProductAddonChecker.VERIFICATION_INVALID_ERR
+ );
+ Assert.equal(e.message, "Content signature is not valid");
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_QuarantinedDomains_AMRemoteSettings.js b/toolkit/mozapps/extensions/test/xpcshell/test_QuarantinedDomains_AMRemoteSettings.js
new file mode 100644
index 0000000000..8251c12964
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_QuarantinedDomains_AMRemoteSettings.js
@@ -0,0 +1,79 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42");
+
+const QUARANTINE_LIST_PREF = "extensions.quarantinedDomains.list";
+
+async function testQuarantinedDomainsFromRemoteSettings() {
+ // Same as MAX_PREF_LENGTH as defined in Preferences.cpp,
+ // see https://searchfox.org/mozilla-central/rev/06510249/modules/libpref/Preferences.cpp#162
+ const MAX_PREF_LENGTH = 1 * 1024 * 1024;
+ const quarantinedDomainsSets = {
+ testSet1: "example.com,example.org",
+ testSet2: "someothersite.org,testset2.org",
+ };
+ await setAndEmitFakeRemoteSettingsData([
+ {
+ id: "quarantinedDomains-testSet-toolong",
+ quarantinedDomains: {
+ [QUARANTINE_LIST_PREF]: "x".repeat(MAX_PREF_LENGTH + 1),
+ },
+ },
+ {
+ id: "quarantinedDomains-testSet1",
+ quarantinedDomains: {
+ [QUARANTINE_LIST_PREF]: quarantinedDomainsSets.testSet1,
+ },
+ },
+ {
+ id: "quarantinedDomains-testSet2",
+ quarantinedDomains: {
+ [QUARANTINE_LIST_PREF]: quarantinedDomainsSets.testSet2,
+ },
+ },
+ ]);
+
+ Assert.equal(
+ Services.prefs.getPrefType(QUARANTINE_LIST_PREF),
+ Services.prefs.PREF_STRING,
+ `Expect ${QUARANTINE_LIST_PREF} preference type to be string`
+ );
+ // The entry too big to fix in the pref value should throw but not preventing
+ // the other entries from being processed.
+ // The Last collection entry setting the pref wins, and so we expect
+ // the pref to be set to the domains listed in the collection
+ // entry with id "quarantinedDomains-testSet2".
+ Assert.equal(
+ Services.prefs.getStringPref(QUARANTINE_LIST_PREF),
+ quarantinedDomainsSets.testSet2,
+ `Got the expected value set on ${QUARANTINE_LIST_PREF}`
+ );
+
+ // Confirm that the updated quarantined domains list is now reflected
+ // by the results returned by WebExtensionPolicy.isQuarantinedURI.
+ // NOTE: Additional test coverage over the quarantined domains behaviors
+ // are part of a separate xpcshell test
+ // (see toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains.js).
+ for (const domain of quarantinedDomainsSets.testSet2.split(",")) {
+ let uri = Services.io.newURI(`https://${domain}/`);
+ ok(
+ WebExtensionPolicy.isQuarantinedURI(uri),
+ `Expect ${domain} to be quarantined`
+ );
+ }
+
+ for (const domain of quarantinedDomainsSets.testSet1.split(",")) {
+ let uri = Services.io.newURI(`https://${domain}/`);
+ ok(
+ !WebExtensionPolicy.isQuarantinedURI(uri),
+ `Expect ${domain} to not be quarantined`
+ );
+ }
+}
+
+add_setup(async () => {
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(testQuarantinedDomainsFromRemoteSettings);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_XPIStates.js b/toolkit/mozapps/extensions/test/xpcshell/test_XPIStates.js
new file mode 100644
index 0000000000..f769f8e4fd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_XPIStates.js
@@ -0,0 +1,133 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test that we only check manifest age for disabled extensions
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+add_task(async function setup() {
+ await promiseStartupManager();
+ registerCleanupFunction(promiseShutdownManager);
+
+ await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "enabled@tests.mozilla.org" } },
+ },
+ });
+ await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "disabled@tests.mozilla.org" },
+ },
+ },
+ });
+
+ let addon = await promiseAddonByID("disabled@tests.mozilla.org");
+ notEqual(addon, null);
+ await addon.disable();
+});
+
+// Keep track of the last time stamp we've used, so that we can keep moving
+// it forward (if we touch two different files in the same add-on with the same
+// timestamp we may not consider the change significant)
+var lastTimestamp = Date.now();
+
+/*
+ * Helper function to touch a file and then test whether we detect the change.
+ * @param XS The XPIState object.
+ * @param aPath File path to touch.
+ * @param aChange True if we should notice the change, False if we shouldn't.
+ */
+function checkChange(XS, aPath, aChange) {
+ Assert.ok(aPath.exists());
+ lastTimestamp += 10000;
+ info("Touching file " + aPath.path + " with " + lastTimestamp);
+ aPath.lastModifiedTime = lastTimestamp;
+ Assert.equal(XS.scanForChanges(), aChange);
+ // Save the pref so we don't detect this change again
+ XS.save();
+}
+
+// Get a reference to the XPIState (loaded by startupManager) so we can unit test it.
+function getXS() {
+ const { XPIInternal } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIProvider.jsm"
+ );
+ return XPIInternal.XPIStates;
+}
+
+async function getXSJSON() {
+ await AddonTestUtils.loadAddonsList(true);
+
+ return aomStartup.readStartupData();
+}
+
+add_task(async function detect_touches() {
+ let XS = getXS();
+
+ // Should be no changes detected here, because everything should start out up-to-date.
+ Assert.ok(!XS.scanForChanges());
+
+ let states = XS.getLocation("app-profile");
+
+ // State should correctly reflect enabled/disabled
+
+ let state = states.get("enabled@tests.mozilla.org");
+ Assert.notEqual(state, null, "Found xpi state for enabled extension");
+ Assert.ok(state.enabled, "enabled extension has correct xpi state");
+
+ state = states.get("disabled@tests.mozilla.org");
+ Assert.notEqual(state, null, "Found xpi state for disabled extension");
+ Assert.ok(!state.enabled, "disabled extension has correct xpi state");
+
+ // Touch various files and make sure the change is detected.
+
+ // We notice that a packed XPI is touched for an enabled add-on.
+ let peFile = profileDir.clone();
+ peFile.append("enabled@tests.mozilla.org.xpi");
+ checkChange(XS, peFile, true);
+
+ // We should notice the packed XPI change for a disabled add-on too.
+ let pdFile = profileDir.clone();
+ pdFile.append("disabled@tests.mozilla.org.xpi");
+ checkChange(XS, pdFile, true);
+});
+
+/*
+ * Uninstalling extensions should immediately remove them from XPIStates.
+ */
+add_task(async function uninstall_bootstrap() {
+ let pe = await promiseAddonByID("enabled@tests.mozilla.org");
+ await pe.uninstall();
+
+ let xpiState = await getXSJSON();
+ Assert.equal(
+ false,
+ "enabled@tests.mozilla.org" in xpiState["app-profile"].addons
+ );
+});
+
+/*
+ * Installing an extension should immediately add it to XPIState
+ */
+add_task(async function install_bootstrap() {
+ const ID = "addon@tests.mozilla.org";
+ let XS = getXS();
+
+ await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+ let addon = await promiseAddonByID(ID);
+
+ let xState = XS.getAddon("app-profile", ID);
+ Assert.ok(!!xState);
+ Assert.ok(xState.enabled);
+ Assert.equal(xState.mtime, addon.updateDate.getTime());
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_XPIcancel.js b/toolkit/mozapps/extensions/test/xpcshell/test_XPIcancel.js
new file mode 100644
index 0000000000..8910d959f0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_XPIcancel.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test the cancellable doing/done/cancelAll API in XPIProvider
+
+const { XPIInstall } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIInstall.jsm"
+);
+
+function run_test() {
+ // Check that cancelling with nothing in progress doesn't blow up
+ XPIInstall.cancelAll();
+
+ // Check that a basic object gets cancelled
+ let getsCancelled = {
+ isCancelled: false,
+ cancel() {
+ if (this.isCancelled) {
+ do_throw("Already cancelled");
+ }
+ this.isCancelled = true;
+ },
+ };
+ XPIInstall.doing(getsCancelled);
+ XPIInstall.cancelAll();
+ Assert.ok(getsCancelled.isCancelled);
+
+ // Check that if we complete a cancellable, it doesn't get cancelled
+ let doesntGetCancelled = {
+ cancel: () => do_throw("This should not have been cancelled"),
+ };
+ XPIInstall.doing(doesntGetCancelled);
+ Assert.ok(XPIInstall.done(doesntGetCancelled));
+ XPIInstall.cancelAll();
+
+ // A cancellable that adds a cancellable
+ getsCancelled.isCancelled = false;
+ let addsAnother = {
+ isCancelled: false,
+ cancel() {
+ if (this.isCancelled) {
+ do_throw("Already cancelled");
+ }
+ this.isCancelled = true;
+ XPIInstall.doing(getsCancelled);
+ },
+ };
+ XPIInstall.doing(addsAnother);
+ XPIInstall.cancelAll();
+ Assert.ok(addsAnother.isCancelled);
+ Assert.ok(getsCancelled.isCancelled);
+
+ // A cancellable that removes another. This assumes that Set() iterates in the
+ // order that members were added
+ let removesAnother = {
+ isCancelled: false,
+ cancel() {
+ if (this.isCancelled) {
+ do_throw("Already cancelled");
+ }
+ this.isCancelled = true;
+ XPIInstall.done(doesntGetCancelled);
+ },
+ };
+ XPIInstall.doing(removesAnother);
+ XPIInstall.doing(doesntGetCancelled);
+ XPIInstall.cancelAll();
+ Assert.ok(removesAnother.isCancelled);
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_addonStartup.js b/toolkit/mozapps/extensions/test/xpcshell/test_addonStartup.js
new file mode 100644
index 0000000000..715b74a068
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_addonStartup.js
@@ -0,0 +1,93 @@
+"use strict";
+
+add_task(async function test_XPIStates_invalid_paths() {
+ let { path } = gAddonStartup;
+
+ let startupDatasets = [
+ {
+ "app-profile": {
+ addons: {
+ "xpcshell-something-or-other@mozilla.org": {
+ bootstrapped: true,
+ dependencies: [],
+ enabled: true,
+ hasEmbeddedWebExtension: false,
+ lastModifiedTime: 1,
+ path: "xpcshell-something-or-other@mozilla.org",
+ version: "0.0.0",
+ },
+ },
+ checkStartupModifications: true,
+ path: "/home/xpcshell/.mozilla/firefox/default/extensions",
+ },
+ },
+ {
+ "app-profile": {
+ addons: {
+ "xpcshell-something-or-other@mozilla.org": {
+ bootstrapped: true,
+ dependencies: [],
+ enabled: true,
+ hasEmbeddedWebExtension: false,
+ lastModifiedTime: 1,
+ path: "xpcshell-something-or-other@mozilla.org",
+ version: "0.0.0",
+ },
+ },
+ checkStartupModifications: true,
+ path: "c:\\Users\\XpcShell\\Application Data\\Mozilla Firefox\\Profiles\\meh",
+ },
+ },
+ {
+ "app-profile": {
+ addons: {
+ "xpcshell-something-or-other@mozilla.org": {
+ bootstrapped: true,
+ dependencies: [],
+ enabled: true,
+ hasEmbeddedWebExtension: false,
+ lastModifiedTime: 1,
+ path: "/home/xpcshell/my-extensions/something-or-other",
+ version: "0.0.0",
+ },
+ },
+ checkStartupModifications: true,
+ path: "/home/xpcshell/.mozilla/firefox/default/extensions",
+ },
+ },
+ {
+ "app-profile": {
+ addons: {
+ "xpcshell-something-or-other@mozilla.org": {
+ bootstrapped: true,
+ dependencies: [],
+ enabled: true,
+ hasEmbeddedWebExtension: false,
+ lastModifiedTime: 1,
+ path: "c:\\Users\\XpcShell\\my-extensions\\something-or-other",
+ version: "0.0.0",
+ },
+ },
+ checkStartupModifications: true,
+ path: "c:\\Users\\XpcShell\\Application Data\\Mozilla Firefox\\Profiles\\meh",
+ },
+ },
+ ];
+
+ for (let startupData of startupDatasets) {
+ await IOUtils.writeJSON(path, startupData, { compress: true });
+
+ try {
+ let result = aomStartup.readStartupData();
+ info(`readStartupData() returned ${JSON.stringify(result)}`);
+ } catch (e) {
+ // We don't care if this throws, only that it doesn't crash.
+ info(`readStartupData() threw: ${e}`);
+ equal(
+ e.result,
+ Cr.NS_ERROR_FILE_UNRECOGNIZED_PATH,
+ "Got expected error code"
+ );
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_addon_manager_telemetry_events.js b/toolkit/mozapps/extensions/test/xpcshell/test_addon_manager_telemetry_events.js
new file mode 100644
index 0000000000..9c8565a0bc
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_addon_manager_telemetry_events.js
@@ -0,0 +1,875 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const { TelemetryController } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryController.sys.mjs"
+);
+const { AMTelemetry } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+// We don't have an easy way to serve update manifests from a secure URL.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+const EVENT_CATEGORY = "addonsManager";
+const EVENT_METHODS_INSTALL = ["install", "update"];
+const EVENT_METHODS_MANAGE = ["disable", "enable", "uninstall"];
+const EVENT_METHODS = [...EVENT_METHODS_INSTALL, ...EVENT_METHODS_MANAGE];
+
+const FAKE_INSTALL_TELEMETRY_INFO = {
+ source: "fake-install-source",
+ method: "fake-install-method",
+};
+
+function getTelemetryEvents(includeMethods = EVENT_METHODS) {
+ const snapshot = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ );
+
+ ok(
+ snapshot.parent && !!snapshot.parent.length,
+ "Got parent telemetry events in the snapshot"
+ );
+
+ return snapshot.parent
+ .filter(([timestamp, category, method]) => {
+ const includeMethod = includeMethods
+ ? includeMethods.includes(method)
+ : true;
+
+ return category === EVENT_CATEGORY && includeMethod;
+ })
+ .map(event => {
+ return {
+ method: event[2],
+ object: event[3],
+ value: event[4],
+ extra: event[5],
+ };
+ });
+}
+
+function assertNoTelemetryEvents() {
+ const snapshot = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ );
+
+ if (!snapshot.parent || snapshot.parent.length === 0) {
+ ok(true, "Got no parent telemetry events as expected");
+ return;
+ }
+
+ let filteredEvents = snapshot.parent.filter(
+ ([timestamp, category, method]) => {
+ return category === EVENT_CATEGORY;
+ }
+ );
+
+ Assert.deepEqual(filteredEvents, [], "Got no AMTelemetry events as expected");
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ // Thunderbird doesn't have one or more of the probes used in this test.
+ // Ensure the data is collected anyway.
+ Services.prefs.setBoolPref(
+ "toolkit.telemetry.testing.overrideProductsCheck",
+ true
+ );
+
+ // Telemetry test setup needed to ensure that the builtin events are defined
+ // and they can be collected and verified.
+ await TelemetryController.testSetup();
+
+ await promiseStartupManager();
+});
+
+// Test the basic install and management flows.
+add_task(
+ {
+ // We need to enable this pref because some assertions verify that
+ // `installOrigins` is collected in some Telemetry events.
+ pref_set: [["extensions.install_origins.enabled", true]],
+ },
+ async function test_basic_telemetry_events() {
+ const EXTENSION_ID = "basic@test.extension";
+
+ const manifest = {
+ browser_specific_settings: { gecko: { id: EXTENSION_ID } },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ useAddonManager: "permanent",
+ amInstallTelemetryInfo: FAKE_INSTALL_TELEMETRY_INFO,
+ });
+
+ await extension.startup();
+
+ const addon = await promiseAddonByID(EXTENSION_ID);
+
+ info("Disabling the extension");
+ await addon.disable();
+
+ info("Set pending uninstall on the extension");
+ const onceAddonUninstalling = promiseAddonEvent("onUninstalling");
+ addon.uninstall(true);
+ await onceAddonUninstalling;
+
+ info("Cancel pending uninstall");
+ const oncePendingUninstallCancelled = promiseAddonEvent(
+ "onOperationCancelled"
+ );
+ addon.cancelUninstall();
+ await oncePendingUninstallCancelled;
+
+ info("Re-enabling the extension");
+ const onceAddonStarted = promiseWebExtensionStartup(EXTENSION_ID);
+ const onceAddonEnabled = promiseAddonEvent("onEnabled");
+ addon.enable();
+ await Promise.all([onceAddonEnabled, onceAddonStarted]);
+
+ await extension.unload();
+
+ let amEvents = getTelemetryEvents();
+
+ const amMethods = amEvents.map(evt => evt.method);
+ const expectedMethods = [
+ // These two install methods are related to the steps "started" and "completed".
+ "install",
+ "install",
+ // Sequence of disable and enable (pending uninstall and undo uninstall are not going to
+ // record any telemetry events).
+ "disable",
+ "enable",
+ // The final "uninstall" when the test extension is unloaded.
+ "uninstall",
+ ];
+ Assert.deepEqual(
+ amMethods,
+ expectedMethods,
+ "Got the addonsManager telemetry events in the expected order"
+ );
+
+ const installEvents = amEvents.filter(evt => evt.method === "install");
+ const expectedInstallEvents = [
+ {
+ method: "install",
+ object: "extension",
+ value: "1",
+ extra: {
+ addon_id: "basic@test.extension",
+ step: "started",
+ install_origins: "0",
+ ...FAKE_INSTALL_TELEMETRY_INFO,
+ },
+ },
+ {
+ method: "install",
+ object: "extension",
+ value: "1",
+ extra: {
+ addon_id: "basic@test.extension",
+ step: "completed",
+ install_origins: "0",
+ ...FAKE_INSTALL_TELEMETRY_INFO,
+ },
+ },
+ ];
+ Assert.deepEqual(
+ installEvents,
+ expectedInstallEvents,
+ "Got the expected addonsManager.install events"
+ );
+
+ const manageEvents = amEvents.filter(evt =>
+ EVENT_METHODS_MANAGE.includes(evt.method)
+ );
+ const expectedExtra = FAKE_INSTALL_TELEMETRY_INFO;
+ const expectedManageEvents = [
+ {
+ method: "disable",
+ object: "extension",
+ value: "basic@test.extension",
+ extra: expectedExtra,
+ },
+ {
+ method: "enable",
+ object: "extension",
+ value: "basic@test.extension",
+ extra: expectedExtra,
+ },
+ {
+ method: "uninstall",
+ object: "extension",
+ value: "basic@test.extension",
+ extra: expectedExtra,
+ },
+ ];
+ Assert.deepEqual(
+ manageEvents,
+ expectedManageEvents,
+ "Got the expected addonsManager.manage events"
+ );
+
+ // Verify that on every install flow, the value of the addonsManager.install Telemetry events
+ // is being incremented.
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ useAddonManager: "permanent",
+ amInstallTelemetryInfo: FAKE_INSTALL_TELEMETRY_INFO,
+ });
+
+ await extension.startup();
+ await extension.unload();
+
+ const eventsFromNewInstall = getTelemetryEvents();
+ equal(
+ eventsFromNewInstall.length,
+ 3,
+ "Got the expected number of addonsManager install events"
+ );
+
+ const eventValues = eventsFromNewInstall
+ .filter(evt => evt.method === "install")
+ .map(evt => evt.value);
+ const expectedValues = ["2", "2"];
+ Assert.deepEqual(
+ eventValues,
+ expectedValues,
+ "Got the expected install id"
+ );
+ }
+);
+
+add_task(
+ {
+ // We need to enable this pref because some assertions verify that
+ // `installOrigins` is collected in some Telemetry events.
+ pref_set: [["extensions.install_origins.enabled", true]],
+ },
+ async function test_update_telemetry_events() {
+ const EXTENSION_ID = "basic@test.extension";
+
+ const testserver = AddonTestUtils.createHttpServer({
+ hosts: ["example.com"],
+ });
+
+ const updateUrl = `http://example.com/updates.json`;
+
+ const testAddon = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ update_url: updateUrl,
+ },
+ },
+ },
+ });
+
+ const testUserRequestedUpdate = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ update_url: updateUrl,
+ },
+ },
+ },
+ });
+ const testAppRequestedUpdate = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "2.1",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ update_url: updateUrl,
+ },
+ },
+ },
+ });
+
+ testserver.registerFile(
+ `/addons/${EXTENSION_ID}-2.0.xpi`,
+ testUserRequestedUpdate
+ );
+ testserver.registerFile(
+ `/addons/${EXTENSION_ID}-2.1.xpi`,
+ testAppRequestedUpdate
+ );
+
+ let updates = [
+ {
+ version: "2.0",
+ update_link: `http://example.com/addons/${EXTENSION_ID}-2.0.xpi`,
+ applications: {
+ gecko: {
+ strict_min_version: "1",
+ },
+ },
+ },
+ ];
+
+ testserver.registerPathHandler("/updates.json", (request, response) => {
+ response.write(`{
+ "addons": {
+ "${EXTENSION_ID}": {
+ "updates": ${JSON.stringify(updates)}
+ }
+ }
+ }`);
+ });
+
+ await promiseInstallFile(testAddon, false, FAKE_INSTALL_TELEMETRY_INFO);
+
+ let addon = await AddonManager.getAddonByID(EXTENSION_ID);
+
+ // User requested update.
+ await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ let installs = await AddonManager.getAllInstalls();
+ await promiseCompleteAllInstalls(installs);
+
+ updates = [
+ {
+ version: "2.1",
+ update_link: `http://example.com/addons/${EXTENSION_ID}-2.1.xpi`,
+ applications: {
+ gecko: {
+ strict_min_version: "1",
+ },
+ },
+ },
+ ];
+
+ // App requested update.
+ await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ let installs2 = await AddonManager.getAllInstalls();
+ await promiseCompleteAllInstalls(installs2);
+
+ updates = [
+ {
+ version: "2.1.1",
+ update_link: `http://example.com/addons/${EXTENSION_ID}-2.1.1-network-failure.xpi`,
+ applications: {
+ gecko: {
+ strict_min_version: "1",
+ },
+ },
+ },
+ ];
+
+ // Update which fails to download.
+ await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ let installs3 = await AddonManager.getAllInstalls();
+ await promiseCompleteAllInstalls(installs3);
+
+ let amEvents = getTelemetryEvents();
+
+ const installEvents = amEvents
+ .filter(evt => evt.method === "install")
+ .map(evt => {
+ delete evt.value;
+ return evt;
+ });
+
+ const addon_id = "basic@test.extension";
+ const object = "extension";
+
+ Assert.deepEqual(
+ installEvents,
+ [
+ {
+ method: "install",
+ object,
+ extra: {
+ addon_id,
+ step: "started",
+ install_origins: "0",
+ ...FAKE_INSTALL_TELEMETRY_INFO,
+ },
+ },
+ {
+ method: "install",
+ object,
+ extra: {
+ addon_id,
+ step: "completed",
+ install_origins: "0",
+ ...FAKE_INSTALL_TELEMETRY_INFO,
+ },
+ },
+ ],
+ "Got the expected addonsManager.install events"
+ );
+
+ const updateEvents = amEvents
+ .filter(evt => evt.method === "update")
+ .map(evt => {
+ delete evt.value;
+ return evt;
+ });
+
+ const method = "update";
+ const baseExtra = FAKE_INSTALL_TELEMETRY_INFO;
+
+ const expectedUpdateEvents = [
+ // User-requested update to the 2.1 version.
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ step: "started",
+ updated_from: "user",
+ },
+ },
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ step: "download_started",
+ updated_from: "user",
+ },
+ },
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ step: "download_completed",
+ updated_from: "user",
+ },
+ },
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ step: "completed",
+ updated_from: "user",
+ },
+ },
+ // App-requested update to the 2.1 version.
+ {
+ method,
+ object,
+ extra: { ...baseExtra, addon_id, step: "started", updated_from: "app" },
+ },
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ step: "download_started",
+ updated_from: "app",
+ },
+ },
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ step: "download_completed",
+ updated_from: "app",
+ },
+ },
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ step: "completed",
+ updated_from: "app",
+ },
+ },
+ // Broken update to the 2.1.1 version (on ERROR_NETWORK_FAILURE).
+ {
+ method,
+ object,
+ extra: { ...baseExtra, addon_id, step: "started", updated_from: "app" },
+ },
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ step: "download_started",
+ updated_from: "app",
+ },
+ },
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ error: "ERROR_NETWORK_FAILURE",
+ step: "download_failed",
+ updated_from: "app",
+ },
+ },
+ ];
+
+ for (let i = 0; i < updateEvents.length; i++) {
+ if (
+ ["download_completed", "download_failed"].includes(
+ updateEvents[i].extra.step
+ )
+ ) {
+ const download_time = parseInt(updateEvents[i].extra.download_time, 10);
+ ok(
+ !isNaN(download_time) && download_time > 0,
+ `Got a download_time extra in ${updateEvents[i].extra.step} events: ${download_time}`
+ );
+
+ delete updateEvents[i].extra.download_time;
+ }
+
+ Assert.deepEqual(
+ updateEvents[i],
+ expectedUpdateEvents[i],
+ "Got the expected addonsManager.update events"
+ );
+ }
+
+ equal(
+ updateEvents.length,
+ expectedUpdateEvents.length,
+ "Got the expected number of addonsManager.update events"
+ );
+
+ await addon.uninstall();
+
+ // Clear any AMTelemetry events related to the uninstalled extensions.
+ getTelemetryEvents();
+ }
+);
+
+add_task(async function test_no_telemetry_events_on_internal_sources() {
+ assertNoTelemetryEvents();
+
+ const INTERNAL_EXTENSION_ID = "internal@test.extension";
+
+ // Install an extension which has internal as its installation source,
+ // and expect it to do not appear in the collected telemetry events.
+ let internalExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: INTERNAL_EXTENSION_ID } },
+ },
+ useAddonManager: "permanent",
+ amInstallTelemetryInfo: { source: "internal" },
+ });
+
+ await internalExtension.startup();
+
+ const internalAddon = await promiseAddonByID(INTERNAL_EXTENSION_ID);
+
+ info("Disabling the internal extension");
+ const onceInternalAddonDisabled = promiseAddonEvent("onDisabled");
+ internalAddon.disable();
+ await onceInternalAddonDisabled;
+
+ info("Re-enabling the internal extension");
+ const onceInternalAddonStarted = promiseWebExtensionStartup(
+ INTERNAL_EXTENSION_ID
+ );
+ const onceInternalAddonEnabled = promiseAddonEvent("onEnabled");
+ internalAddon.enable();
+ await Promise.all([onceInternalAddonEnabled, onceInternalAddonStarted]);
+
+ await internalExtension.unload();
+
+ assertNoTelemetryEvents();
+});
+
+add_task(async function test_collect_attribution_data_for_amo() {
+ assertNoTelemetryEvents();
+
+ // We pass the `source` value to `amInstallTelemetryInfo` in this test so the
+ // host could be anything in this variable below. Whether to collect
+ // attribution data for AMO is determined by the `source` value, not this
+ // host.
+ const url = "https://addons.mozilla.org/";
+ const addonId = "{28374a9a-676c-5640-bfa7-865cd4686ead}";
+ // This is the SHA256 hash of the `addonId` above.
+ const expectedHashedAddonId =
+ "cf815c9f45c249473d630705f89e64d359737a106a375bbb83be71e6d52dc234";
+
+ for (const { source, sourceURL, expectNoEvent, expectedAmoAttribution } of [
+ // Basic test.
+ {
+ source: "amo",
+ sourceURL: `${url}?utm_content=utm-content-value`,
+ expectedAmoAttribution: {
+ utm_content: "utm-content-value",
+ },
+ },
+ // No UTM parameters will produce an event without any attribution data.
+ {
+ source: "amo",
+ sourceURL: url,
+ expectedAmoAttribution: {},
+ },
+ // Invalid source URLs will produce an event without any attribution data.
+ {
+ source: "amo",
+ sourceURL: "invalid-url",
+ expectedAmoAttribution: {},
+ },
+ // No source URL.
+ {
+ source: "amo",
+ sourceURL: null,
+ expectedAmoAttribution: {},
+ },
+ {
+ source: "amo",
+ sourceURL: undefined,
+ expectedAmoAttribution: {},
+ },
+ // Ignore unsupported/bogus UTM parameters.
+ {
+ source: "amo",
+ sourceURL: [
+ `${url}?utm_content=utm-content-value`,
+ "utm_foo=invalid",
+ "utm_campaign=some-campaign",
+ "utm_term=invalid-too",
+ ].join("&"),
+ expectedAmoAttribution: {
+ utm_campaign: "some-campaign",
+ utm_content: "utm-content-value",
+ },
+ },
+ {
+ source: "amo",
+ sourceURL: `${url}?foo=bar&q=azerty`,
+ expectedAmoAttribution: {},
+ },
+ // Long values are truncated.
+ {
+ source: "amo",
+ sourceURL: `${url}?utm_medium=${"a".repeat(100)}`,
+ expectedAmoAttribution: {
+ utm_medium: "a".repeat(40),
+ },
+ },
+ // Only collect the first value if the parameter is passed more than once.
+ {
+ source: "amo",
+ sourceURL: `${url}?utm_source=first-source&utm_source=second-source`,
+ expectedAmoAttribution: {
+ utm_source: "first-source",
+ },
+ },
+ // When source is "disco", we don't collect the UTM parameters.
+ {
+ source: "disco",
+ sourceURL: `${url}?utm_content=utm-content-value`,
+ expectedAmoAttribution: {},
+ },
+ // When source is neither "amo" nor "disco", we don't collect anything.
+ {
+ source: "link",
+ sourceURL: `${url}?utm_content=utm-content-value`,
+ expectNoEvent: true,
+ },
+ {
+ source: null,
+ sourceURL: `${url}?utm_content=utm-content-value`,
+ expectNoEvent: true,
+ },
+ {
+ source: undefined,
+ sourceURL: `${url}?utm_content=utm-content-value`,
+ expectNoEvent: true,
+ },
+ ]) {
+ const extDefinition = {
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: addonId } },
+ },
+ amInstallTelemetryInfo: {
+ ...FAKE_INSTALL_TELEMETRY_INFO,
+ sourceURL,
+ source,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extDefinition);
+
+ await extension.startup();
+
+ const installStatsEvents = getTelemetryEvents(["install_stats"]);
+
+ if (expectNoEvent === true) {
+ Assert.equal(
+ installStatsEvents.length,
+ 0,
+ "no install_stats event should be recorded"
+ );
+ } else {
+ Assert.equal(
+ installStatsEvents.length,
+ 1,
+ "only one install_stats event should be recorded"
+ );
+
+ const installStatsEvent = installStatsEvents[0];
+
+ Assert.deepEqual(installStatsEvent, {
+ method: "install_stats",
+ object: "extension",
+ value: expectedHashedAddonId,
+ extra: {
+ addon_id: addonId,
+ ...expectedAmoAttribution,
+ },
+ });
+ }
+
+ await extension.upgrade({
+ ...extDefinition,
+ manifest: {
+ ...extDefinition.manifest,
+ version: "2.0",
+ },
+ });
+
+ Assert.deepEqual(
+ getTelemetryEvents(["install_stats"]),
+ [],
+ "no install_stats event should be recorded on addon updates"
+ );
+
+ await extension.unload();
+ }
+
+ getTelemetryEvents();
+});
+
+add_task(async function test_collect_attribution_data_for_amo_with_long_id() {
+ assertNoTelemetryEvents();
+
+ // We pass the `source` value to `installTelemetryInfo` in this test so the
+ // host could be anything in this variable below. Whether to collect
+ // attribution data for AMO is determined by the `source` value, not this
+ // host.
+ const url = "https://addons.mozilla.org/";
+ const addonId = `@${"a".repeat(90)}`;
+ // This is the SHA256 hash of the `addonId` above.
+ const expectedHashedAddonId =
+ "964d902353fc1c127228b66ec8a174c340cb2e02dbb550c6000fb1cd3ca2f489";
+
+ const installTelemetryInfo = {
+ ...FAKE_INSTALL_TELEMETRY_INFO,
+ sourceURL: `${url}?utm_content=utm-content-value`,
+ source: "amo",
+ };
+
+ // We call `recordInstallStatsEvent()` directly because using an add-on ID
+ // longer than 64 chars causes signing issues in tests (because of the
+ // differences between the fake CertDB injected by
+ // `AddonTestUtils.overrideCertDB()` and the real one).
+ const fakeAddonInstall = {
+ addon: { id: addonId },
+ type: "extension",
+ installTelemetryInfo,
+ hashedAddonId: expectedHashedAddonId,
+ };
+ AMTelemetry.recordInstallStatsEvent(fakeAddonInstall);
+
+ const installStatsEvents = getTelemetryEvents(["install_stats"]);
+ Assert.equal(
+ installStatsEvents.length,
+ 1,
+ "only one install_stats event should be recorded"
+ );
+
+ const installStatsEvent = installStatsEvents[0];
+
+ Assert.deepEqual(installStatsEvent, {
+ method: "install_stats",
+ object: "extension",
+ value: expectedHashedAddonId,
+ extra: {
+ addon_id: AMTelemetry.getTrimmedString(addonId),
+ utm_content: "utm-content-value",
+ },
+ });
+});
+
+add_task(async function test_collect_attribution_data_for_rtamo() {
+ assertNoTelemetryEvents();
+
+ const url = "https://addons.mozilla.org/";
+ const addonId = "{28374a9a-676c-5640-bfa7-865cd4686ead}";
+ // This is the SHA256 hash of the `addonId` above.
+ const expectedHashedAddonId =
+ "cf815c9f45c249473d630705f89e64d359737a106a375bbb83be71e6d52dc234";
+
+ // We simulate what is happening in:
+ // https://searchfox.org/mozilla-central/rev/d2786d9a6af7507bc3443023f0495b36b7e84c2d/browser/components/newtab/content-src/lib/aboutwelcome-utils.js#91
+ const installTelemetryInfo = {
+ ...FAKE_INSTALL_TELEMETRY_INFO,
+ sourceURL: `${url}?utm_content=utm-content-value`,
+ source: "rtamo",
+ };
+
+ const fakeAddonInstall = {
+ addon: { id: addonId },
+ type: "extension",
+ installTelemetryInfo,
+ hashedAddonId: expectedHashedAddonId,
+ };
+ AMTelemetry.recordInstallStatsEvent(fakeAddonInstall);
+
+ const installStatsEvents = getTelemetryEvents(["install_stats"]);
+ Assert.equal(
+ installStatsEvents.length,
+ 1,
+ "only one install_stats event should be recorded"
+ );
+
+ const installStatsEvent = installStatsEvents[0];
+
+ Assert.deepEqual(installStatsEvent, {
+ method: "install_stats",
+ object: "extension",
+ value: expectedHashedAddonId,
+ extra: {
+ addon_id: AMTelemetry.getTrimmedString(addonId),
+ },
+ });
+});
+
+add_task(async function teardown() {
+ await TelemetryController.testShutdown();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_amo_stats_telemetry.js b/toolkit/mozapps/extensions/test/xpcshell/test_amo_stats_telemetry.js
new file mode 100644
index 0000000000..8176c5881a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_amo_stats_telemetry.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryController } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryController.sys.mjs"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+add_task(async function setup() {
+ // We need to set this pref to `true` in order to collect add-ons Telemetry
+ // data (which is considered extended data and disabled in CI).
+ const overridePreReleasePref = "toolkit.telemetry.testing.overridePreRelease";
+ let oldOverrideValue = Services.prefs.getBoolPref(
+ overridePreReleasePref,
+ false
+ );
+ Services.prefs.setBoolPref(overridePreReleasePref, true);
+ registerCleanupFunction(() => {
+ Services.prefs.setBoolPref(overridePreReleasePref, oldOverrideValue);
+ });
+
+ await TelemetryController.testSetup();
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_ping_payload_and_environment() {
+ const extensions = [
+ {
+ id: "addons-telemetry@test-extension-1",
+ name: "some extension 1",
+ version: "1.2.3",
+ },
+ {
+ id: "addons-telemetry@test-extension-2",
+ name: "some extension 2",
+ version: "0.1",
+ },
+ ];
+
+ // Install some extensions.
+ const installedExtensions = [];
+ for (const { id, name, version } of extensions) {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name,
+ version,
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "permanent",
+ });
+ installedExtensions.push(extension);
+
+ await extension.startup();
+ }
+
+ const { payload, environment } = TelemetryController.getCurrentPingData();
+
+ // Important: `payload.info.addons` is being used for AMO usage stats.
+ Assert.ok("addons" in payload.info, "payload.info.addons is defined");
+ Assert.equal(
+ payload.info.addons,
+ extensions
+ .map(({ id, version }) => `${encodeURIComponent(id)}:${version}`)
+ .join(",")
+ );
+ Assert.ok(
+ "XPI" in payload.addonDetails,
+ "payload.addonDetails.XPI is defined"
+ );
+ for (const { id, name } of extensions) {
+ Assert.ok(id in payload.addonDetails.XPI);
+ Assert.equal(payload.addonDetails.XPI[id].name, name);
+ }
+
+ const { addons } = environment;
+ Assert.ok(
+ "activeAddons" in addons,
+ "environment.addons.activeAddons is defined"
+ );
+ Assert.ok("theme" in addons, "environment.addons.theme is defined");
+ for (const { id } of extensions) {
+ Assert.ok(id in environment.addons.activeAddons);
+ }
+
+ for (const extension of installedExtensions) {
+ await extension.unload();
+ }
+});
+
+add_task(async function cleanup() {
+ await TelemetryController.testShutdown();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_aom_startup.js b/toolkit/mozapps/extensions/test/xpcshell/test_aom_startup.js
new file mode 100644
index 0000000000..f59a14dbbc
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_aom_startup.js
@@ -0,0 +1,189 @@
+"use strict";
+
+const { JSONFile } = ChromeUtils.importESModule(
+ "resource://gre/modules/JSONFile.sys.mjs"
+);
+
+const aomStartup = Cc["@mozilla.org/addons/addon-manager-startup;1"].getService(
+ Ci.amIAddonManagerStartup
+);
+
+const gProfDir = do_get_profile();
+
+Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION
+);
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42.0", "42.0");
+
+const DUMMY_ID = "@dummy";
+const DUMMY_ADDONS = {
+ addons: {
+ "@dummy": {
+ lastModifiedTime: 1337,
+ rootURI: "resource:///modules/themes/dummy/",
+ version: "1",
+ },
+ },
+};
+
+const TEST_ADDON_ID = "@test-theme";
+const TEST_THEME = {
+ lastModifiedTime: 1337,
+ rootURI: "resource:///modules/themes/test/",
+ version: "1",
+};
+
+const TEST_ADDONS = {
+ addons: {
+ "@test-theme": TEST_THEME,
+ },
+};
+
+// Utility to write out various addonStartup.json files.
+async function writeAOMStartupData(data) {
+ let jsonFile = new JSONFile({
+ path: PathUtils.join(gProfDir.path, "addonStartup.json.lz4"),
+ compression: "lz4",
+ });
+ jsonFile.data = data;
+ await jsonFile._save();
+ return aomStartup.readStartupData();
+}
+
+// This tests that any buitin removed from the build will
+// get removed from the state data.
+add_task(async function test_startup_missing_builtin() {
+ let startupData = await writeAOMStartupData({
+ "app-builtin": DUMMY_ADDONS,
+ });
+ Assert.ok(
+ !!startupData["app-builtin"].addons[DUMMY_ID],
+ "non-existant addon is in startup data"
+ );
+
+ await AddonTestUtils.promiseStartupManager();
+ await AddonTestUtils.promiseShutdownManager();
+
+ // This data is flushed on shutdown, so we check it after shutdown.
+ startupData = aomStartup.readStartupData();
+ Assert.equal(
+ startupData["app-builtin"].addons[DUMMY_ID],
+ undefined,
+ "non-existant addon is removed from startup data"
+ );
+});
+
+// This test verifies that a builtin installed prior to the
+// second scan is not overwritten by old state data during
+// the scan.
+add_task(async function test_startup_default_theme_moved() {
+ let startupData = await writeAOMStartupData({
+ "app-profile": DUMMY_ADDONS,
+ "app-builtin": TEST_ADDONS,
+ });
+ Assert.ok(
+ !!startupData["app-profile"].addons[DUMMY_ID],
+ "non-existant addon is in startup data"
+ );
+ Assert.ok(
+ !!startupData["app-builtin"].addons[TEST_ADDON_ID],
+ "test addon is in startup data"
+ );
+
+ let themeDef = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: TEST_ADDON_ID } },
+ version: "1.1",
+ theme: {},
+ },
+ };
+
+ await setupBuiltinExtension(themeDef, "second-loc");
+ await AddonTestUtils.promiseStartupManager("44");
+ await AddonManager.maybeInstallBuiltinAddon(
+ TEST_ADDON_ID,
+ "1.1",
+ "resource://second-loc/"
+ );
+ await AddonManagerPrivate.getNewSideloads();
+
+ let addon = await AddonManager.getAddonByID(TEST_ADDON_ID);
+ Assert.ok(!addon.foreignInstall, "addon was not marked as a foreign install");
+ Assert.equal(addon.version, "1.1", "addon version is correct");
+
+ await AddonTestUtils.promiseShutdownManager();
+
+ // This data is flushed on shutdown, so we check it after shutdown.
+ startupData = aomStartup.readStartupData();
+ Assert.equal(
+ startupData["app-builtin"].addons[TEST_ADDON_ID].version,
+ "1.1",
+ "startup data is correct in cache"
+ );
+ Assert.equal(
+ startupData["app-builtin"].addons[DUMMY_ID],
+ undefined,
+ "non-existant addon is removed from startup data"
+ );
+});
+
+// This test verifies that a builtin addon being updated
+// is not marked as a foreignInstall.
+add_task(async function test_startup_builtin_not_foreign() {
+ let startupData = await writeAOMStartupData({
+ "app-profile": DUMMY_ADDONS,
+ "app-builtin": {
+ addons: {
+ "@test-theme": {
+ ...TEST_THEME,
+ rootURI: "resource://second-loc/",
+ },
+ },
+ },
+ });
+ Assert.ok(
+ !!startupData["app-profile"].addons[DUMMY_ID],
+ "non-existant addon is in startup data"
+ );
+ Assert.ok(
+ !!startupData["app-builtin"].addons[TEST_ADDON_ID],
+ "test addon is in startup data"
+ );
+
+ let themeDef = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: TEST_ADDON_ID } },
+ version: "1.1",
+ theme: {},
+ },
+ };
+
+ await setupBuiltinExtension(themeDef, "second-loc");
+ await AddonTestUtils.promiseStartupManager("43");
+ await AddonManager.maybeInstallBuiltinAddon(
+ TEST_ADDON_ID,
+ "1.1",
+ "resource://second-loc/"
+ );
+ await AddonManagerPrivate.getNewSideloads();
+
+ let addon = await AddonManager.getAddonByID(TEST_ADDON_ID);
+ Assert.ok(!addon.foreignInstall, "addon was not marked as a foreign install");
+ Assert.equal(addon.version, "1.1", "addon version is correct");
+
+ await AddonTestUtils.promiseShutdownManager();
+
+ // This data is flushed on shutdown, so we check it after shutdown.
+ startupData = aomStartup.readStartupData();
+ Assert.equal(
+ startupData["app-builtin"].addons[TEST_ADDON_ID].version,
+ "1.1",
+ "startup data is correct in cache"
+ );
+ Assert.equal(
+ startupData["app-builtin"].addons[DUMMY_ID],
+ undefined,
+ "non-existant addon is removed from startup data"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_bad_json.js b/toolkit/mozapps/extensions/test/xpcshell/test_bad_json.js
new file mode 100644
index 0000000000..ec6c30bd52
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_bad_json.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that we rebuild the database correctly if it contains
+// JSON data that parses correctly but doesn't contain required fields
+
+add_task(async function () {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ await promiseStartupManager();
+
+ const ID = "addon@tests.mozilla.org";
+ await promiseInstallWebExtension({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ await promiseShutdownManager();
+
+ // First startup/shutdown finished
+ // Replace the JSON store with something bogus
+ await IOUtils.writeJSON(gExtensionsJSON.path, {
+ not: "what we expect to find",
+ });
+
+ await promiseStartupManager();
+ // Retrieve an addon to force the database to rebuild
+ let addon = await AddonManager.getAddonByID(ID);
+
+ Assert.equal(addon.id, ID);
+
+ await promiseShutdownManager();
+
+ // Make sure our JSON database has schemaVersion and our installed extension
+ let data = await IOUtils.readJSON(gExtensionsJSON.path);
+ Assert.ok("schemaVersion" in data);
+ Assert.equal(data.addons[0].id, ID);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_badschema.js b/toolkit/mozapps/extensions/test/xpcshell/test_badschema.js
new file mode 100644
index 0000000000..0fc810bf91
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_badschema.js
@@ -0,0 +1,237 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Checks that we rebuild something sensible from a database with a bad schema
+
+var testserver = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+
+// register files with server
+testserver.registerDirectory("/data/", do_get_file("data"));
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+const ADDONS = {
+ "addon1@tests.mozilla.org": {
+ manifest: {
+ name: "Test 1",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon1@tests.mozilla.org",
+ strict_min_version: "2",
+ strict_max_version: "2",
+ },
+ },
+ },
+ desiredValues: {
+ isActive: true,
+ userDisabled: false,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ "addon2@tests.mozilla.org": {
+ manifest: {
+ name: "Test 2",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon2@tests.mozilla.org",
+ },
+ },
+ },
+ initialState: {
+ userDisabled: true,
+ },
+ desiredValues: {
+ isActive: false,
+ userDisabled: true,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ "addon3@tests.mozilla.org": {
+ manifest: {
+ name: "Test 3",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon3@tests.mozilla.org",
+ update_url: "http://example.com/data/test_corrupt.json",
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ },
+ findUpdates: true,
+ desiredValues: {
+ isActive: true,
+ userDisabled: false,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ "addon4@tests.mozilla.org": {
+ manifest: {
+ name: "Test 4",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon4@tests.mozilla.org",
+ update_url: "http://example.com/data/test_corrupt.json",
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ },
+ initialState: {
+ userDisabled: true,
+ },
+ findUpdates: true,
+ desiredValues: {
+ isActive: false,
+ userDisabled: true,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ "addon5@tests.mozilla.org": {
+ manifest: {
+ name: "Test 5",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon5@tests.mozilla.org",
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ },
+ desiredValues: {
+ isActive: false,
+ userDisabled: false,
+ appDisabled: true,
+ pendingOperations: 0,
+ },
+ },
+
+ "theme1@tests.mozilla.org": {
+ manifest: {
+ manifest_version: 2,
+ name: "Theme 1",
+ version: "1.0",
+ theme: { images: { theme_frame: "example.png" } },
+ browser_specific_settings: {
+ gecko: {
+ id: "theme1@tests.mozilla.org",
+ },
+ },
+ },
+ desiredValues: {
+ isActive: false,
+ userDisabled: true,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ "theme2@tests.mozilla.org": {
+ manifest: {
+ manifest_version: 2,
+ name: "Theme 2",
+ version: "1.0",
+ theme: { images: { theme_frame: "example.png" } },
+ browser_specific_settings: {
+ gecko: {
+ id: "theme2@tests.mozilla.org",
+ },
+ },
+ },
+ initialState: {
+ userDisabled: false,
+ },
+ desiredValues: {
+ isActive: true,
+ userDisabled: false,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+};
+
+const IDS = Object.keys(ADDONS);
+
+function promiseUpdates(addon) {
+ return new Promise(resolve => {
+ addon.findUpdates(
+ { onUpdateFinished: resolve },
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ });
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2", "2");
+
+ for (let addon of Object.values(ADDONS)) {
+ let webext = createTempWebExtensionFile({ manifest: addon.manifest });
+ await AddonTestUtils.manuallyInstall(webext);
+ }
+
+ await promiseStartupManager();
+
+ let addons = await getAddons(IDS);
+ for (let [id, addon] of Object.entries(ADDONS)) {
+ if (addon.initialState) {
+ await setInitialState(addons.get(id), addon.initialState);
+ }
+ if (addon.findUpdates) {
+ await promiseUpdates(addons.get(id));
+ }
+ }
+});
+
+add_task(async function test_after_restart() {
+ await promiseRestartManager();
+
+ info("Test add-on state after restart");
+ let addons = await getAddons(IDS);
+ for (let [id, addon] of Object.entries(ADDONS)) {
+ checkAddon(id, addons.get(id), addon.desiredValues);
+ }
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_after_schema_version_change() {
+ // After restarting the database won't be open so we can alter
+ // the schema
+ await changeXPIDBVersion(100);
+
+ await promiseStartupManager();
+
+ info("Test add-on state after schema version change");
+ let addons = await getAddons(IDS);
+ for (let [id, addon] of Object.entries(ADDONS)) {
+ checkAddon(id, addons.get(id), addon.desiredValues);
+ }
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_after_second_restart() {
+ await promiseStartupManager();
+
+ info("Test add-on state after second restart");
+ let addons = await getAddons(IDS);
+ for (let [id, addon] of Object.entries(ADDONS)) {
+ checkAddon(id, addons.get(id), addon.desiredValues);
+ }
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_bug587088.js b/toolkit/mozapps/extensions/test/xpcshell/test_bug587088.js
new file mode 100644
index 0000000000..261ef61807
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_bug587088.js
@@ -0,0 +1,194 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This test is currently dead code.
+/* eslint-disable */
+
+// Tests that trying to upgrade or uninstall an extension that has a file locked
+// will roll back the upgrade or uninstall and retry at the next restart
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const ADDONS = [
+ {
+ "install.rdf": {
+ id: "addon1@tests.mozilla.org",
+ version: "1.0",
+ name: "Bug 587088 Test",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "1",
+ },
+ ],
+ },
+ testfile: "",
+ testfile1: "",
+ },
+
+ {
+ "install.rdf": {
+ id: "addon1@tests.mozilla.org",
+ version: "2.0",
+ name: "Bug 587088 Test",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "1",
+ },
+ ],
+ },
+ testfile: "",
+ testfile2: "",
+ },
+];
+
+add_task(async function setup() {
+ // This is only an issue on windows.
+ if (!("nsIWindowsRegKey" in Ci)) return;
+
+ do_test_pending();
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+});
+
+function check_addon(aAddon, aVersion) {
+ Assert.notEqual(aAddon, null);
+ Assert.equal(aAddon.version, aVersion);
+ Assert.ok(aAddon.isActive);
+ Assert.ok(isExtensionInAddonsList(profileDir, aAddon.id));
+
+ Assert.ok(aAddon.hasResource("testfile"));
+ if (aVersion == "1.0") {
+ Assert.ok(aAddon.hasResource("testfile1"));
+ Assert.ok(!aAddon.hasResource("testfile2"));
+ } else {
+ Assert.ok(!aAddon.hasResource("testfile1"));
+ Assert.ok(aAddon.hasResource("testfile2"));
+ }
+
+ Assert.equal(aAddon.pendingOperations, AddonManager.PENDING_NONE);
+}
+
+function check_addon_upgrading(aAddon) {
+ Assert.notEqual(aAddon, null);
+ Assert.equal(aAddon.version, "1.0");
+ Assert.ok(aAddon.isActive);
+ Assert.ok(isExtensionInAddonsList(profileDir, aAddon.id));
+
+ Assert.ok(aAddon.hasResource("testfile"));
+ Assert.ok(aAddon.hasResource("testfile1"));
+ Assert.ok(!aAddon.hasResource("testfile2"));
+
+ Assert.equal(aAddon.pendingOperations, AddonManager.PENDING_UPGRADE);
+
+ Assert.equal(aAddon.pendingUpgrade.version, "2.0");
+}
+
+function check_addon_uninstalling(aAddon, aAfterRestart) {
+ Assert.notEqual(aAddon, null);
+ Assert.equal(aAddon.version, "1.0");
+
+ if (aAfterRestart) {
+ Assert.ok(!aAddon.isActive);
+ Assert.ok(!isExtensionInAddonsList(profileDir, aAddon.id));
+ } else {
+ Assert.ok(aAddon.isActive);
+ Assert.ok(isExtensionInAddonsList(profileDir, aAddon.id));
+ }
+
+ Assert.ok(aAddon.hasResource("testfile"));
+ Assert.ok(aAddon.hasResource("testfile1"));
+ Assert.ok(!aAddon.hasResource("testfile2"));
+
+ Assert.equal(aAddon.pendingOperations, AddonManager.PENDING_UNINSTALL);
+}
+
+add_task(async function test_1() {
+ await AddonTestUtils.promiseInstallXPI(ADDONS[0]);
+
+ await promiseRestartManager();
+
+ let a1 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ check_addon(a1, "1.0");
+
+ // Lock either install.rdf for unpacked add-ons or the xpi for packed add-ons.
+ let uri = a1.getResourceURI("install.rdf");
+ if (uri instanceof Ci.nsIJARURI) uri = uri.JARFile;
+
+ let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fstream.init(uri.QueryInterface(Ci.nsIFileURL).file, -1, 0, 0);
+
+ await AddonTestUtils.promiseInstallXPI(ADDONS[1]);
+
+ check_addon_upgrading(a1);
+
+ await promiseRestartManager();
+
+ let a1_2 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ check_addon_upgrading(a1_2);
+
+ await promiseRestartManager();
+
+ let a1_3 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ check_addon_upgrading(a1_3);
+
+ fstream.close();
+
+ await promiseRestartManager();
+
+ let a1_4 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ check_addon(a1_4, "2.0");
+
+ await a1_4.uninstall();
+});
+
+// Test that a failed uninstall gets rolled back
+add_task(async function test_2() {
+ await promiseRestartManager();
+
+ await AddonTestUtils.promiseInstallXPI(ADDONS[0]);
+ await promiseRestartManager();
+
+ let a1 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ check_addon(a1, "1.0");
+
+ // Lock either install.rdf for unpacked add-ons or the xpi for packed add-ons.
+ let uri = a1.getResourceURI("install.rdf");
+ if (uri instanceof Ci.nsIJARURI) uri = uri.JARFile;
+
+ let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fstream.init(uri.QueryInterface(Ci.nsIFileURL).file, -1, 0, 0);
+
+ await a1.uninstall();
+
+ check_addon_uninstalling(a1);
+
+ await promiseRestartManager();
+
+ let a1_2 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ check_addon_uninstalling(a1_2, true);
+
+ await promiseRestartManager();
+
+ let a1_3 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ check_addon_uninstalling(a1_3, true);
+
+ fstream.close();
+
+ await promiseRestartManager();
+
+ let a1_4 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ Assert.equal(a1_4, null);
+ var dir = profileDir.clone();
+ dir.append(do_get_expected_addon_name("addon1@tests.mozilla.org"));
+ Assert.ok(!dir.exists());
+ Assert.ok(!isExtensionInAddonsList(profileDir, "addon1@tests.mozilla.org"));
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_builtin_location.js b/toolkit/mozapps/extensions/test/xpcshell/test_builtin_location.js
new file mode 100644
index 0000000000..30801e9f72
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_builtin_location.js
@@ -0,0 +1,149 @@
+"use strict";
+
+/* globals browser */
+let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION;
+Services.prefs.setIntPref("extensions.enabledScopes", scopes);
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1"
+);
+
+async function getWrapper(id, hidden) {
+ let wrapper = await installBuiltinExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ hidden,
+ },
+ background() {
+ browser.test.sendMessage("started");
+ },
+ });
+ await wrapper.awaitMessage("started");
+ return wrapper;
+}
+
+// Tests installing an extension from the built-in location.
+add_task(async function test_builtin_location() {
+ let id = "builtin@tests.mozilla.org";
+ await AddonTestUtils.promiseStartupManager();
+ let wrapper = await getWrapper(id);
+
+ let addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+ ok(addon.isPrivileged, "Addon is privileged");
+ ok(wrapper.extension.isAppProvided, "Addon is app provided");
+ ok(!addon.hidden, "Addon is not hidden");
+
+ // Built-in extensions are not checked against the blocklist,
+ // so we shouldn't have loaded it.
+ ok(!Services.blocklist.isLoaded, "Blocklist hasn't been loaded");
+
+ // After a restart, the extension should start up normally.
+ await promiseRestartManager();
+ await wrapper.awaitStartup();
+ await wrapper.awaitMessage("started");
+ ok(true, "Extension in built-in location ran after restart");
+
+ addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+
+ // After a restart that causes a database rebuild, it should still work
+ await promiseRestartManager("2");
+ await wrapper.awaitStartup();
+ await wrapper.awaitMessage("started");
+ ok(true, "Extension in built-in location ran after restart");
+
+ addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+
+ // After a restart that changes the schema version, it should still work
+ await promiseShutdownManager();
+ Services.prefs.setIntPref("extensions.databaseSchema", 0);
+ await promiseStartupManager();
+
+ await wrapper.awaitStartup();
+ await wrapper.awaitMessage("started");
+ ok(true, "Extension in built-in location ran after restart");
+
+ addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+
+ await wrapper.unload();
+
+ addon = await promiseAddonByID(id);
+ equal(addon, null, "Addon is gone after uninstall");
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+// Tests installing a hidden extension from the built-in location.
+add_task(async function test_builtin_location_hidden() {
+ let id = "hidden@tests.mozilla.org";
+ await AddonTestUtils.promiseStartupManager();
+ let wrapper = await getWrapper(id, true);
+
+ let addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+ ok(addon.isPrivileged, "Addon is privileged");
+ ok(wrapper.extension.isAppProvided, "Addon is app provided");
+ ok(addon.hidden, "Addon is hidden");
+
+ await wrapper.unload();
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+// Tests updates to builtin extensions
+add_task(async function test_builtin_update() {
+ let id = "bleah@tests.mozilla.org";
+ await AddonTestUtils.promiseStartupManager();
+
+ let wrapper = await installBuiltinExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ version: "1.0",
+ },
+ background() {
+ browser.test.sendMessage("started");
+ },
+ });
+ await wrapper.awaitMessage("started");
+
+ await AddonTestUtils.promiseShutdownManager();
+
+ // Change the built-in
+ await setupBuiltinExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ version: "2.0",
+ },
+ background() {
+ browser.test.sendMessage("started");
+ },
+ });
+
+ let updateReason;
+ AddonTestUtils.on("bootstrap-method", (method, params, reason) => {
+ updateReason = reason;
+ });
+
+ // Re-start, with a new app version
+ await AddonTestUtils.promiseStartupManager("3");
+
+ await wrapper.awaitMessage("started");
+
+ equal(
+ updateReason,
+ BOOTSTRAP_REASONS.ADODN_UPGRADE,
+ "Builtin addon's bootstrap update() method was called at startup"
+ );
+
+ await wrapper.unload();
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_cacheflush.js b/toolkit/mozapps/extensions/test/xpcshell/test_cacheflush.js
new file mode 100644
index 0000000000..f6039b29bf
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_cacheflush.js
@@ -0,0 +1,86 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that flushing the zipreader cache happens when appropriate
+
+var gExpectedFile = null;
+var gCacheFlushCount = 0;
+
+var CacheFlushObserver = {
+ observe(aSubject, aTopic, aData) {
+ if (aTopic != "flush-cache-entry") {
+ return;
+ }
+
+ // Ignore flushes from the fake cert DB or extension-process-script
+ if (aData == "cert-override" || aSubject == null) {
+ return;
+ }
+
+ if (!gExpectedFile) {
+ return;
+ }
+ ok(aSubject instanceof Ci.nsIFile);
+ equal(aSubject.path, gExpectedFile.path);
+ gCacheFlushCount++;
+ },
+};
+
+add_task(async function setup() {
+ Services.obs.addObserver(CacheFlushObserver, "flush-cache-entry");
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "2");
+
+ await promiseStartupManager();
+});
+
+// Tests that the cache is flushed when installing a restartless add-on
+add_task(async function test_flush_restartless_install() {
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ name: "Cache Flush Test",
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: "addon2@tests.mozilla.org" } },
+ },
+ });
+
+ let install = await AddonManager.getInstallForFile(xpi);
+
+ await new Promise(resolve => {
+ install.addListener({
+ onInstallStarted() {
+ // We should flush the staged XPI when completing the install
+ gExpectedFile = gProfD.clone();
+ gExpectedFile.append("extensions");
+ gExpectedFile.append("staged");
+ gExpectedFile.append("addon2@tests.mozilla.org.xpi");
+ },
+
+ onInstallEnded() {
+ equal(gCacheFlushCount, 1);
+ gExpectedFile = null;
+ gCacheFlushCount = 0;
+
+ resolve();
+ },
+ });
+
+ install.install();
+ });
+});
+
+// Tests that the cache is flushed when uninstalling a restartless add-on
+add_task(async function test_flush_uninstall() {
+ let addon = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+
+ // We should flush the installed XPI when uninstalling
+ gExpectedFile = gProfD.clone();
+ gExpectedFile.append("extensions");
+ gExpectedFile.append("addon2@tests.mozilla.org.xpi");
+
+ await addon.uninstall();
+
+ ok(gCacheFlushCount >= 1);
+ gExpectedFile = null;
+ gCacheFlushCount = 0;
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_childprocess.js b/toolkit/mozapps/extensions/test/xpcshell/test_childprocess.js
new file mode 100644
index 0000000000..d7661d52ad
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_childprocess.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that the AddonManager refuses to load in child processes.
+
+// NOTE: This test does NOT load head_addons.js, because that would indirectly
+// load AddonManager.sys.mjs. In this test, we want to be the first to load the
+// AddonManager module to verify that it cannot be loaded in child processes.
+
+const { updateAppInfo } = ChromeUtils.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+
+function run_test() {
+ updateAppInfo();
+ Services.appinfo.processType = Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT;
+ try {
+ ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs");
+ do_throw("AddonManager should have refused to load");
+ } catch (ex) {
+ info(ex.message);
+ Assert.ok(!!ex.message);
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_colorways_builtin_theme_upgrades.js b/toolkit/mozapps/extensions/test/xpcshell/test_colorways_builtin_theme_upgrades.js
new file mode 100644
index 0000000000..6449481f67
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_colorways_builtin_theme_upgrades.js
@@ -0,0 +1,582 @@
+"use strict";
+
+const { BuiltInThemes } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemes.sys.mjs"
+);
+const { BuiltInThemeConfig } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemeConfig.sys.mjs"
+);
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+// Enable SCOPE_APPLICATION for builtin testing.
+let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION;
+Services.prefs.setIntPref("extensions.enabledScopes", scopes);
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+const ADDON_ID = "mock-colorway@mozilla.org";
+const ADDON_ID_RETAINED = "mock-disabled-retained-colorway@mozilla.org";
+const ADDON_ID_NOT_RETAINED = "mock-disabled-not-retained-colorway@mozilla.org";
+const DEFAULT_THEME_ID = "default-theme@mozilla.org";
+const NOT_MIGRATED_THEME = "mock-not-migrated-theme@mozilla.org";
+
+const RETAINED_THEMES_PREF = "browser.theme.retainedExpiredThemes";
+const COLORWAY_MIGRATION_PREF = "browser.theme.colorway-migration";
+
+const ICON_SVG = `
+ <svg width="63" height="62" viewBox="0 0 63 62" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <circle cx="31.5" cy="31" r="31" fill="url(#paint0_linear)"/>
+ <defs>
+ <linearGradient id="paint0_linear" x1="44.4829" y1="19" x2="10.4829" y2="53" gradientUnits="userSpaceOnUse">
+ <stop stop-color="hsl(147, 94%, 25%)"/>
+ <stop offset="1" stop-color="hsl(146, 38%, 49%)"/>
+ </linearGradient>
+ </defs>
+ </svg>
+`;
+const createMockThemeManifest = (id, version) => ({
+ name: "A mock colorway theme",
+ author: "Mozilla",
+ version,
+ icons: { 32: "icon.svg" },
+ theme: {
+ colors: {
+ toolbar: "red",
+ },
+ },
+ browser_specific_settings: {
+ gecko: { id },
+ },
+});
+
+let server = createHttpServer();
+
+const SERVER_BASE_URL = `http://localhost:${server.identity.primaryPort}`;
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+Services.prefs.setCharPref(
+ "extensions.update.background.url",
+ `${SERVER_BASE_URL}/upgrade.json`
+);
+
+AddonTestUtils.registerJSON(server, "/upgrade.json", {
+ addons: {
+ [ADDON_ID]: {
+ updates: [
+ {
+ version: "2.0.0",
+ update_link: `${SERVER_BASE_URL}/${ADDON_ID}.xpi`,
+ },
+ ],
+ },
+ [ADDON_ID_RETAINED]: {
+ updates: [
+ {
+ version: "3.0.0",
+ update_link: `${SERVER_BASE_URL}/${ADDON_ID_RETAINED}.xpi`,
+ },
+ ],
+ },
+ // We list the test extension with addon id ADDON_ID_NOT_RETAINED here,
+ // but the xpi file doesn't exist because we expect that we wouldn't
+ // be checking this extension for updates, and that expected behavior
+ // regresses, the test would fail either for the explicit assertion
+ // (checking that we don't find an update) or because we would be trying
+ // to download a file from an url that isn't going to be handled.
+ [ADDON_ID_NOT_RETAINED]: {
+ updates: [
+ {
+ version: "4.0.0",
+ update_link: `${SERVER_BASE_URL}/non-existing.xpi`,
+ },
+ ],
+ },
+ },
+});
+
+function createWebExtensionFile(id, version) {
+ return AddonTestUtils.createTempWebExtensionFile({
+ files: { "icon.svg": ICON_SVG },
+ manifest: createMockThemeManifest(id, version),
+ });
+}
+
+let xpiUpdate = createWebExtensionFile(ADDON_ID, "2.0.0");
+let retainedThemeUpdate = createWebExtensionFile(ADDON_ID_RETAINED, "3.0.0");
+
+server.registerFile(`/${ADDON_ID}.xpi`, xpiUpdate);
+server.registerFile(`/${ADDON_ID_RETAINED}.xpi`, retainedThemeUpdate);
+
+function assertAddonWrapperProperties(
+ addonWrapper,
+ { id, version, isBuiltin, type, isBuiltinColorwayTheme, scope }
+) {
+ Assert.deepEqual(
+ {
+ id: addonWrapper.id,
+ version: addonWrapper.version,
+ type: addonWrapper.type,
+ scope: addonWrapper.scope,
+ isBuiltin: addonWrapper.isBuiltin,
+ isBuiltinColorwayTheme: addonWrapper.isBuiltinColorwayTheme,
+ },
+ {
+ id,
+ version,
+ type,
+ scope,
+ isBuiltin,
+ isBuiltinColorwayTheme,
+ },
+ `Got expected properties on addon wrapper for "${id}"`
+ );
+}
+
+function assertAddonCanUpgrade(addonWrapper, canUpgrade) {
+ equal(
+ !!(addonWrapper.permissions & AddonManager.PERM_CAN_UPGRADE),
+ canUpgrade,
+ `Expected "${addonWrapper.id}" to ${
+ canUpgrade ? "have" : "not have"
+ } PERM_CAN_UPGRADE AOM permission`
+ );
+}
+
+function assertIsActiveThemeID(addonId) {
+ equal(
+ Services.prefs.getCharPref("extensions.activeThemeID"),
+ addonId,
+ `Expect ${addonId} to be the currently active theme`
+ );
+}
+
+function assertIsExpiredTheme(addonId, expectExpired) {
+ equal(
+ // themeIsExpired returns undefined for themes without an expiry date,
+ // normalized here to always be a boolean.
+ !!BuiltInThemes.themeIsExpired(addonId),
+ expectExpired,
+ `Expect ${addonId} to be recognized as an expired colorway theme`
+ );
+}
+
+function assertIsRetainedExpiredTheme(addonId, expectRetainedExpired) {
+ equal(
+ BuiltInThemes.isRetainedExpiredTheme(addonId),
+ expectRetainedExpired,
+ `Expect ${addonId} to be recognized as a retained expired colorway theme`
+ );
+}
+
+function waitForBootstrapUpdateMethod(addonId, newVersion) {
+ return new Promise(resolve => {
+ function listener(_evt, { method, params }) {
+ if (
+ method === "update" &&
+ params.id === addonId &&
+ params.newVersion === newVersion
+ ) {
+ AddonTestUtils.off("bootstrap-method", listener);
+ info(`Update bootstrap method called for ${addonId} ${newVersion}`);
+ resolve();
+ }
+ }
+ AddonTestUtils.on("bootstrap-method", listener);
+ });
+}
+
+let waitForTemporaryXPIFilesRemoved;
+
+add_setup(async () => {
+ info("Creating BuiltInThemes stubs");
+ const sandbox = sinon.createSandbox();
+ // Restoring the mocked BuiltInThemeConfig doesn't really matter for xpcshell
+ // because each test file will run in its own separate xpcshell instance,
+ // but cleaning it up doesn't harm neither.
+ registerCleanupFunction(() => {
+ info("Restoring BuiltInThemes sandbox for cleanup");
+ sandbox.restore();
+ BuiltInThemes.builtInThemeMap = BuiltInThemeConfig;
+ });
+
+ // Mock BuiltInThemes builtInThemeMap.
+ BuiltInThemes.builtInThemeMap = new Map();
+ sandbox.stub(BuiltInThemes.builtInThemeMap, "get").callsFake(id => {
+ info(`Mock BuiltInthemes.builtInThemeMap.get result for ${id}`);
+ // No theme info is expected to be returned for the default-theme.
+ if (id === DEFAULT_THEME_ID) {
+ return undefined;
+ }
+ if (!id.endsWith("colorway@mozilla.org")) {
+ return BuiltInThemeConfig.get(id);
+ }
+ let mockThemeProperties = {
+ collection: "Mock expired colorway collection",
+ figureUrl: "about:blank",
+ expiry: new Date("1970-01-01"),
+ };
+ return mockThemeProperties;
+ });
+
+ // Start AOM and make sure updates are enabled.
+ await AddonTestUtils.promiseStartupManager();
+ AddonManager.updateEnabled = true;
+
+ // Enable the default theme explicitly (mainly because on DevEdition builds
+ // the dark theme would be the one enabled by default).
+ const defaultTheme = await AddonManager.getAddonByID(DEFAULT_THEME_ID);
+ await defaultTheme.enable();
+ assertIsActiveThemeID(defaultTheme.id);
+
+ const tmpFiles = new Set();
+ const addonInstallListener = {
+ onInstallEnded: function collectTmpFiles(install) {
+ tmpFiles.add(install.file);
+ },
+ };
+ AddonManager.addInstallListener(addonInstallListener);
+ registerCleanupFunction(() => {
+ AddonManager.removeInstallListener(addonInstallListener);
+ });
+
+ // Make sure all the tempfile created for the background updates have
+ // been removed (otherwise AddonTestUtils cleanup function will trigger
+ // intermittent test failures due to unexpected xpi files that may still
+ // be found in the temporary directory).
+ waitForTemporaryXPIFilesRemoved = async () => {
+ info(
+ "Wait for temporary xpi files created by the background updates to have been removed"
+ );
+ const files = Array.from(tmpFiles);
+ tmpFiles.clear();
+ await TestUtils.waitForCondition(async () => {
+ for (const file of files) {
+ if (await file.exists()) {
+ return false;
+ }
+ }
+ return true;
+ }, "Wait for the temporary files created for the background updates to have been removed");
+ };
+});
+
+add_task(
+ {
+ pref_set: [[COLORWAY_MIGRATION_PREF, false]],
+ },
+ async function test_colorways_migration_disabled() {
+ info("Install and activate a colorway built-in test theme");
+
+ await installBuiltinExtension(
+ {
+ manifest: createMockThemeManifest(ADDON_ID, "1.0.0"),
+ },
+ false /* waitForStartup */
+ );
+ const activeTheme = await AddonManager.getAddonByID(ADDON_ID);
+ assertAddonWrapperProperties(activeTheme, {
+ id: ADDON_ID,
+ version: "1.0.0",
+ type: "theme",
+ scope: AddonManager.SCOPE_APPLICATION,
+ isBuiltin: true,
+ isBuiltinColorwayTheme: true,
+ });
+ const promiseThemeEnabled = AddonTestUtils.promiseAddonEvent(
+ "onEnabled",
+ addon => addon.id === ADDON_ID
+ );
+ await activeTheme.enable();
+ await promiseThemeEnabled;
+ ok(activeTheme.isActive, "Expect the colorways theme to be active");
+ assertIsActiveThemeID(activeTheme.id);
+
+ info("Verify that built-in colorway migration is disabled as expected");
+
+ assertAddonCanUpgrade(activeTheme, false);
+
+ const promiseBackgroundUpdatesFound = TestUtils.topicObserved(
+ "addons-background-updates-found"
+ );
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ const [, numUpdatesFound] = await promiseBackgroundUpdatesFound;
+ equal(numUpdatesFound, 0, "Expect no add-on updates to be found");
+
+ await activeTheme.uninstall();
+ }
+);
+
+add_task(
+ {
+ pref_set: [
+ [COLORWAY_MIGRATION_PREF, true],
+ [
+ RETAINED_THEMES_PREF,
+ JSON.stringify([ADDON_ID_RETAINED, NOT_MIGRATED_THEME]),
+ ],
+ ],
+ },
+ async function test_colorways_builtin_upgrade() {
+ info("Verify default theme initially enabled");
+ const defaultTheme = await AddonManager.getAddonByID(DEFAULT_THEME_ID);
+ assertAddonWrapperProperties(defaultTheme, {
+ id: DEFAULT_THEME_ID,
+ version: defaultTheme.version,
+ type: "theme",
+ scope: AddonManager.SCOPE_APPLICATION,
+ isBuiltin: true,
+ isBuiltinColorwayTheme: false,
+ });
+ ok(
+ defaultTheme.isActive,
+ "Expect the default theme to be initially active"
+ );
+ assertIsActiveThemeID(defaultTheme.id);
+
+ info("Install the non retained expired colorway test theme");
+ await installBuiltinExtension(
+ {
+ manifest: createMockThemeManifest(ADDON_ID_NOT_RETAINED, "1.0.0"),
+ },
+ false /* waitForStartup */
+ );
+ const notRetainedTheme = await AddonManager.getAddonByID(
+ ADDON_ID_NOT_RETAINED
+ );
+ assertAddonWrapperProperties(notRetainedTheme, {
+ id: ADDON_ID_NOT_RETAINED,
+ version: "1.0.0",
+ type: "theme",
+ scope: AddonManager.SCOPE_APPLICATION,
+ isBuiltin: true,
+ isBuiltinColorwayTheme: true,
+ });
+
+ info("Install the retained expired colorway test theme");
+ await installBuiltinExtension(
+ {
+ manifest: createMockThemeManifest(ADDON_ID_RETAINED, "1.0.0"),
+ },
+ false /* waitForStartup */
+ );
+ const retainedTheme = await AddonManager.getAddonByID(ADDON_ID_RETAINED);
+ assertAddonWrapperProperties(retainedTheme, {
+ id: ADDON_ID_RETAINED,
+ version: "1.0.0",
+ type: "theme",
+ scope: AddonManager.SCOPE_APPLICATION,
+ isBuiltin: true,
+ isBuiltinColorwayTheme: true,
+ });
+
+ info("Install the active colorway test theme");
+ await installBuiltinExtension(
+ {
+ manifest: createMockThemeManifest(ADDON_ID, "1.0.0"),
+ },
+ false /* waitForStartup */
+ );
+ const activeTheme = await AddonManager.getAddonByID(ADDON_ID);
+ assertAddonWrapperProperties(activeTheme, {
+ id: ADDON_ID,
+ version: "1.0.0",
+ type: "theme",
+ scope: AddonManager.SCOPE_APPLICATION,
+ isBuiltin: true,
+ isBuiltinColorwayTheme: true,
+ });
+ const promiseThemeEnabled = AddonTestUtils.promiseAddonEvent(
+ "onEnabled",
+ addon => addon.id === ADDON_ID
+ );
+ await activeTheme.enable();
+ await promiseThemeEnabled;
+ ok(activeTheme.isActive, "Expect the colorways theme to be active");
+ assertIsActiveThemeID(activeTheme.id);
+
+ info("Verify only active and retained colorways themes can be upgraded");
+ assertIsActiveThemeID(activeTheme.id);
+ assertIsExpiredTheme(activeTheme.id, true);
+ assertIsRetainedExpiredTheme(activeTheme.id, false);
+
+ assertIsExpiredTheme(retainedTheme.id, true);
+ assertIsRetainedExpiredTheme(retainedTheme.id, true);
+
+ assertIsExpiredTheme(notRetainedTheme.id, true);
+ assertIsRetainedExpiredTheme(notRetainedTheme.id, false);
+
+ assertIsExpiredTheme(defaultTheme.id, false);
+ assertIsRetainedExpiredTheme(defaultTheme.id, false);
+
+ assertAddonCanUpgrade(retainedTheme, true);
+ assertAddonCanUpgrade(notRetainedTheme, false);
+ assertAddonCanUpgrade(activeTheme, true);
+ // Make sure a non-colorways built-in theme cannot check for updates.
+ assertAddonCanUpgrade(defaultTheme, false);
+
+ Assert.deepEqual(
+ Services.prefs.getStringPref(RETAINED_THEMES_PREF),
+ JSON.stringify([retainedTheme.id, NOT_MIGRATED_THEME]),
+ `Expect the retained theme id to be listed in the ${RETAINED_THEMES_PREF} pref`
+ );
+
+ const promiseUpdatesInstalled = Promise.all([
+ waitForBootstrapUpdateMethod(ADDON_ID, "2.0.0"),
+ waitForBootstrapUpdateMethod(ADDON_ID_RETAINED, "3.0.0"),
+ ]);
+
+ const promiseInstallsEnded = Promise.all([
+ AddonTestUtils.promiseInstallEvent(
+ "onInstallEnded",
+ addon => addon.id === ADDON_ID
+ ),
+ AddonTestUtils.promiseInstallEvent(
+ "onInstallEnded",
+ addon => addon.id === ADDON_ID_RETAINED
+ ),
+ ]);
+
+ const promiseActiveThemeStartupCompleted =
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID);
+
+ const promiseBackgroundUpdatesFound = TestUtils.topicObserved(
+ "addons-background-updates-found"
+ );
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ const [, numUpdatesFound] = await promiseBackgroundUpdatesFound;
+ equal(numUpdatesFound, 2, "Expect 2 add-on updates to have been found");
+
+ info("Wait for the 2 expected updates to be completed");
+ await promiseUpdatesInstalled;
+
+ const updatedActiveTheme = await AddonManager.getAddonByID(ADDON_ID);
+ assertAddonWrapperProperties(updatedActiveTheme, {
+ id: ADDON_ID,
+ version: "2.0.0",
+ type: "theme",
+ scope: AddonManager.SCOPE_PROFILE,
+ isBuiltin: false,
+ isBuiltinColorwayTheme: false,
+ });
+ // Expect the updated active theme to stay set as the currently active theme.
+ assertIsActiveThemeID(updatedActiveTheme.id);
+
+ info("Verify addon update on disabled builtin colorway theme");
+
+ const updatedRetainedTheme = await AddonManager.getAddonByID(
+ ADDON_ID_RETAINED
+ );
+ assertAddonWrapperProperties(updatedRetainedTheme, {
+ id: ADDON_ID_RETAINED,
+ version: "3.0.0",
+ type: "theme",
+ scope: AddonManager.SCOPE_PROFILE,
+ isBuiltin: false,
+ isBuiltinColorwayTheme: false,
+ });
+ // Expect the updated active theme to stay set as the currently active theme.
+ assertIsActiveThemeID(updatedActiveTheme.id);
+ ok(updatedActiveTheme.isActive, "Expect the colorways theme to be active");
+
+ // We need to wait for the active theme startup otherwise uninstall the active
+ // theme will fail to remove the xpi file because it is stil active while the
+ // test is running on windows builds.
+ info("Wait for the active theme to have been fully loaded");
+ await promiseActiveThemeStartupCompleted;
+
+ await promiseInstallsEnded;
+
+ Assert.deepEqual(
+ Services.prefs.getStringPref(RETAINED_THEMES_PREF),
+ JSON.stringify([NOT_MIGRATED_THEME]),
+ `Expect migrated retained theme to not be listed anymore in the ${RETAINED_THEMES_PREF} pref`
+ );
+
+ info(
+ "uninstall test colorways themes and expect default theme to become active"
+ );
+
+ const promiseUninstalled = Promise.all([
+ AddonTestUtils.promiseAddonEvent(
+ "onUninstalled",
+ addon => addon.id === ADDON_ID
+ ),
+ AddonTestUtils.promiseAddonEvent(
+ "onUninstalled",
+ addon => addon.id === ADDON_ID_RETAINED
+ ),
+ AddonTestUtils.promiseAddonEvent(
+ "onUninstalled",
+ addon => addon.id === ADDON_ID_NOT_RETAINED
+ ),
+ ]);
+
+ const promiseDefaultThemeEnabled =
+ AddonTestUtils.promiseAddonEvent("onEnabled");
+
+ await updatedActiveTheme.uninstall();
+ await updatedRetainedTheme.uninstall();
+ await notRetainedTheme.uninstall();
+
+ await promiseUninstalled;
+
+ info("Wait for the default theme to become active");
+ // Waiting explicitly for the onEnabled addon event prevents a race between
+ // the test task exiting (and the AddonManager being shutdown automatically
+ // as a side effect of that) and the XPIProvider trying to call the addon event
+ // listeners for the default theme being enabled), which would trigger a test
+ // failure after the test is existing.
+ await promiseDefaultThemeEnabled;
+
+ ok(defaultTheme.isActive, "Expect the default theme to be active");
+ assertIsActiveThemeID(defaultTheme.id);
+
+ // Wait for the temporary file to be actually removed, otherwise the hack we use
+ // to mock an AOM restart (which is unloading the related jsm modules) may
+ // affect the successfull removal of the temporary file because some of the
+ // global helpers defined and used inside the XPIProvider may have been gone
+ // already and intermittently trigger unexpected errors.
+ await waitForTemporaryXPIFilesRemoved();
+
+ // Restart the addon manager to confirm that the migrated colorways themes
+ // are still gone after an AOM restart and that the previously installed
+ // builtin hasn't been made implicitly visible again.
+ info(
+ "Verify old builtin colorways are not visible and default-theme still active after AOM restart"
+ );
+ await AddonTestUtils.promiseRestartManager();
+
+ const defaultThemeAfterRestart = await AddonManager.getAddonByID(
+ DEFAULT_THEME_ID
+ );
+ ok(
+ defaultThemeAfterRestart.isActive,
+ "Expect the default theme to be active"
+ );
+
+ equal(
+ (await AddonManager.getAddonByID(ADDON_ID))?.version,
+ undefined,
+ "Expect the active theme addon to not be available anymore after being uninstalled"
+ );
+ equal(
+ (await AddonManager.getAddonByID(ADDON_ID_RETAINED))?.version,
+ undefined,
+ "Expect the retained theme addon to not be available anymore after being uninstalled"
+ );
+ equal(
+ (await AddonManager.getAddonByID(ADDON_ID_NOT_RETAINED))?.version,
+ undefined,
+ "Expect the not retained expired theme addon to not be available anymore after being uninstalled"
+ );
+ }
+);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_cookies.js b/toolkit/mozapps/extensions/test/xpcshell/test_cookies.js
new file mode 100644
index 0000000000..56f745929b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_cookies.js
@@ -0,0 +1,102 @@
+"use strict";
+
+let server = createHttpServer({ hosts: ["example.com"] });
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "45", "45");
+
+Services.prefs.setBoolPref("extensions.getAddons.cache.enabled", true);
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+// Tests that cookies are not sent with background requests.
+add_task(async function test_cookies() {
+ const ID = "bg-cookies@tests.mozilla.org";
+
+ // Add a new handler to the test web server for the given file path.
+ // The handler appends the incoming requests to `results` and replies
+ // with the provided body.
+ function makeHandler(path, results, body) {
+ server.registerPathHandler(path, (request, response) => {
+ results.push(request);
+ response.write(body);
+ });
+ }
+
+ let gets = [];
+ makeHandler("/get", gets, JSON.stringify({ results: [] }));
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, "http://example.com/get");
+
+ let updates = [];
+ makeHandler(
+ "/update",
+ updates,
+ JSON.stringify({
+ addons: {
+ [ID]: {
+ updates: [
+ {
+ version: "2.0",
+ update_link: "http://example.com/update.xpi",
+ applications: {
+ gecko: {},
+ },
+ },
+ ],
+ },
+ },
+ })
+ );
+
+ let xpiFetches = [];
+ makeHandler("/update.xpi", xpiFetches, "");
+
+ const COOKIE = "test";
+ // cookies.add() takes a time in seconds
+ let expiration = Date.now() / 1000 + 60 * 60;
+ Services.cookies.add(
+ "example.com",
+ "/",
+ COOKIE,
+ "testing",
+ false,
+ false,
+ false,
+ expiration,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+
+ await promiseStartupManager();
+
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ update_url: "http://example.com/update",
+ },
+ },
+ },
+ });
+
+ equal(gets.length, 1, "Saw one addon metadata request");
+ equal(gets[0].hasHeader("Cookie"), false, "Metadata request has no cookies");
+
+ await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onDownloadFailed"),
+ AddonManagerPrivate.backgroundUpdateCheck(),
+ ]);
+
+ equal(updates.length, 1, "Saw one update check request");
+ equal(updates[0].hasHeader("Cookie"), false, "Update request has no cookies");
+
+ equal(xpiFetches.length, 1, "Saw one request for updated xpi");
+ equal(
+ xpiFetches[0].hasHeader("Cookie"),
+ false,
+ "Request for updated XPI has no cookies"
+ );
+
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_corrupt.js b/toolkit/mozapps/extensions/test/xpcshell/test_corrupt.js
new file mode 100644
index 0000000000..727c643763
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_corrupt.js
@@ -0,0 +1,216 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Checks that we rebuild something sensible from a corrupt database
+
+// Create and configure the HTTP server.
+var testserver = createHttpServer({ hosts: ["example.com"] });
+
+// register files with server
+testserver.registerDirectory("/data/", do_get_file("data"));
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+const ADDONS = {
+ // Will get a compatibility update and stay enabled
+ "addon3@tests.mozilla.org": {
+ manifest: {
+ name: "Test 3",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon3@tests.mozilla.org",
+ update_url: "http://example.com/data/test_corrupt.json",
+ },
+ },
+ },
+ findUpdates: true,
+ desiredState: {
+ isActive: true,
+ userDisabled: false,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ // Will get a compatibility update and be enabled
+ "addon4@tests.mozilla.org": {
+ manifest: {
+ name: "Test 4",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon4@tests.mozilla.org",
+ update_url: "http://example.com/data/test_corrupt.json",
+ },
+ },
+ },
+ initialState: {
+ userDisabled: true,
+ },
+ findUpdates: true,
+ desiredState: {
+ isActive: false,
+ userDisabled: true,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ "addon5@tests.mozilla.org": {
+ manifest: {
+ name: "Test 5",
+ browser_specific_settings: { gecko: { id: "addon5@tests.mozilla.org" } },
+ },
+ desiredState: {
+ isActive: true,
+ userDisabled: false,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ "addon7@tests.mozilla.org": {
+ manifest: {
+ name: "Test 7",
+ browser_specific_settings: { gecko: { id: "addon7@tests.mozilla.org" } },
+ },
+ initialState: {
+ userDisabled: true,
+ },
+ desiredState: {
+ isActive: false,
+ userDisabled: true,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ // The default theme
+ "theme1@tests.mozilla.org": {
+ manifest: {
+ manifest_version: 2,
+ name: "Theme 1",
+ version: "1.0",
+ theme: { images: { theme_frame: "example.png" } },
+ browser_specific_settings: {
+ gecko: {
+ id: "theme1@tests.mozilla.org",
+ },
+ },
+ },
+ desiredState: {
+ isActive: false,
+ userDisabled: true,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ "theme2@tests.mozilla.org": {
+ manifest: {
+ manifest_version: 2,
+ name: "Theme 2",
+ version: "1.0",
+ theme: { images: { theme_frame: "example.png" } },
+ browser_specific_settings: {
+ gecko: {
+ id: "theme2@tests.mozilla.org",
+ },
+ },
+ },
+ initialState: {
+ userDisabled: false,
+ },
+ desiredState: {
+ isActive: true,
+ userDisabled: false,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+};
+
+const IDS = Object.keys(ADDONS);
+
+function promiseUpdates(addon) {
+ return new Promise(resolve => {
+ addon.findUpdates(
+ { onUpdateFinished: resolve },
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ });
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2", "2");
+
+ for (let addon of Object.values(ADDONS)) {
+ let webext = createTempWebExtensionFile({ manifest: addon.manifest });
+ await AddonTestUtils.manuallyInstall(webext);
+ }
+
+ await promiseStartupManager();
+
+ let addons = await getAddons(IDS);
+ for (let [id, addon] of Object.entries(ADDONS)) {
+ if (addon.initialState) {
+ await setInitialState(addons.get(id), addon.initialState);
+ }
+ if (addon.findUpdates) {
+ await promiseUpdates(addons.get(id));
+ }
+ }
+});
+
+add_task(async function test_after_restart() {
+ await promiseRestartManager();
+
+ info("Test add-on state after restart");
+ let addons = await getAddons(IDS);
+ for (let [id, addon] of Object.entries(ADDONS)) {
+ checkAddon(id, addons.get(id), addon.desiredState);
+ }
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_after_corruption() {
+ // Shutdown and replace the database with a corrupt file (a directory
+ // serves this purpose). On startup the add-ons manager won't rebuild
+ // because there is a file there still.
+ gExtensionsJSON.remove(true);
+ gExtensionsJSON.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+ await promiseStartupManager();
+
+ Services.obs.notifyObservers(null, "sessionstore-windows-restored");
+ await AddonManagerPrivate.databaseReady;
+
+ // Accessing the add-ons should open and recover the database
+ info("Test add-on state after corruption");
+ let addons = await getAddons(IDS);
+ for (let [id, addon] of Object.entries(ADDONS)) {
+ checkAddon(id, addons.get(id), addon.desiredState);
+ }
+
+ await Assert.rejects(
+ promiseShutdownManager(),
+ /NotAllowedError: Could not open the file at .+ for writing/
+ );
+});
+
+add_task(async function test_after_second_restart() {
+ await promiseStartupManager();
+
+ info("Test add-on state after second restart");
+ let addons = await getAddons(IDS);
+ for (let [id, addon] of Object.entries(ADDONS)) {
+ checkAddon(id, addons.get(id), addon.desiredState);
+ }
+
+ await Assert.rejects(
+ promiseShutdownManager(),
+ /NotAllowedError: Could not open the file at .+ for writing/
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_crash_annotation_quoting.js b/toolkit/mozapps/extensions/test/xpcshell/test_crash_annotation_quoting.js
new file mode 100644
index 0000000000..4458c6d592
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_crash_annotation_quoting.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that strange characters in an add-on version don't break the
+// crash annotation.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+add_task(async function run_test() {
+ await promiseStartupManager();
+
+ let n = 1;
+ for (let version in ["1,0", "1:0"]) {
+ let id = `addon${n++}@tests.mozilla.org`;
+ await promiseInstallWebExtension({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+
+ do_check_in_crash_annotation(id, version);
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_db_path.js b/toolkit/mozapps/extensions/test/xpcshell/test_db_path.js
new file mode 100644
index 0000000000..a9a54291f0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_db_path.js
@@ -0,0 +1,64 @@
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+const DEFAULT_THEME_ID = "default-theme@mozilla.org";
+
+let global = this;
+
+// Test that paths in the extensions database are stored properly
+// if they include non-ascii characters (see bug 1428234 for an example of
+// a past bug with such paths)
+add_task(async function test_non_ascii_path() {
+ const PROFILE_VAR = "XPCSHELL_TEST_PROFILE_DIR";
+ let profileDir = PathUtils.join(
+ Services.env.get(PROFILE_VAR),
+ "\u00ce \u00e5m \u00f1\u00f8t \u00e5s\u00e7ii"
+ );
+ Services.env.set(PROFILE_VAR, profileDir);
+
+ AddonTestUtils.init(global);
+ AddonTestUtils.overrideCertDB();
+ AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1"
+ );
+
+ const ID1 = "profile1@tests.mozilla.org";
+ let xpi1 = await AddonTestUtils.createTempWebExtensionFile({
+ id: ID1,
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID1 } },
+ },
+ });
+
+ const ID2 = "profile2@tests.mozilla.org";
+ let xpi2 = await AddonTestUtils.createTempWebExtensionFile({
+ id: ID2,
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID2 } },
+ },
+ });
+
+ await AddonTestUtils.manuallyInstall(xpi1);
+ await AddonTestUtils.promiseStartupManager();
+ await AddonTestUtils.promiseInstallFile(xpi2);
+ await AddonTestUtils.promiseShutdownManager();
+
+ let dbfile = PathUtils.join(profileDir, "extensions.json");
+ let data = await IOUtils.readJSON(dbfile);
+
+ let addons = data.addons.filter(a => a.id !== DEFAULT_THEME_ID);
+ Assert.ok(Array.isArray(addons), "extensions.json has addons array");
+ Assert.equal(2, addons.length, "extensions.json has 2 addons");
+ Assert.ok(
+ addons[0].path.startsWith(profileDir),
+ "path property for sideloaded extension has the proper profile directory"
+ );
+ Assert.ok(
+ addons[1].path.startsWith(profileDir),
+ "path property for extension installed at runtime has the proper profile directory"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_delay_update_webextension.js b/toolkit/mozapps/extensions/test/xpcshell/test_delay_update_webextension.js
new file mode 100644
index 0000000000..7b1c6fbef9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_delay_update_webextension.js
@@ -0,0 +1,556 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that delaying an update works for WebExtensions.
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+if (AppConstants.platform == "win" && AppConstants.DEBUG) {
+ // Shutdown timing is flaky in this test, and remote extensions
+ // sometimes wind up leaving the XPI locked at the point when we try
+ // to remove it.
+ Services.prefs.setBoolPref("extensions.webextensions.remote", false);
+}
+
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Message manager disconnected/
+);
+
+/* globals browser*/
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+const stageDir = profileDir.clone();
+stageDir.append("staged");
+
+const IGNORE_ID = "test_delay_update_ignore_webext@tests.mozilla.org";
+const COMPLETE_ID = "test_delay_update_complete_webext@tests.mozilla.org";
+const DEFER_ID = "test_delay_update_defer_webext@tests.mozilla.org";
+const STAGED_ID = "test_delay_update_staged_webext@tests.mozilla.org";
+const STAGED_NO_UPDATE_URL_ID =
+ "test_delay_update_staged_webext_no_update_url@tests.mozilla.org";
+const NOUPDATE_ID = "test_no_update_webext@tests.mozilla.org";
+
+// Create and configure the HTTP server.
+var testserver = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+testserver.registerDirectory("/data/", do_get_file("data"));
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42");
+BootstrapMonitor.init();
+
+const ADDONS = {
+ test_delay_update_complete_webextension_v2: {
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Delay Upgrade",
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: { id: COMPLETE_ID },
+ },
+ },
+ },
+ test_delay_update_defer_webextension_v2: {
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Delay Upgrade",
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: { id: DEFER_ID },
+ },
+ },
+ },
+ test_delay_update_staged_webextension_v2: {
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Delay Upgrade",
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: {
+ id: STAGED_ID,
+ update_url: `http://example.com/data/test_delay_updates_staged.json`,
+ strict_min_version: "1",
+ strict_max_version: "41",
+ },
+ },
+ },
+ },
+ test_delay_update_staged_webextension_no_update_url_v2: {
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Delay Upgrade",
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: {
+ id: STAGED_NO_UPDATE_URL_ID,
+ strict_min_version: "1",
+ strict_max_version: "41",
+ },
+ },
+ },
+ },
+ test_delay_update_ignore_webextension_v2: {
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Delay Upgrade",
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: { id: IGNORE_ID },
+ },
+ },
+ },
+};
+
+const XPIS = {};
+for (let [name, files] of Object.entries(ADDONS)) {
+ XPIS[name] = AddonTestUtils.createTempXPIFile(files);
+ testserver.registerFile(`/addons/${name}.xpi`, XPIS[name]);
+}
+
+// add-on registers upgrade listener, and ignores update.
+add_task(async function delay_updates_ignore() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: IGNORE_ID,
+ update_url: `http://example.com/data/test_delay_updates_ignore.json`,
+ },
+ },
+ },
+ background() {
+ browser.runtime.onUpdateAvailable.addListener(details => {
+ if (details) {
+ 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")]);
+ BootstrapMonitor.checkInstalled(IGNORE_ID, "1.0");
+
+ let addon = await promiseAddonByID(IGNORE_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(addon.name, "Generated extension");
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ let update = await promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+
+ await promiseCompleteAllInstalls([install]);
+
+ Assert.equal(install.state, AddonManager.STATE_POSTPONED);
+ BootstrapMonitor.checkInstalled(IGNORE_ID, "1.0");
+
+ // addon upgrade has been delayed
+ let addon_postponed = await promiseAddonByID(IGNORE_ID);
+ Assert.notEqual(addon_postponed, null);
+ Assert.equal(addon_postponed.version, "1.0");
+ Assert.equal(addon_postponed.name, "Generated extension");
+ Assert.ok(addon_postponed.isCompatible);
+ Assert.ok(!addon_postponed.appDisabled);
+ Assert.ok(addon_postponed.isActive);
+ Assert.equal(addon_postponed.type, "extension");
+
+ await extension.awaitFinish("delay");
+
+ // restarting allows upgrade to proceed
+ await promiseRestartManager();
+
+ let addon_upgraded = await promiseAddonByID(IGNORE_ID);
+ await extension.awaitStartup();
+ BootstrapMonitor.checkUpdated(IGNORE_ID, "2.0");
+
+ Assert.notEqual(addon_upgraded, null);
+ Assert.equal(addon_upgraded.version, "2.0");
+ Assert.equal(addon_upgraded.name, "Delay Upgrade");
+ Assert.ok(addon_upgraded.isCompatible);
+ Assert.ok(!addon_upgraded.appDisabled);
+ Assert.ok(addon_upgraded.isActive);
+ Assert.equal(addon_upgraded.type, "extension");
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+// add-on registers upgrade listener, and allows update.
+add_task(async function delay_updates_complete() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: COMPLETE_ID,
+ update_url: `http://example.com/data/test_delay_updates_complete.json`,
+ },
+ },
+ },
+ background() {
+ browser.runtime.onUpdateAvailable.addListener(details => {
+ browser.test.notifyPass("reload");
+ browser.runtime.reload();
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+
+ let addon = await promiseAddonByID(COMPLETE_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(addon.name, "Generated extension");
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ let update = await promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+
+ let promiseInstalled = promiseAddonEvent("onInstalled");
+ await promiseCompleteAllInstalls([install]);
+
+ await extension.awaitFinish("reload");
+
+ // addon upgrade has been allowed
+ let [addon_allowed] = await promiseInstalled;
+ await extension.awaitStartup();
+
+ Assert.notEqual(addon_allowed, null);
+ Assert.equal(addon_allowed.version, "2.0");
+ Assert.equal(addon_allowed.name, "Delay Upgrade");
+ Assert.ok(addon_allowed.isCompatible);
+ Assert.ok(!addon_allowed.appDisabled);
+ Assert.ok(addon_allowed.isActive);
+ Assert.equal(addon_allowed.type, "extension");
+
+ await new Promise(executeSoon);
+
+ if (stageDir.exists()) {
+ do_throw(
+ "Staging directory should not exist for formerly-postponed extension"
+ );
+ }
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+// add-on registers upgrade listener, initially defers update then allows upgrade
+add_task(async function delay_updates_defer() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: DEFER_ID,
+ update_url: `http://example.com/data/test_delay_updates_defer.json`,
+ },
+ },
+ },
+ background() {
+ browser.runtime.onUpdateAvailable.addListener(details => {
+ // Upgrade will only proceed when "allow" message received.
+ browser.test.onMessage.addListener(msg => {
+ if (msg == "allow") {
+ browser.test.notifyPass("allowed");
+ browser.runtime.reload();
+ } else {
+ browser.test.fail(`wrong message: ${msg}`);
+ }
+ });
+ browser.test.sendMessage("truly ready");
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+
+ let addon = await promiseAddonByID(DEFER_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(addon.name, "Generated extension");
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ let update = await promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+
+ let promiseInstalled = promiseAddonEvent("onInstalled");
+ await promiseCompleteAllInstalls([install]);
+
+ Assert.equal(install.state, AddonManager.STATE_POSTPONED);
+
+ // upgrade is initially postponed
+ let addon_postponed = await promiseAddonByID(DEFER_ID);
+ Assert.notEqual(addon_postponed, null);
+ Assert.equal(addon_postponed.version, "1.0");
+ Assert.equal(addon_postponed.name, "Generated extension");
+ Assert.ok(addon_postponed.isCompatible);
+ Assert.ok(!addon_postponed.appDisabled);
+ Assert.ok(addon_postponed.isActive);
+ Assert.equal(addon_postponed.type, "extension");
+
+ // add-on will not allow upgrade until message is received
+ await extension.awaitMessage("truly ready");
+ extension.sendMessage("allow");
+ await extension.awaitFinish("allowed");
+
+ // addon upgrade has been allowed
+ let [addon_allowed] = await promiseInstalled;
+ await extension.awaitStartup();
+
+ Assert.notEqual(addon_allowed, null);
+ Assert.equal(addon_allowed.version, "2.0");
+ Assert.equal(addon_allowed.name, "Delay Upgrade");
+ Assert.ok(addon_allowed.isCompatible);
+ Assert.ok(!addon_allowed.appDisabled);
+ Assert.ok(addon_allowed.isActive);
+ Assert.equal(addon_allowed.type, "extension");
+
+ await promiseRestartManager();
+
+ // restart changes nothing
+ addon_allowed = await promiseAddonByID(DEFER_ID);
+ await extension.awaitStartup();
+
+ Assert.notEqual(addon_allowed, null);
+ Assert.equal(addon_allowed.version, "2.0");
+ Assert.equal(addon_allowed.name, "Delay Upgrade");
+ Assert.ok(addon_allowed.isCompatible);
+ Assert.ok(!addon_allowed.appDisabled);
+ Assert.ok(addon_allowed.isActive);
+ Assert.equal(addon_allowed.type, "extension");
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+// add-on registers upgrade listener to deny update, completes after restart,
+// even though the updated XPI is incompatible - the information returned
+// by the update server defined in its manifest returns a compatible range
+add_task(async function delay_updates_staged() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: STAGED_ID,
+ update_url: `http://example.com/data/test_delay_updates_staged.json`,
+ },
+ },
+ },
+ background() {
+ browser.runtime.onUpdateAvailable.addListener(details => {
+ browser.test.sendMessage("denied");
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+
+ let addon = await promiseAddonByID(STAGED_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(addon.name, "Generated extension");
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ let update = await promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+ await promiseCompleteAllInstalls([install]);
+
+ Assert.equal(install.state, AddonManager.STATE_POSTPONED);
+
+ // upgrade is initially postponed
+ let addon_postponed = await promiseAddonByID(STAGED_ID);
+ Assert.notEqual(addon_postponed, null);
+ Assert.equal(addon_postponed.version, "1.0");
+ Assert.equal(addon_postponed.name, "Generated extension");
+ Assert.ok(addon_postponed.isCompatible);
+ Assert.ok(!addon_postponed.appDisabled);
+ Assert.ok(addon_postponed.isActive);
+ Assert.equal(addon_postponed.type, "extension");
+
+ // add-on reports an available upgrade, but denied it till next restart
+ await extension.awaitMessage("denied");
+
+ await promiseRestartManager();
+ await extension.awaitStartup();
+
+ // add-on should have been updated during restart
+ let addon_upgraded = await promiseAddonByID(STAGED_ID);
+ Assert.notEqual(addon_upgraded, null);
+ Assert.equal(addon_upgraded.version, "2.0");
+ Assert.equal(addon_upgraded.name, "Delay Upgrade");
+ Assert.ok(addon_upgraded.isCompatible);
+ Assert.ok(!addon_upgraded.appDisabled);
+ Assert.ok(addon_upgraded.isActive);
+ Assert.equal(addon_upgraded.type, "extension");
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+// add-on registers upgrade listener to deny update, does not complete after
+// restart, because the updated XPI is incompatible - there is no update server
+// defined in its manifest, which could return a compatible range
+add_task(async function delay_updates_staged_no_update_url() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: STAGED_NO_UPDATE_URL_ID,
+ update_url: `http://example.com/data/test_delay_updates_staged.json`,
+ },
+ },
+ },
+ background() {
+ browser.runtime.onUpdateAvailable.addListener(details => {
+ browser.test.sendMessage("denied");
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+
+ let addon = await promiseAddonByID(STAGED_NO_UPDATE_URL_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(addon.name, "Generated extension");
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ let update = await promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+ await promiseCompleteAllInstalls([install]);
+
+ Assert.equal(install.state, AddonManager.STATE_POSTPONED);
+
+ // upgrade is initially postponed
+ let addon_postponed = await promiseAddonByID(STAGED_NO_UPDATE_URL_ID);
+ Assert.notEqual(addon_postponed, null);
+ Assert.equal(addon_postponed.version, "1.0");
+ Assert.equal(addon_postponed.name, "Generated extension");
+ Assert.ok(addon_postponed.isCompatible);
+ Assert.ok(!addon_postponed.appDisabled);
+ Assert.ok(addon_postponed.isActive);
+ Assert.equal(addon_postponed.type, "extension");
+
+ // add-on reports an available upgrade, but denied it till next restart
+ await extension.awaitMessage("denied");
+
+ await promiseRestartManager();
+ await extension.awaitStartup();
+
+ // add-on should not have been updated during restart
+ let addon_upgraded = await promiseAddonByID(STAGED_NO_UPDATE_URL_ID);
+ Assert.notEqual(addon_upgraded, null);
+ Assert.equal(addon_upgraded.version, "1.0");
+ Assert.equal(addon_upgraded.name, "Generated extension");
+ Assert.ok(addon_upgraded.isCompatible);
+ Assert.ok(!addon_upgraded.appDisabled);
+ Assert.ok(addon_upgraded.isActive);
+ Assert.equal(addon_upgraded.type, "extension");
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+// browser.runtime.reload() without a pending upgrade should just reload.
+add_task(async function runtime_reload() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: NOUPDATE_ID,
+ update_url: `http://example.com/data/test_no_update.json`,
+ },
+ },
+ },
+ background() {
+ browser.test.onMessage.addListener(msg => {
+ if (msg == "reload") {
+ browser.runtime.reload();
+ } else {
+ browser.test.fail(`wrong message: ${msg}`);
+ }
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+
+ let addon = await promiseAddonByID(NOUPDATE_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(addon.name, "Generated extension");
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ await promiseFindAddonUpdates(addon);
+
+ extension.sendMessage("reload");
+ // Wait for extension to restart, to make sure reload works.
+ await AddonTestUtils.promiseWebExtensionStartup(NOUPDATE_ID);
+ await extension.awaitMessage("ready");
+
+ addon = await promiseAddonByID(NOUPDATE_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(addon.name, "Generated extension");
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_dependencies.js b/toolkit/mozapps/extensions/test/xpcshell/test_dependencies.js
new file mode 100644
index 0000000000..476c9e0595
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_dependencies.js
@@ -0,0 +1,140 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+const ADDONS = [
+ {
+ id: "addon1@experiments.addons.mozilla.org",
+ dependencies: ["experiments.addon2"],
+ },
+ {
+ id: "addon2@experiments.addons.mozilla.org",
+ dependencies: ["experiments.addon3"],
+ },
+ {
+ id: "addon3@experiments.addons.mozilla.org",
+ },
+ {
+ id: "addon4@experiments.addons.mozilla.org",
+ },
+ {
+ id: "addon5@experiments.addons.mozilla.org",
+ dependencies: ["experiments.addon2"],
+ },
+];
+
+let addonFiles = [];
+
+let events = [];
+
+function promiseAddonStartup(id) {
+ return new Promise(resolve => {
+ const onBootstrapMethod = (event, { method, params }) => {
+ if (method == "startup" && params.id == id) {
+ AddonTestUtils.off("bootstrap-method", onBootstrapMethod);
+ resolve();
+ }
+ };
+
+ AddonTestUtils.on("bootstrap-method", onBootstrapMethod);
+ });
+}
+
+add_task(async function setup() {
+ await promiseStartupManager();
+
+ const onBootstrapMethod = (event, { method, params }) => {
+ if (method == "startup" || method == "shutdown") {
+ events.push([method, params.id]);
+ }
+ };
+
+ AddonTestUtils.on("bootstrap-method", onBootstrapMethod);
+ registerCleanupFunction(() => {
+ AddonTestUtils.off("bootstrap-method", onBootstrapMethod);
+ });
+
+ for (let addon of ADDONS) {
+ let manifest = {
+ browser_specific_settings: { gecko: { id: addon.id } },
+ permissions: addon.dependencies,
+ };
+
+ addonFiles.push(await createTempWebExtensionFile({ manifest }));
+ }
+});
+
+add_task(async function () {
+ deepEqual(events, [], "Should have no events");
+
+ await promiseInstallFile(addonFiles[3]);
+
+ deepEqual(events, [["startup", ADDONS[3].id]]);
+
+ events.length = 0;
+
+ await promiseInstallFile(addonFiles[0]);
+ deepEqual(events, [], "Should have no events");
+
+ await promiseInstallFile(addonFiles[1]);
+ deepEqual(events, [], "Should have no events");
+
+ await Promise.all([
+ promiseInstallFile(addonFiles[2]),
+ promiseAddonStartup(ADDONS[0].id),
+ ]);
+
+ deepEqual(events, [
+ ["startup", ADDONS[2].id],
+ ["startup", ADDONS[1].id],
+ ["startup", ADDONS[0].id],
+ ]);
+
+ events.length = 0;
+
+ await Promise.all([
+ promiseInstallFile(addonFiles[2]),
+ promiseAddonStartup(ADDONS[0].id),
+ ]);
+
+ deepEqual(events, [
+ ["shutdown", ADDONS[0].id],
+ ["shutdown", ADDONS[1].id],
+ ["shutdown", ADDONS[2].id],
+
+ ["startup", ADDONS[2].id],
+ ["startup", ADDONS[1].id],
+ ["startup", ADDONS[0].id],
+ ]);
+
+ events.length = 0;
+
+ await promiseInstallFile(addonFiles[4]);
+
+ deepEqual(events, [["startup", ADDONS[4].id]]);
+
+ events.length = 0;
+
+ await promiseRestartManager();
+
+ deepEqual(events, [
+ ["shutdown", ADDONS[4].id],
+ ["shutdown", ADDONS[3].id],
+ ["shutdown", ADDONS[0].id],
+ ["shutdown", ADDONS[1].id],
+ ["shutdown", ADDONS[2].id],
+
+ ["startup", ADDONS[2].id],
+ ["startup", ADDONS[1].id],
+ ["startup", ADDONS[0].id],
+ ["startup", ADDONS[3].id],
+ ["startup", ADDONS[4].id],
+ ]);
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_dictionary_webextension.js b/toolkit/mozapps/extensions/test/xpcshell/test_dictionary_webextension.js
new file mode 100644
index 0000000000..847d519036
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_dictionary_webextension.js
@@ -0,0 +1,233 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "spellCheck",
+ "@mozilla.org/spellchecker/engine;1",
+ "mozISpellCheckingEngine"
+);
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "61", "61");
+
+ // Initialize the URLPreloader so that we can load the built-in
+ // add-ons list, which contains the list of built-in dictionaries.
+ AddonTestUtils.initializeURLPreloader();
+
+ await promiseStartupManager();
+
+ // Starts collecting the Addon Manager Telemetry events.
+ AddonTestUtils.hookAMTelemetryEvents();
+});
+
+add_task(
+ {
+ // We need to enable this pref because some assertions verify that
+ // `installOrigins` is collected in some Telemetry events.
+ pref_set: [["extensions.install_origins.enabled", true]],
+ },
+ async function test_validation() {
+ await Assert.rejects(
+ promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "en-US-no-dic@dictionaries.mozilla.org" },
+ },
+ dictionaries: {
+ "en-US": "en-US.dic",
+ },
+ },
+ }),
+ /Expected file to be downloaded for install/
+ );
+
+ await Assert.rejects(
+ promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "en-US-no-aff@dictionaries.mozilla.org" },
+ },
+ dictionaries: {
+ "en-US": "en-US.dic",
+ },
+ },
+
+ files: {
+ "en-US.dic": "",
+ },
+ }),
+ /Expected file to be downloaded for install/
+ );
+
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "en-US-1@dictionaries.mozilla.org" },
+ },
+ dictionaries: {
+ "en-US": "en-US.dic",
+ },
+ },
+
+ files: {
+ "en-US.dic": "",
+ "en-US.aff": "",
+ },
+ });
+
+ let addon2 = await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "en-US-2@dictionaries.mozilla.org" },
+ },
+ dictionaries: {
+ "en-US": "dictionaries/en-US.dic",
+ },
+ },
+
+ files: {
+ "dictionaries/en-US.dic": "",
+ "dictionaries/en-US.aff": "",
+ },
+ });
+
+ await addon.uninstall();
+ await addon2.uninstall();
+
+ let amEvents = AddonTestUtils.getAMTelemetryEvents();
+
+ let amInstallEvents = amEvents
+ .filter(evt => evt.method === "install")
+ .map(evt => {
+ const { object, extra } = evt;
+ return { object, extra };
+ });
+
+ Assert.deepEqual(
+ amInstallEvents.filter(evt => evt.object === "unknown"),
+ [
+ {
+ object: "unknown",
+ extra: {
+ step: "started",
+ error: "ERROR_CORRUPT_FILE",
+ install_origins: "0",
+ },
+ },
+ {
+ object: "unknown",
+ extra: {
+ step: "started",
+ error: "ERROR_CORRUPT_FILE",
+ install_origins: "0",
+ },
+ },
+ ],
+ "Got the expected install telemetry events for the corrupted dictionaries"
+ );
+
+ Assert.deepEqual(
+ amInstallEvents.filter(evt => evt.extra.addon_id === addon.id),
+ [
+ {
+ object: "dictionary",
+ extra: { step: "started", addon_id: addon.id, install_origins: "0" },
+ },
+ {
+ object: "dictionary",
+ extra: {
+ step: "completed",
+ addon_id: addon.id,
+ install_origins: "0",
+ },
+ },
+ ],
+ "Got the expected install telemetry events for the first installed dictionary"
+ );
+
+ Assert.deepEqual(
+ amInstallEvents.filter(evt => evt.extra.addon_id === addon2.id),
+ [
+ {
+ object: "dictionary",
+ extra: { step: "started", addon_id: addon2.id, install_origins: "0" },
+ },
+ {
+ object: "dictionary",
+ extra: {
+ step: "completed",
+ addon_id: addon2.id,
+ install_origins: "0",
+ },
+ },
+ ],
+ "Got the expected install telemetry events for the second installed dictionary"
+ );
+
+ let amUninstallEvents = amEvents
+ .filter(evt => evt.method === "uninstall")
+ .map(evt => {
+ const { object, value } = evt;
+ return { object, value };
+ });
+
+ Assert.deepEqual(
+ amUninstallEvents,
+ [
+ { object: "dictionary", value: addon.id },
+ { object: "dictionary", value: addon2.id },
+ ],
+ "Got the expected uninstall telemetry events"
+ );
+ }
+);
+
+const WORD = "Flehgragh";
+
+add_task(async function test_registration() {
+ spellCheck.dictionaries = ["en-US"];
+
+ ok(!spellCheck.check(WORD), "Word should not pass check before add-on loads");
+
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "en-US@dictionaries.mozilla.org" },
+ },
+ dictionaries: {
+ "en-US": "en-US.dic",
+ },
+ },
+
+ files: {
+ "en-US.dic": `2\n${WORD}\nnativ/A\n`,
+ "en-US.aff": `
+SFX A Y 1
+SFX A 0 en [^elr]
+ `,
+ },
+ });
+
+ ok(
+ spellCheck.check(WORD),
+ "Word should pass check while add-on load is loaded"
+ );
+ ok(spellCheck.check("nativen"), "Words should have correct affixes");
+
+ await addon.uninstall();
+
+ await new Promise(executeSoon);
+
+ ok(
+ !spellCheck.check(WORD),
+ "Word should not pass check after add-on unloads"
+ );
+});
+
+add_task(function teardown_telemetry_events() {
+ // Ignore any additional telemetry events collected in this file.
+ AddonTestUtils.getAMTelemetryEvents();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_distribution.js b/toolkit/mozapps/extensions/test/xpcshell/test_distribution.js
new file mode 100644
index 0000000000..61231160d8
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_distribution.js
@@ -0,0 +1,115 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that add-ons distributed with the application get installed
+// correctly
+
+// Allow distributed add-ons to install
+Services.prefs.setBoolPref("extensions.installDistroAddons", true);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+const distroDir = gProfD.clone();
+distroDir.append("distribution");
+distroDir.append("extensions");
+registerDirectory("XREAppDist", distroDir.parent);
+
+async function setOldModificationTime() {
+ // Make sure the installed extension has an old modification time so any
+ // changes will be detected
+ await promiseShutdownManager();
+ let extension = gProfD.clone();
+ extension.append("extensions");
+ extension.append(`${ID}.xpi`);
+ setExtensionModifiedTime(extension, Date.now() - MAKE_FILE_OLD_DIFFERENCE);
+ await promiseStartupManager();
+}
+
+const ID = "addon@tests.mozilla.org";
+
+async function writeDistroAddon(version) {
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+ xpi.copyTo(distroDir, `${ID}.xpi`);
+}
+
+// Tests that on the first startup the add-on gets installed
+add_task(async function run_test_1() {
+ await writeDistroAddon("1.0");
+ await promiseStartupManager();
+
+ let a1 = await AddonManager.getAddonByID(ID);
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.version, "1.0");
+ Assert.ok(a1.isActive);
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+ Assert.ok(!a1.foreignInstall);
+});
+
+// Tests that starting with a newer version in the distribution dir doesn't
+// install it yet
+add_task(async function run_test_2() {
+ await setOldModificationTime();
+
+ await writeDistroAddon("2.0");
+ await promiseRestartManager();
+
+ let a1 = await AddonManager.getAddonByID(ID);
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.version, "1.0");
+ Assert.ok(a1.isActive);
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+});
+
+// Test that an app upgrade installs the newer version
+add_task(async function run_test_3() {
+ await promiseRestartManager("2");
+
+ let a1 = await AddonManager.getAddonByID(ID);
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.version, "2.0");
+ Assert.ok(a1.isActive);
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+ Assert.ok(!a1.foreignInstall);
+});
+
+// Test that an app upgrade doesn't downgrade the extension
+add_task(async function run_test_4() {
+ await setOldModificationTime();
+
+ await writeDistroAddon("1.0");
+ await promiseRestartManager("3");
+
+ let a1 = await AddonManager.getAddonByID(ID);
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.version, "2.0");
+ Assert.ok(a1.isActive);
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+});
+
+// Tests that after uninstalling a restart doesn't re-install the extension
+add_task(async function run_test_5() {
+ let a1 = await AddonManager.getAddonByID(ID);
+ await a1.uninstall();
+
+ await promiseRestartManager();
+
+ let a1_2 = await AddonManager.getAddonByID(ID);
+ Assert.equal(a1_2, null);
+});
+
+// Tests that upgrading the application still doesn't re-install the uninstalled
+// extension
+add_task(async function run_test_6() {
+ await promiseRestartManager("4");
+
+ let a1 = await AddonManager.getAddonByID(ID);
+ Assert.equal(a1, null);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_distribution_langpack.js b/toolkit/mozapps/extensions/test/xpcshell/test_distribution_langpack.js
new file mode 100644
index 0000000000..0e594d60ec
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_distribution_langpack.js
@@ -0,0 +1,112 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that add-ons distributed with the application in
+// langauge subdirectories correctly get installed
+
+// Allow distributed add-ons to install
+Services.prefs.setBoolPref("extensions.installDistroAddons", true);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+const distroDir = gProfD.clone();
+distroDir.append("distribution");
+distroDir.append("extensions");
+registerDirectory("XREAppDist", distroDir.parent);
+const enUSDistroDir = distroDir.clone();
+enUSDistroDir.append("locale-en-US");
+const deDEDistroDir = distroDir.clone();
+deDEDistroDir.append("locale-de-DE");
+const esESDistroDir = distroDir.clone();
+esESDistroDir.append("locale-es-ES");
+
+const enUSID = "addon-en-US@tests.mozilla.org";
+const deDEID = "addon-de-DE@tests.mozilla.org";
+const esESID = "addon-es-ES@tests.mozilla.org";
+
+async function writeDistroAddons(version) {
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id: enUSID } },
+ },
+ });
+ xpi.copyTo(enUSDistroDir, `${enUSID}.xpi`);
+
+ xpi = await createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id: deDEID } },
+ },
+ });
+ xpi.copyTo(deDEDistroDir, `${deDEID}.xpi`);
+
+ xpi = await createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id: esESID } },
+ },
+ });
+ xpi.copyTo(esESDistroDir, `${esESID}.xpi`);
+}
+
+add_task(async function setup() {
+ await writeDistroAddons("1.0");
+});
+
+// Tests that on the first startup the requested locale
+// add-on gets installed, and others don't.
+add_task(async function run_locale_test() {
+ Services.locale.availableLocales = ["de-DE", "en-US"];
+ Services.locale.requestedLocales = ["de-DE"];
+
+ Assert.equal(Services.locale.requestedLocale, "de-DE");
+
+ await promiseStartupManager();
+
+ let a1 = await AddonManager.getAddonByID(deDEID);
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.version, "1.0");
+ Assert.ok(a1.isActive);
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+ Assert.ok(!a1.foreignInstall);
+
+ let a2 = await AddonManager.getAddonByID(enUSID);
+ Assert.equal(a2, null);
+
+ let a3 = await AddonManager.getAddonByID(esESID);
+ Assert.equal(a3, null);
+
+ await a1.uninstall();
+ await promiseShutdownManager();
+});
+
+// Tests that on the first startup the correct fallback locale
+// add-on gets installed, and others don't.
+add_task(async function run_fallback_test() {
+ Services.locale.availableLocales = ["es-ES", "en-US"];
+ Services.locale.requestedLocales = ["es-UY"];
+
+ Assert.equal(Services.locale.requestedLocale, "es-UY");
+
+ await promiseStartupManager();
+
+ let a1 = await AddonManager.getAddonByID(esESID);
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.version, "1.0");
+ Assert.ok(a1.isActive);
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+ Assert.ok(!a1.foreignInstall);
+
+ let a2 = await AddonManager.getAddonByID(enUSID);
+ Assert.equal(a2, null);
+
+ let a3 = await AddonManager.getAddonByID(deDEID);
+ Assert.equal(a3, null);
+
+ await a1.uninstall();
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_embedderDisabled.js b/toolkit/mozapps/extensions/test/xpcshell/test_embedderDisabled.js
new file mode 100644
index 0000000000..943b3cf0c3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_embedderDisabled.js
@@ -0,0 +1,124 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const ADDON_ID = "embedder-disabled@tests.mozilla.org";
+const PREF_IS_EMBEDDED = "extensions.isembedded";
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
+
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(PREF_IS_EMBEDDED);
+});
+
+async function installExtension() {
+ return promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ADDON_ID } },
+ },
+ });
+}
+
+add_task(async function test_setup() {
+ await promiseStartupManager();
+});
+
+add_task(async function embedder_disabled_while_not_embedding() {
+ const addon = await installExtension();
+ let exceptionThrown = false;
+ try {
+ await addon.setEmbedderDisabled(true);
+ } catch (exception) {
+ exceptionThrown = true;
+ }
+
+ equal(exceptionThrown, true);
+
+ // Verify that the addon is not affected
+ equal(addon.isActive, true);
+ equal(addon.embedderDisabled, undefined);
+
+ await addon.uninstall();
+});
+
+add_task(async function unset_embedder_disabled_while_not_embedding() {
+ Services.prefs.setBoolPref(PREF_IS_EMBEDDED, true);
+
+ const addon = await installExtension();
+ await addon.setEmbedderDisabled(true);
+
+ // Verify the addon is not active anymore
+ equal(addon.isActive, false);
+ equal(addon.embedderDisabled, true);
+
+ Services.prefs.setBoolPref(PREF_IS_EMBEDDED, false);
+
+ // Verify that embedder disabled cannot be read if not embedding
+ equal(addon.embedderDisabled, undefined);
+
+ await addon.disable();
+ await addon.enable();
+
+ // Verify that embedder disabled can be removed
+ equal(addon.isActive, true);
+ equal(addon.embedderDisabled, undefined);
+
+ await addon.uninstall();
+});
+
+add_task(async function embedder_disabled_while_embedding() {
+ Services.prefs.setBoolPref(PREF_IS_EMBEDDED, true);
+
+ const addon = await installExtension();
+ await addon.setEmbedderDisabled(true);
+
+ // Verify the addon is not active anymore
+ equal(addon.embedderDisabled, true);
+ equal(addon.isActive, false);
+
+ await addon.setEmbedderDisabled(false);
+
+ // Verify that the addon is now enabled again
+ equal(addon.isActive, true);
+ equal(addon.embedderDisabled, false);
+ await addon.uninstall();
+
+ Services.prefs.setBoolPref(PREF_IS_EMBEDDED, false);
+});
+
+add_task(async function embedder_disabled_while_user_disabled() {
+ Services.prefs.setBoolPref(PREF_IS_EMBEDDED, true);
+
+ const addon = await installExtension();
+ await addon.disable();
+
+ // Verify that the addon is userDisabled
+ equal(addon.isActive, false);
+ equal(addon.userDisabled, true);
+ equal(addon.embedderDisabled, false);
+
+ await addon.setEmbedderDisabled(true);
+
+ // Verify that the addon can be userDisabled and embedderDisabled
+ equal(addon.isActive, false);
+ equal(addon.userDisabled, true);
+ equal(addon.embedderDisabled, true);
+
+ await addon.setEmbedderDisabled(false);
+
+ // Verify that unsetting embedderDisabled doesn't enable the addon
+ equal(addon.isActive, false);
+ equal(addon.userDisabled, true);
+ equal(addon.embedderDisabled, false);
+
+ await addon.enable();
+
+ // Verify that the addon can be enabled after unsetting userDisabled
+ equal(addon.isActive, true);
+ equal(addon.userDisabled, false);
+ equal(addon.embedderDisabled, false);
+
+ await addon.uninstall();
+
+ Services.prefs.setBoolPref(PREF_IS_EMBEDDED, false);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_error.js b/toolkit/mozapps/extensions/test/xpcshell/test_error.js
new file mode 100644
index 0000000000..ee972f222e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_error.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that various error conditions are handled correctly
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ await promiseStartupManager();
+});
+
+// Checks that a local file validates ok
+add_task(async function run_test_1() {
+ let xpi = await createTempWebExtensionFile({});
+ let install = await AddonManager.getInstallForFile(xpi);
+ Assert.notEqual(install, null);
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOADED);
+ Assert.equal(install.error, 0);
+
+ install.cancel();
+});
+
+// Checks that a corrupt file shows an error
+add_task(async function run_test_2() {
+ let xpi = AddonTestUtils.allocTempXPIFile();
+ await IOUtils.writeUTF8(xpi.path, "this is not a zip file");
+
+ let install = await AddonManager.getInstallForFile(xpi);
+ Assert.notEqual(install, null);
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install.error, AddonManager.ERROR_CORRUPT_FILE);
+});
+
+// Checks that an empty file shows an error
+add_task(async function run_test_3() {
+ let xpi = await AddonTestUtils.createTempXPIFile({});
+ let install = await AddonManager.getInstallForFile(xpi);
+ Assert.notEqual(install, null);
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install.error, AddonManager.ERROR_CORRUPT_FILE);
+});
+
+// Checks that a file that doesn't match its hash shows an error
+add_task(async function run_test_4() {
+ let xpi = await createTempWebExtensionFile({});
+ let url = Services.io.newFileURI(xpi).spec;
+ let install = await AddonManager.getInstallForURL(url, { hash: "sha1:foo" });
+ Assert.notEqual(install, null);
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install.error, AddonManager.ERROR_INCORRECT_HASH);
+});
+
+// Checks that a file that doesn't exist shows an error
+add_task(async function run_test_5() {
+ let file = do_get_file("data");
+ file.append("missing.xpi");
+ let install = await AddonManager.getInstallForFile(file);
+ Assert.notEqual(install, null);
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install.error, AddonManager.ERROR_NETWORK_FAILURE);
+});
+
+// Checks that an add-on with an illegal ID shows an error
+add_task(async function run_test_6() {
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "invalid" } },
+ },
+ });
+ let install = await AddonManager.getInstallForFile(xpi);
+ Assert.notEqual(install, null);
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install.error, AddonManager.ERROR_CORRUPT_FILE);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_ext_management.js b/toolkit/mozapps/extensions/test/xpcshell/test_ext_management.js
new file mode 100644
index 0000000000..9fbff4efe1
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_ext_management.js
@@ -0,0 +1,223 @@
+"use strict";
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "48", "48");
+ await promiseStartupManager();
+});
+
+/* eslint-disable no-undef */
+// Shared background function for getSelf tests
+function backgroundGetSelf() {
+ browser.management.getSelf().then(
+ extInfo => {
+ let url = browser.runtime.getURL("*");
+ extInfo.hostPermissions = extInfo.hostPermissions.filter(i => i != url);
+
+ // Internal permissions are currently part of the permissions included
+ // in the management.getSelf results, and in non release channels
+ // any temporary installed extension is recognized as privileged
+ // and some internal permission would be added automatically.
+ //
+ // TODO(Bug 1713344): this may become unnecessary if we filter out
+ // the internal permissions from the management API results.
+ extInfo.permissions = extInfo.permissions.filter(
+ i => !i.startsWith("internal:")
+ );
+
+ extInfo.url = browser.runtime.getURL("");
+ browser.test.sendMessage("management-getSelf", extInfo);
+ },
+ error => {
+ browser.test.notifyFail(`getSelf rejected with error: ${error}`);
+ }
+ );
+}
+/* eslint-enable no-undef */
+
+add_task(async function test_management_get_self_complete() {
+ const id = "get_self_test_complete@tests.mozilla.com";
+ const permissions = ["management", "cookies"];
+ const hostPermissions = ["*://example.org/*", "https://foo.example.org/*"];
+
+ let manifest = {
+ browser_specific_settings: {
+ gecko: {
+ id,
+ update_url: "https://updates.mozilla.com/",
+ },
+ },
+ name: "test extension name",
+ short_name: "test extension short name",
+ description: "test extension description",
+ version: "1.0",
+ homepage_url: "http://www.example.com/",
+ options_ui: {
+ page: "get_self_options.html",
+ },
+ icons: {
+ 16: "icons/icon-16.png",
+ 48: "icons/icon-48.png",
+ },
+ permissions: [...permissions, ...hostPermissions],
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ background: backgroundGetSelf,
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let extInfo = await extension.awaitMessage("management-getSelf");
+
+ equal(extInfo.id, id, "getSelf returned the expected id");
+ equal(
+ extInfo.installType,
+ "development",
+ "getSelf returned the expected installType"
+ );
+ for (let prop of ["name", "description", "version"]) {
+ equal(
+ extInfo[prop],
+ manifest[prop],
+ `getSelf returned the expected ${prop}`
+ );
+ }
+ equal(
+ extInfo.shortName,
+ manifest.short_name,
+ "getSelf returned the expected shortName"
+ );
+ equal(
+ extInfo.mayDisable,
+ true,
+ "getSelf returned the expected value for mayDisable"
+ );
+ equal(
+ extInfo.enabled,
+ true,
+ "getSelf returned the expected value for enabled"
+ );
+ equal(
+ extInfo.homepageUrl,
+ manifest.homepage_url,
+ "getSelf returned the expected homepageUrl"
+ );
+ equal(
+ extInfo.updateUrl,
+ manifest.browser_specific_settings.gecko.update_url,
+ "getSelf returned the expected updateUrl"
+ );
+ ok(
+ extInfo.optionsUrl.endsWith(manifest.options_ui.page),
+ "getSelf returned the expected optionsUrl"
+ );
+ for (let [index, size] of Object.keys(manifest.icons).sort().entries()) {
+ let iconUrl = `${extInfo.url}${manifest.icons[size]}`;
+ equal(
+ extInfo.icons[index].size,
+ +size,
+ "getSelf returned the expected icon size"
+ );
+ equal(
+ extInfo.icons[index].url,
+ iconUrl,
+ "getSelf returned the expected icon url"
+ );
+ }
+ deepEqual(
+ extInfo.permissions.sort(),
+ permissions.sort(),
+ "getSelf returned the expected permissions"
+ );
+ deepEqual(
+ extInfo.hostPermissions.sort(),
+ hostPermissions.sort(),
+ "getSelf returned the expected hostPermissions"
+ );
+ equal(
+ extInfo.installType,
+ "development",
+ "getSelf returned the expected installType"
+ );
+ await extension.unload();
+});
+
+add_task(async function test_management_get_self_minimal() {
+ const id = "get_self_test_minimal@tests.mozilla.com";
+
+ let manifest = {
+ browser_specific_settings: {
+ gecko: {
+ id,
+ },
+ },
+ name: "test extension name",
+ version: "1.0",
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ background: backgroundGetSelf,
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let extInfo = await extension.awaitMessage("management-getSelf");
+
+ equal(extInfo.id, id, "getSelf returned the expected id");
+ equal(
+ extInfo.installType,
+ "development",
+ "getSelf returned the expected installType"
+ );
+ for (let prop of ["name", "version"]) {
+ equal(
+ extInfo[prop],
+ manifest[prop],
+ `getSelf returned the expected ${prop}`
+ );
+ }
+ for (let prop of ["shortName", "description", "optionsUrl"]) {
+ equal(extInfo[prop], "", `getSelf returned the expected ${prop}`);
+ }
+ for (let prop of ["homepageUrl", " updateUrl", "icons"]) {
+ equal(
+ Reflect.getOwnPropertyDescriptor(extInfo, prop),
+ undefined,
+ `getSelf did not return a ${prop} property`
+ );
+ }
+ for (let prop of ["permissions", "hostPermissions"]) {
+ deepEqual(extInfo[prop], [], `getSelf returned the expected ${prop}`);
+ }
+ await extension.unload();
+});
+
+add_task(async function test_management_get_self_permanent() {
+ const id = "get_self_test_permanent@tests.mozilla.com";
+
+ let manifest = {
+ browser_specific_settings: {
+ gecko: {
+ id,
+ },
+ },
+ name: "test extension name",
+ version: "1.0",
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ background: backgroundGetSelf,
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+ let extInfo = await extension.awaitMessage("management-getSelf");
+
+ equal(extInfo.id, id, "getSelf returned the expected id");
+ equal(
+ extInfo.installType,
+ "normal",
+ "getSelf returned the expected installType"
+ );
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_filepointer.js b/toolkit/mozapps/extensions/test/xpcshell/test_filepointer.js
new file mode 100644
index 0000000000..c72737d4fe
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_filepointer.js
@@ -0,0 +1,327 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that various operations with file pointers work and do not affect the
+// source files
+
+const ID1 = "addon1@tests.mozilla.org";
+const ID2 = "addon2@tests.mozilla.org";
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+profileDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+
+const sourceDir = gProfD.clone();
+sourceDir.append("source");
+
+function promiseWriteWebExtension(path, data) {
+ let files = ExtensionTestCommon.generateFiles(data);
+ return AddonTestUtils.promiseWriteFilesToDir(path, files);
+}
+
+function promiseWritePointer(aId, aName) {
+ let path = PathUtils.join(profileDir.path, aName || aId);
+
+ let target = PathUtils.join(sourceDir.path, do_get_expected_addon_name(aId));
+
+ return IOUtils.writeUTF8(path, target);
+}
+
+function promiseWriteRelativePointer(aId, aName) {
+ let path = PathUtils.join(profileDir.path, aName || aId);
+
+ let absTarget = sourceDir.clone();
+ absTarget.append(do_get_expected_addon_name(aId));
+
+ let relTarget = absTarget.getRelativeDescriptor(profileDir);
+
+ return IOUtils.writeUTF8(path, relTarget);
+}
+
+add_task(async function setup() {
+ ok(TEST_UNPACKED, "Pointer files only work with unpacked directories");
+
+ // Unpacked extensions are never signed, so this can only run with
+ // signature checks disabled.
+ Services.prefs.setBoolPref(PREF_XPI_SIGNATURES_REQUIRED, false);
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+});
+
+// Tests that installing a new add-on by pointer works
+add_task(async function test_new_pointer_install() {
+ let target = PathUtils.join(sourceDir.path, ID1);
+ await promiseWriteWebExtension(target, {
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID1 } },
+ },
+ });
+ await promiseWritePointer(ID1);
+ await promiseStartupManager();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "1.0");
+
+ let file = getAddonFile(addon);
+ equal(file.parent.path, sourceDir.path);
+
+ let rootUri = do_get_addon_root_uri(sourceDir, ID1);
+ let uri = addon.getResourceURI();
+ equal(uri.spec, rootUri);
+
+ // Check that upgrade is disabled for addons installed by file-pointers.
+ equal(addon.permissions & AddonManager.PERM_CAN_UPGRADE, 0);
+});
+
+// Tests that installing the addon from some other source doesn't clobber
+// the original sources
+add_task(async function test_addon_over_pointer() {
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: ID1 } },
+ },
+ });
+
+ let install = await AddonManager.getInstallForFile(
+ xpi,
+ "application/x-xpinstall"
+ );
+ await install.install();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "2.0");
+
+ let url = addon.getResourceURI();
+ if (url instanceof Ci.nsIJARURI) {
+ url = url.JARFile;
+ }
+ let { file } = url.QueryInterface(Ci.nsIFileURL);
+ equal(file.parent.path, profileDir.path);
+
+ let rootUri = do_get_addon_root_uri(profileDir, ID1);
+ let uri = addon.getResourceURI();
+ equal(uri.spec, rootUri);
+
+ let source = sourceDir.clone();
+ source.append(ID1);
+ ok(source.exists());
+
+ await addon.uninstall();
+});
+
+// Tests that uninstalling doesn't clobber the original sources
+add_task(async function test_uninstall_pointer() {
+ await promiseWritePointer(ID1);
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "1.0");
+
+ await addon.uninstall();
+
+ let source = sourceDir.clone();
+ source.append(ID1);
+ ok(source.exists());
+});
+
+// Tests that misnaming a pointer doesn't clobber the sources
+add_task(async function test_bad_pointer() {
+ await promiseWritePointer(ID2, ID1);
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+ equal(a1, null);
+ equal(a2, null);
+
+ let source = sourceDir.clone();
+ source.append(ID1);
+ ok(source.exists());
+
+ let pointer = profileDir.clone();
+ pointer.append(ID2);
+ ok(!pointer.exists());
+});
+
+// Tests that changing the ID of an existing add-on doesn't clobber the sources
+add_task(async function test_bad_pointer_id() {
+ let dir = sourceDir.clone();
+ dir.append(ID1);
+
+ // Make sure the modification time changes enough to be detected.
+ setExtensionModifiedTime(dir, dir.lastModifiedTime - 5000);
+ await promiseWritePointer(ID1);
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "1.0");
+
+ await promiseWriteWebExtension(dir.path, {
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID2 } },
+ },
+ });
+ setExtensionModifiedTime(dir, dir.lastModifiedTime - 5000);
+
+ await promiseRestartManager();
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+ equal(a1, null);
+ equal(a2, null);
+
+ let source = sourceDir.clone();
+ source.append(ID1);
+ ok(source.exists());
+
+ let pointer = profileDir.clone();
+ pointer.append(ID1);
+ ok(!pointer.exists());
+});
+
+// Removing the pointer file should uninstall the add-on
+add_task(async function test_remove_pointer() {
+ let dir = sourceDir.clone();
+ dir.append(ID1);
+
+ await promiseWriteWebExtension(dir.path, {
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID1 } },
+ },
+ });
+
+ setExtensionModifiedTime(dir, dir.lastModifiedTime - 5000);
+ await promiseWritePointer(ID1);
+
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "1.0");
+
+ let pointer = profileDir.clone();
+ pointer.append(ID1);
+ pointer.remove(false);
+
+ await promiseRestartManager();
+
+ addon = await AddonManager.getAddonByID(ID1);
+ equal(addon, null);
+});
+
+// Removing the pointer file and replacing it with a directory should work
+add_task(async function test_replace_pointer() {
+ await promiseWritePointer(ID1);
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "1.0");
+
+ let pointer = profileDir.clone();
+ pointer.append(ID1);
+ pointer.remove(false);
+
+ await promiseWriteWebExtension(PathUtils.join(profileDir.path, ID1), {
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: ID1 } },
+ },
+ });
+
+ await promiseRestartManager();
+
+ addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "2.0");
+
+ await addon.uninstall();
+});
+
+// Changes to the source files should be detected
+add_task(async function test_change_pointer_sources() {
+ await promiseWritePointer(ID1);
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "1.0");
+
+ let dir = sourceDir.clone();
+ dir.append(ID1);
+ await promiseWriteWebExtension(dir.path, {
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: ID1 } },
+ },
+ });
+ setExtensionModifiedTime(dir, dir.lastModifiedTime - 5000);
+
+ await promiseRestartManager();
+
+ addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "2.0");
+
+ await addon.uninstall();
+});
+
+// Removing the add-on the pointer file points at should uninstall the add-on
+add_task(async function test_remove_pointer_target() {
+ let target = PathUtils.join(sourceDir.path, ID1);
+ await promiseWriteWebExtension(target, {
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID1 } },
+ },
+ });
+ await promiseWritePointer(ID1);
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "1.0");
+
+ await IOUtils.remove(target, { recursive: true });
+
+ await promiseRestartManager();
+
+ addon = await AddonManager.getAddonByID(ID1);
+ equal(addon, null);
+
+ let pointer = profileDir.clone();
+ pointer.append(ID1);
+ ok(!pointer.exists());
+});
+
+// Tests that installing a new add-on by pointer with a relative path works
+add_task(async function test_new_relative_pointer() {
+ let target = PathUtils.join(sourceDir.path, ID1);
+ await promiseWriteWebExtension(target, {
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID1 } },
+ },
+ });
+ await promiseWriteRelativePointer(ID1);
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ equal(addon.version, "1.0");
+
+ let { file } = addon.getResourceURI().QueryInterface(Ci.nsIFileURL);
+ equal(file.parent.path, sourceDir.path);
+
+ let rootUri = do_get_addon_root_uri(sourceDir, ID1);
+ let uri = addon.getResourceURI();
+ equal(uri.spec, rootUri);
+
+ // Check that upgrade is disabled for addons installed by file-pointers.
+ equal(addon.permissions & AddonManager.PERM_CAN_UPGRADE, 0);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_general.js b/toolkit/mozapps/extensions/test/xpcshell/test_general.js
new file mode 100644
index 0000000000..896e69d6f8
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_general.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This just verifies that the EM can actually startup and shutdown a few times
+// without any errors
+
+// We have to look up how many add-ons are present since there will be plugins
+// etc. detected
+var gCount;
+
+async function run_test() {
+ do_test_pending();
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ await promiseStartupManager();
+ let list = await AddonManager.getAddonsByTypes(null);
+ gCount = list.length;
+
+ executeSoon(run_test_1);
+}
+
+async function run_test_1() {
+ await promiseRestartManager();
+
+ let addons = await AddonManager.getAddonsByTypes(null);
+ Assert.equal(gCount, addons.length);
+
+ executeSoon(run_test_2);
+}
+
+async function run_test_2() {
+ await promiseShutdownManager();
+
+ await promiseStartupManager();
+
+ let addons = await AddonManager.getAddonsByTypes(null);
+ Assert.equal(gCount, addons.length);
+
+ executeSoon(run_test_3);
+}
+
+async function run_test_3() {
+ await promiseRestartManager();
+
+ let addons = await AddonManager.getAddonsByTypes(null);
+ Assert.equal(gCount, addons.length);
+ do_test_finished();
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_getInstallSourceFromHost.js b/toolkit/mozapps/extensions/test/xpcshell/test_getInstallSourceFromHost.js
new file mode 100644
index 0000000000..1f9b65ca85
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_getInstallSourceFromHost.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+add_task(function test_getInstallSourceFromHost_helpers() {
+ const test_hostname =
+ AppConstants.MOZ_APP_NAME !== "thunderbird"
+ ? "addons.allizom.org"
+ : "addons-stage.thunderbird.net";
+
+ const sourceHostTestCases = [
+ {
+ host: test_hostname,
+ installSourceFromHost: "test-host",
+ },
+ {
+ host: "addons.mozilla.org",
+ installSourceFromHost: "amo",
+ },
+ {
+ host: "discovery.addons.mozilla.org",
+ installSourceFromHost: "disco",
+ },
+ {
+ host: "about:blank",
+ installSourceFromHost: "unknown",
+ },
+ {
+ host: "fake-extension-uuid",
+ installSourceFromHost: "unknown",
+ },
+ {
+ host: null,
+ installSourceFromHost: "unknown",
+ },
+ ];
+
+ for (let testCase of sourceHostTestCases) {
+ let { host, installSourceFromHost } = testCase;
+
+ equal(
+ AddonManager.getInstallSourceFromHost(host),
+ installSourceFromHost,
+ `Got the expected result from getInstallFromHost for host ${host}`
+ );
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_gmpProvider.js b/toolkit/mozapps/extensions/test/xpcshell/test_gmpProvider.js
new file mode 100644
index 0000000000..51b5735ae8
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_gmpProvider.js
@@ -0,0 +1,457 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { GMPTestUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/GMPProvider.sys.mjs"
+);
+const { GMPInstallManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/GMPInstallManager.sys.mjs"
+);
+const { GMPPrefs, GMP_PLUGIN_IDS, OPEN_H264_ID, WIDEVINE_ID } =
+ ChromeUtils.importESModule("resource://gre/modules/GMPUtils.sys.mjs");
+const { UpdateUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/UpdateUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyGetter(
+ this,
+ "pluginsBundle",
+ () => new Localization(["toolkit/about/aboutPlugins.ftl"])
+);
+
+var gMockAddons = new Map();
+var gMockEmeAddons = new Map();
+
+const mockH264Addon = Object.freeze({
+ id: OPEN_H264_ID,
+ isValid: true,
+ isInstalled: false,
+ nameId: "plugins-openh264-name",
+ descriptionId: "plugins-openh264-description",
+});
+gMockAddons.set(mockH264Addon.id, mockH264Addon);
+
+const mockWidevineAddon = Object.freeze({
+ id: WIDEVINE_ID,
+ isValid: true,
+ isInstalled: false,
+ nameId: "plugins-widevine-name",
+ descriptionId: "plugins-widevine-description",
+});
+gMockAddons.set(mockWidevineAddon.id, mockWidevineAddon);
+gMockEmeAddons.set(mockWidevineAddon.id, mockWidevineAddon);
+
+var gInstalledAddonId = "";
+var gPrefs = Services.prefs;
+var gGetKey = GMPPrefs.getPrefKey;
+
+const MockGMPInstallManagerPrototype = {
+ checkForAddons: () =>
+ Promise.resolve({
+ usedFallback: true,
+ addons: [...gMockAddons.values()],
+ }),
+
+ installAddon: addon => {
+ gInstalledAddonId = addon.id;
+ return Promise.resolve();
+ },
+};
+
+add_setup(async () => {
+ Assert.deepEqual(
+ GMP_PLUGIN_IDS,
+ Array.from(gMockAddons.keys()),
+ "set of mock addons matches the actual set of plugins"
+ );
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ // The GMPProvider does not register until the first content process
+ // is launched, so we simulate that by firing this notification.
+ Services.obs.notifyObservers(null, "ipc:first-content-process-created");
+
+ await promiseStartupManager();
+
+ gPrefs.setBoolPref(GMPPrefs.KEY_LOGGING_DUMP, true);
+ gPrefs.setIntPref(GMPPrefs.KEY_LOGGING_LEVEL, 0);
+ gPrefs.setBoolPref(GMPPrefs.KEY_EME_ENABLED, true);
+ for (let addon of gMockAddons.values()) {
+ gPrefs.setBoolPref(gGetKey(GMPPrefs.KEY_PLUGIN_VISIBLE, addon.id), true);
+ gPrefs.setBoolPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_FORCE_SUPPORTED, addon.id),
+ true
+ );
+ }
+});
+
+add_task(async function test_notInstalled() {
+ for (let addon of gMockAddons.values()) {
+ gPrefs.setCharPref(gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id), "");
+ gPrefs.setBoolPref(gGetKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id), false);
+ }
+
+ let addons = await promiseAddonsByIDs([...gMockAddons.keys()]);
+ Assert.equal(addons.length, gMockAddons.size);
+
+ for (let addon of addons) {
+ Assert.ok(!addon.isInstalled);
+ Assert.equal(addon.type, "plugin");
+ Assert.equal(addon.version, "");
+
+ let mockAddon = gMockAddons.get(addon.id);
+
+ Assert.notEqual(mockAddon, null);
+ let name = await pluginsBundle.formatValue(mockAddon.nameId);
+ Assert.equal(addon.name, name);
+ let description = await pluginsBundle.formatValue(mockAddon.descriptionId);
+ Assert.equal(addon.description, description);
+
+ Assert.ok(!addon.isActive);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.userDisabled);
+
+ Assert.equal(
+ addon.blocklistState,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ Assert.equal(addon.scope, AddonManager.SCOPE_APPLICATION);
+ Assert.equal(addon.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.equal(addon.operationsRequiringRestart, AddonManager.PENDING_NONE);
+
+ Assert.equal(
+ addon.permissions,
+ AddonManager.PERM_CAN_UPGRADE | AddonManager.PERM_CAN_ENABLE
+ );
+
+ Assert.equal(addon.updateDate, null);
+
+ Assert.ok(addon.isCompatible);
+ Assert.ok(addon.isPlatformCompatible);
+ Assert.ok(addon.providesUpdatesSecurely);
+ Assert.ok(!addon.foreignInstall);
+
+ let libraries = addon.pluginLibraries;
+ Assert.ok(libraries);
+ Assert.equal(libraries.length, 0);
+ Assert.equal(addon.pluginFullpath, "");
+ }
+});
+
+add_task(async function test_installed() {
+ const TEST_DATE = new Date(2013, 0, 1, 12);
+ const TEST_VERSION = "1.2.3.4";
+ const TEST_TIME_SEC = Math.round(TEST_DATE.getTime() / 1000);
+
+ let addons = await promiseAddonsByIDs([...gMockAddons.keys()]);
+ Assert.equal(addons.length, gMockAddons.size);
+
+ for (let addon of addons) {
+ let mockAddon = gMockAddons.get(addon.id);
+ Assert.notEqual(mockAddon, null);
+
+ let file = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ file.append(addon.id);
+ file.append(TEST_VERSION);
+ gPrefs.setBoolPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_ENABLED, mockAddon.id),
+ false
+ );
+ gPrefs.setIntPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, mockAddon.id),
+ TEST_TIME_SEC
+ );
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, mockAddon.id),
+ TEST_VERSION
+ );
+
+ Assert.ok(addon.isInstalled);
+ Assert.equal(addon.type, "plugin");
+ Assert.ok(!addon.isActive);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.userDisabled);
+
+ let name = await pluginsBundle.formatValue(mockAddon.nameId);
+ Assert.equal(addon.name, name);
+ Assert.equal(addon.version, TEST_VERSION);
+
+ Assert.equal(
+ addon.permissions,
+ AddonManager.PERM_CAN_UPGRADE | AddonManager.PERM_CAN_ENABLE
+ );
+
+ Assert.equal(addon.updateDate.getTime(), TEST_TIME_SEC * 1000);
+
+ let libraries = addon.pluginLibraries;
+ Assert.ok(libraries);
+ Assert.equal(libraries.length, 1);
+ Assert.equal(libraries[0], TEST_VERSION);
+ let fullpath = addon.pluginFullpath;
+ Assert.equal(fullpath.length, 1);
+ Assert.equal(fullpath[0], file.path);
+ }
+});
+
+add_task(async function test_enable() {
+ let addons = await promiseAddonsByIDs([...gMockAddons.keys()]);
+ Assert.equal(addons.length, gMockAddons.size);
+
+ for (let addon of addons) {
+ gPrefs.setBoolPref(gGetKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id), true);
+
+ Assert.ok(addon.isActive);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(!addon.userDisabled);
+
+ Assert.equal(
+ addon.permissions,
+ AddonManager.PERM_CAN_UPGRADE | AddonManager.PERM_CAN_DISABLE
+ );
+ }
+});
+
+add_task(async function test_globalEmeDisabled() {
+ let addons = await promiseAddonsByIDs([...gMockEmeAddons.keys()]);
+ Assert.equal(addons.length, gMockEmeAddons.size);
+
+ gPrefs.setBoolPref(GMPPrefs.KEY_EME_ENABLED, false);
+ for (let addon of addons) {
+ Assert.ok(!addon.isActive);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.userDisabled);
+
+ Assert.equal(addon.permissions, 0);
+ }
+ gPrefs.setBoolPref(GMPPrefs.KEY_EME_ENABLED, true);
+});
+
+add_task(async function test_autoUpdatePrefPersistance() {
+ let addons = await promiseAddonsByIDs([...gMockAddons.keys()]);
+ Assert.equal(addons.length, gMockAddons.size);
+
+ for (let addon of addons) {
+ let autoupdateKey = gGetKey(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, addon.id);
+ gPrefs.clearUserPref(autoupdateKey);
+
+ addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DISABLE;
+ Assert.ok(!gPrefs.getBoolPref(autoupdateKey));
+
+ addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_ENABLE;
+ Assert.equal(addon.applyBackgroundUpdates, AddonManager.AUTOUPDATE_ENABLE);
+ Assert.ok(gPrefs.getBoolPref(autoupdateKey));
+
+ addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT;
+ Assert.ok(!gPrefs.prefHasUserValue(autoupdateKey));
+ }
+});
+
+function createMockPluginFilesIfNeeded(aFile, aPluginId) {
+ function createFile(aFileName) {
+ let f = aFile.clone();
+ f.append(aFileName);
+ if (!f.exists()) {
+ f.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ }
+ }
+
+ let id = aPluginId.substring(4);
+ let libName = AppConstants.DLL_PREFIX + id + AppConstants.DLL_SUFFIX;
+
+ createFile(libName);
+ if (aPluginId == WIDEVINE_ID) {
+ createFile("manifest.json");
+ } else {
+ createFile(id + ".info");
+ }
+}
+
+add_task(async function test_pluginRegistration() {
+ const TEST_VERSION = "1.2.3.4";
+
+ let addedPaths = [];
+ let removedPaths = [];
+ let clearPaths = () => {
+ addedPaths = [];
+ removedPaths = [];
+ };
+
+ const MockGMPService = {
+ addPluginDirectory: path => {
+ if (!addedPaths.includes(path)) {
+ addedPaths.push(path);
+ }
+ },
+ removePluginDirectory: path => {
+ if (!removedPaths.includes(path)) {
+ removedPaths.push(path);
+ }
+ },
+ removeAndDeletePluginDirectory: path => {
+ if (!removedPaths.includes(path)) {
+ removedPaths.push(path);
+ }
+ },
+ };
+
+ let profD = do_get_profile();
+ for (let addon of gMockAddons.values()) {
+ await GMPTestUtils.overrideGmpService(MockGMPService, () =>
+ testAddon(addon)
+ );
+ }
+
+ async function testAddon(addon) {
+ let file = profD.clone();
+ file.append(addon.id);
+ file.append(TEST_VERSION);
+
+ gPrefs.setBoolPref(gGetKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id), true);
+
+ // Test that plugin registration fails if the plugin dynamic library and
+ // info files are not present.
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id),
+ TEST_VERSION
+ );
+ clearPaths();
+ await promiseRestartManager();
+ Assert.equal(addedPaths.indexOf(file.path), -1);
+ Assert.deepEqual(removedPaths, [file.path]);
+
+ // Create dummy GMP library/info files, and test that plugin registration
+ // succeeds during startup, now that we've added GMP info/lib files.
+ createMockPluginFilesIfNeeded(file, addon.id);
+
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id),
+ TEST_VERSION
+ );
+ clearPaths();
+ await promiseRestartManager();
+ Assert.notEqual(addedPaths.indexOf(file.path), -1);
+ Assert.deepEqual(removedPaths, []);
+
+ // Setting the ABI to something invalid should cause plugin to be removed at startup.
+ clearPaths();
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_ABI, addon.id),
+ "invalid-ABI"
+ );
+ await promiseRestartManager();
+ Assert.equal(addedPaths.indexOf(file.path), -1);
+ Assert.deepEqual(removedPaths, [file.path]);
+
+ // Setting the ABI to expected ABI should cause registration at startup.
+ clearPaths();
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id),
+ TEST_VERSION
+ );
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_ABI, addon.id),
+ UpdateUtils.ABI
+ );
+ await promiseRestartManager();
+ Assert.notEqual(addedPaths.indexOf(file.path), -1);
+ Assert.deepEqual(removedPaths, []);
+
+ // Check that clearing the version doesn't trigger registration.
+ clearPaths();
+ gPrefs.clearUserPref(gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id));
+ Assert.deepEqual(addedPaths, []);
+ Assert.deepEqual(removedPaths, [file.path]);
+
+ // Restarting with no version set should not trigger registration.
+ clearPaths();
+ await promiseRestartManager();
+ Assert.equal(addedPaths.indexOf(file.path), -1);
+ Assert.equal(removedPaths.indexOf(file.path), -1);
+
+ // Changing the pref mid-session should cause unregistration and registration.
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id),
+ TEST_VERSION
+ );
+ clearPaths();
+ const TEST_VERSION_2 = "5.6.7.8";
+ let file2 = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ file2.append(addon.id);
+ file2.append(TEST_VERSION_2);
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id),
+ TEST_VERSION_2
+ );
+ Assert.deepEqual(addedPaths, [file2.path]);
+ Assert.deepEqual(removedPaths, [file.path]);
+
+ // Disabling the plugin should cause unregistration.
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id),
+ TEST_VERSION
+ );
+ clearPaths();
+ gPrefs.setBoolPref(gGetKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id), false);
+ Assert.deepEqual(addedPaths, []);
+ Assert.deepEqual(removedPaths, [file.path]);
+
+ // Restarting with the plugin disabled should not cause registration.
+ clearPaths();
+ await promiseRestartManager();
+ Assert.equal(addedPaths.indexOf(file.path), -1);
+ Assert.equal(removedPaths.indexOf(file.path), -1);
+
+ // Re-enabling the plugin should cause registration.
+ clearPaths();
+ gPrefs.setBoolPref(gGetKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id), true);
+ Assert.deepEqual(addedPaths, [file.path]);
+ Assert.deepEqual(removedPaths, []);
+ }
+});
+
+add_task(async function test_periodicUpdate() {
+ // The GMPInstallManager constructor has an empty body,
+ // so replacing the prototype is safe.
+ let originalInstallManager = GMPInstallManager.prototype;
+ GMPInstallManager.prototype = MockGMPInstallManagerPrototype;
+
+ let addons = await promiseAddonsByIDs([...gMockAddons.keys()]);
+ Assert.equal(addons.length, gMockAddons.size);
+
+ for (let addon of addons) {
+ gPrefs.clearUserPref(gGetKey(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, addon.id));
+
+ addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DISABLE;
+ gPrefs.setIntPref(GMPPrefs.KEY_UPDATE_LAST_CHECK, 0);
+ let result = await addon.findUpdates(
+ {},
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ Assert.strictEqual(result, false);
+
+ addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_ENABLE;
+ gPrefs.setIntPref(GMPPrefs.KEY_UPDATE_LAST_CHECK, Date.now() / 1000 - 60);
+ result = await addon.findUpdates(
+ {},
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ Assert.strictEqual(result, false);
+
+ const SEC_IN_A_DAY = 24 * 60 * 60;
+ gPrefs.setIntPref(
+ GMPPrefs.KEY_UPDATE_LAST_CHECK,
+ Date.now() / 1000 - 2 * SEC_IN_A_DAY
+ );
+ gInstalledAddonId = "";
+ result = await addon.findUpdates(
+ {},
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ Assert.strictEqual(result, true);
+ Assert.equal(gInstalledAddonId, addon.id);
+ }
+
+ GMPInstallManager.prototype = originalInstallManager;
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_harness.js b/toolkit/mozapps/extensions/test/xpcshell/test_harness.js
new file mode 100644
index 0000000000..8be3cdcf22
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_harness.js
@@ -0,0 +1,13 @@
+"use strict";
+
+// Test that the test harness is sane.
+
+// Test that the temporary directory is actually overridden in the
+// directory service.
+add_task(async function test_TmpD_override() {
+ equal(
+ FileUtils.getDir("TmpD", []).path,
+ AddonTestUtils.tempDir.path,
+ "Should get the correct temporary directory from the directory service"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_hidden.js b/toolkit/mozapps/extensions/test/xpcshell/test_hidden.js
new file mode 100644
index 0000000000..3d1c187b81
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_hidden.js
@@ -0,0 +1,251 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged");
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_hidden() {
+ let xpi1 = createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "privileged@tests.mozilla.org",
+ },
+ },
+
+ name: "Hidden Extension",
+ hidden: true,
+ },
+ });
+
+ let xpi2 = createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "unprivileged@tests.mozilla.org",
+ },
+ },
+
+ name: "Non-Hidden Extension",
+ hidden: true,
+ },
+ });
+
+ await promiseInstallAllFiles([xpi1, xpi2]);
+
+ let [addon1, addon2] = await promiseAddonsByIDs([
+ "privileged@tests.mozilla.org",
+ "unprivileged@tests.mozilla.org",
+ ]);
+
+ ok(addon1.isPrivileged, "Privileged is privileged");
+ ok(addon1.hidden, "Privileged extension should be hidden");
+ ok(!addon2.isPrivileged, "Unprivileged extension is not privileged");
+ ok(!addon2.hidden, "Unprivileged extension should not be hidden");
+
+ await promiseRestartManager();
+
+ [addon1, addon2] = await promiseAddonsByIDs([
+ "privileged@tests.mozilla.org",
+ "unprivileged@tests.mozilla.org",
+ ]);
+
+ ok(addon1.isPrivileged, "Privileged is privileged");
+ ok(addon1.hidden, "Privileged extension should be hidden");
+ ok(!addon2.isPrivileged, "Unprivileged extension is not privileged");
+ ok(!addon2.hidden, "Unprivileged extension should not be hidden");
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "privileged@but-temporary" } },
+ hidden: true,
+ },
+ });
+ await extension.startup();
+ let tempAddon = extension.addon;
+ ok(tempAddon.isPrivileged, "Temporary add-on is privileged");
+ ok(
+ !tempAddon.hidden,
+ "Temporary add-on is not hidden despite being privileged"
+ );
+ await extension.unload();
+});
+
+add_task(
+ {
+ pref_set: [["extensions.manifestV3.enabled", true]],
+ },
+ async function test_hidden_and_browser_action_props_are_mutually_exclusive() {
+ const TEST_CASES = [
+ {
+ title: "hidden and browser_action",
+ manifest: {
+ hidden: true,
+ browser_action: {},
+ },
+ expectError: true,
+ },
+ {
+ title: "hidden and page_action",
+ manifest: {
+ hidden: true,
+ page_action: {},
+ },
+ expectError: true,
+ },
+ {
+ title: "hidden, browser_action and page_action",
+ manifest: {
+ hidden: true,
+ browser_action: {},
+ page_action: {},
+ },
+ expectError: true,
+ },
+ {
+ title: "hidden and no browser_action or page_action",
+ manifest: {
+ hidden: true,
+ },
+ expectError: false,
+ },
+ {
+ title: "not hidden and browser_action",
+ manifest: {
+ hidden: false,
+ browser_action: {},
+ },
+ expectError: false,
+ },
+ {
+ title: "not hidden and page_action",
+ manifest: {
+ hidden: false,
+ page_action: {},
+ },
+ expectError: false,
+ },
+ {
+ title: "no hidden prop and browser_action",
+ manifest: {
+ browser_action: {},
+ },
+ expectError: false,
+ },
+ {
+ title: "hidden and action",
+ manifest: {
+ manifest_version: 3,
+ hidden: true,
+ action: {},
+ },
+ expectError: true,
+ },
+ {
+ title: "hidden, action and page_action",
+ manifest: {
+ manifest_version: 3,
+ hidden: true,
+ action: {},
+ page_action: {},
+ },
+ expectError: true,
+ },
+ {
+ title: "no hidden prop and action",
+ manifest: {
+ manifest_version: 3,
+ action: {},
+ },
+ expectError: false,
+ },
+ {
+ title: "no hidden prop and page_action",
+ manifest: {
+ page_action: {},
+ },
+ expectError: false,
+ },
+ {
+ title: "hidden and action but not privileged",
+ manifest: {
+ manifest_version: 3,
+ hidden: true,
+ action: {},
+ },
+ expectError: false,
+ isPrivileged: false,
+ },
+ {
+ title: "hidden and browser_action but not privileged",
+ manifest: {
+ hidden: true,
+ browser_action: {},
+ },
+ expectError: false,
+ isPrivileged: false,
+ },
+ {
+ title: "hidden and page_action but not privileged",
+ manifest: {
+ hidden: true,
+ page_action: {},
+ },
+ expectError: false,
+ isPrivileged: false,
+ },
+ ];
+
+ let count = 0;
+
+ for (const {
+ title,
+ manifest,
+ expectError,
+ isPrivileged = true,
+ } of TEST_CASES) {
+ info(`== ${title} ==`);
+
+ // Thunderbird doesn't have page actions.
+ if (manifest.page_action && AppConstants.MOZ_APP_NAME == "thunderbird") {
+ continue;
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: `${isPrivileged ? "" : "not-"}privileged@ext-${count++}`,
+ },
+ },
+ permissions: ["mozillaAddons"],
+ ...manifest,
+ },
+ background() {
+ /* globals browser */
+ browser.test.sendMessage("ok");
+ },
+ isPrivileged,
+ });
+
+ if (expectError) {
+ await Assert.rejects(
+ extension.startup(),
+ /Cannot use browser and\/or page actions in hidden add-ons/,
+ "expected extension not started"
+ );
+ } else {
+ await extension.startup();
+ await extension.awaitMessage("ok");
+ await extension.unload();
+ }
+ }
+ }
+);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_install.js b/toolkit/mozapps/extensions/test/xpcshell/test_install.js
new file mode 100644
index 0000000000..2d2ee3df24
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_install.js
@@ -0,0 +1,1050 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+var testserver = createHttpServer({ hosts: ["example.com"] });
+var gInstallDate;
+
+const ADDONS = {
+ test_install1: {
+ manifest: {
+ name: "Test 1",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: "addon1@tests.mozilla.org" } },
+ },
+ },
+ test_install2_1: {
+ manifest: {
+ name: "Test 2",
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: "addon2@tests.mozilla.org" } },
+ },
+ },
+ test_install2_2: {
+ manifest: {
+ name: "Test 2",
+ version: "3.0",
+ browser_specific_settings: { gecko: { id: "addon2@tests.mozilla.org" } },
+ },
+ },
+ test_install3: {
+ manifest: {
+ name: "Test 3",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon3@tests.mozilla.org",
+ strict_min_version: "0",
+ strict_max_version: "0",
+ update_url: "http://example.com/update.json",
+ },
+ },
+ },
+ },
+};
+
+const XPIS = {};
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, false);
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const UPDATE_JSON = {
+ addons: {
+ "addon3@tests.mozilla.org": {
+ updates: [
+ {
+ version: "1.0",
+ applications: {
+ gecko: {
+ strict_min_version: "0",
+ strict_max_version: "2",
+ },
+ },
+ },
+ ],
+ },
+ },
+};
+
+const GETADDONS_JSON = {
+ page_size: 25,
+ page_count: 1,
+ count: 1,
+ next: null,
+ previous: null,
+ results: [
+ {
+ name: "Test 2",
+ type: "extension",
+ guid: "addon2@tests.mozilla.org",
+ current_version: {
+ version: "1.0",
+ files: [
+ {
+ size: 2,
+ url: "http://example.com/test_install2_1.xpi",
+ },
+ ],
+ },
+ authors: [
+ {
+ name: "Test Creator",
+ url: "http://example.com/creator.html",
+ },
+ ],
+ summary: "Repository summary",
+ description: "Repository description",
+ },
+ ],
+};
+
+function checkInstall(install, expected) {
+ for (let [key, value] of Object.entries(expected)) {
+ if (value instanceof Ci.nsIURI) {
+ equal(
+ install[key] && install[key].spec,
+ value.spec,
+ `Expected value of install.${key}`
+ );
+ } else {
+ deepEqual(install[key], value, `Expected value of install.${key}`);
+ }
+ }
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ for (let [name, data] of Object.entries(ADDONS)) {
+ XPIS[name] = AddonTestUtils.createTempWebExtensionFile(data);
+ testserver.registerFile(`/addons/${name}.xpi`, XPIS[name]);
+ }
+
+ await promiseStartupManager();
+
+ // Create and configure the HTTP server.
+ AddonTestUtils.registerJSON(testserver, "/update.json", UPDATE_JSON);
+ testserver.registerDirectory("/data/", do_get_file("data"));
+ testserver.registerPathHandler("/redirect", function (aRequest, aResponse) {
+ aResponse.setStatusLine(null, 301, "Moved Permanently");
+ let url = aRequest.host + ":" + aRequest.port + aRequest.queryString;
+ aResponse.setHeader("Location", "http://" + url);
+ });
+ gPort = testserver.identity.primaryPort;
+});
+
+// Checks that an install from a local file proceeds as expected
+add_task(async function test_install_file() {
+ let [, install] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onNewInstall"),
+ AddonManager.getInstallForFile(XPIS.test_install1),
+ ]);
+
+ let uri = Services.io.newFileURI(XPIS.test_install1);
+ checkInstall(install, {
+ type: "extension",
+ version: "1.0",
+ name: "Test 1",
+ state: AddonManager.STATE_DOWNLOADED,
+ sourceURI: uri,
+ });
+
+ let { addon } = install;
+ checkAddon("addon1@tests.mozilla.org", addon, {
+ install,
+ sourceURI: uri,
+ });
+ notEqual(addon.syncGUID, null);
+ equal(
+ addon.getResourceURI("manifest.json").spec,
+ `jar:${uri.spec}!/manifest.json`
+ );
+
+ let activeInstalls = await AddonManager.getAllInstalls();
+ equal(activeInstalls.length, 1);
+ equal(activeInstalls[0], install);
+
+ let fooInstalls = await AddonManager.getInstallsByTypes(["foo"]);
+ equal(fooInstalls.length, 0);
+
+ let extensionInstalls = await AddonManager.getInstallsByTypes(["extension"]);
+ equal(extensionInstalls.length, 1);
+ equal(extensionInstalls[0], install);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "addon1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ },
+ () => install.install()
+ );
+
+ addon = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ ok(addon);
+
+ ok(!hasFlag(addon.permissions, AddonManager.PERM_CAN_ENABLE));
+ ok(hasFlag(addon.permissions, AddonManager.PERM_CAN_DISABLE));
+
+ let updateDate = Date.now();
+
+ await promiseRestartManager();
+
+ activeInstalls = await AddonManager.getAllInstalls();
+ equal(activeInstalls, 0);
+
+ let a1 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ let uri2 = do_get_addon_root_uri(profileDir, "addon1@tests.mozilla.org");
+
+ checkAddon("addon1@tests.mozilla.org", a1, {
+ type: "extension",
+ version: "1.0",
+ name: "Test 1",
+ foreignInstall: false,
+ sourceURI: Services.io.newFileURI(XPIS.test_install1),
+ });
+
+ notEqual(a1.syncGUID, null);
+ ok(a1.syncGUID.length >= 9);
+
+ ok(isExtensionInBootstrappedList(profileDir, a1.id));
+ ok(XPIS.test_install1.exists());
+ do_check_in_crash_annotation(a1.id, a1.version);
+
+ let difference = a1.installDate.getTime() - updateDate;
+ if (Math.abs(difference) > MAX_TIME_DIFFERENCE) {
+ do_throw("Add-on install time was out by " + difference + "ms");
+ }
+
+ difference = a1.updateDate.getTime() - updateDate;
+ if (Math.abs(difference) > MAX_TIME_DIFFERENCE) {
+ do_throw("Add-on update time was out by " + difference + "ms");
+ }
+
+ equal(a1.getResourceURI("manifest.json").spec, uri2 + "manifest.json");
+
+ // Ensure that extension bundle (or icon if unpacked) has updated
+ // lastModifiedDate.
+ let testFile = getAddonFile(a1);
+ ok(testFile.exists());
+ difference = testFile.lastModifiedTime - Date.now();
+ ok(Math.abs(difference) < MAX_TIME_DIFFERENCE);
+
+ await a1.uninstall();
+ let { id, version } = a1;
+ await promiseRestartManager();
+ do_check_not_in_crash_annotation(id, version);
+});
+
+// Tests that an install from a url downloads.
+add_task(async function test_install_url() {
+ let url = "http://example.com/addons/test_install2_1.xpi";
+ let install = await AddonManager.getInstallForURL(url, {
+ name: "Test 2",
+ version: "1.0",
+ });
+ checkInstall(install, {
+ version: "1.0",
+ name: "Test 2",
+ state: AddonManager.STATE_AVAILABLE,
+ sourceURI: Services.io.newURI(url),
+ });
+
+ let activeInstalls = await AddonManager.getAllInstalls();
+ equal(activeInstalls.length, 1);
+ equal(activeInstalls[0], install);
+
+ await expectEvents(
+ {
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded", returnValue: false },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ checkInstall(install, {
+ version: "2.0",
+ name: "Test 2",
+ state: AddonManager.STATE_DOWNLOADED,
+ });
+ equal(install.addon.install, install);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "addon2@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => install.install()
+ );
+
+ let updateDate = Date.now();
+
+ await promiseRestartManager();
+
+ let installs = await AddonManager.getAllInstalls();
+ equal(installs, 0);
+
+ let a2 = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ checkAddon("addon2@tests.mozilla.org", a2, {
+ type: "extension",
+ version: "2.0",
+ name: "Test 2",
+ sourceURI: Services.io.newURI(url),
+ });
+ notEqual(a2.syncGUID, null);
+
+ ok(isExtensionInBootstrappedList(profileDir, a2.id));
+ ok(XPIS.test_install2_1.exists());
+ do_check_in_crash_annotation(a2.id, a2.version);
+
+ let difference = a2.installDate.getTime() - updateDate;
+ Assert.lessOrEqual(
+ Math.abs(difference),
+ MAX_TIME_DIFFERENCE,
+ "Add-on install time was correct"
+ );
+
+ difference = a2.updateDate.getTime() - updateDate;
+ Assert.lessOrEqual(
+ Math.abs(difference),
+ MAX_TIME_DIFFERENCE,
+ "Add-on update time was correct"
+ );
+
+ gInstallDate = a2.installDate;
+});
+
+// Tests that installing a new version of an existing add-on works
+add_task(async function test_install_new_version() {
+ let url = "http://example.com/addons/test_install2_2.xpi";
+ let [, install] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onNewInstall"),
+ AddonManager.getInstallForURL(url, {
+ name: "Test 2",
+ version: "3.0",
+ }),
+ ]);
+
+ checkInstall(install, {
+ version: "3.0",
+ name: "Test 2",
+ state: AddonManager.STATE_AVAILABLE,
+ existingAddon: null,
+ });
+
+ let activeInstalls = await AddonManager.getAllInstalls();
+ equal(activeInstalls.length, 1);
+ equal(activeInstalls[0], install);
+
+ await expectEvents(
+ {
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded", returnValue: false },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ checkInstall(install, {
+ version: "3.0",
+ name: "Test 2",
+ state: AddonManager.STATE_DOWNLOADED,
+ existingAddon: await AddonManager.getAddonByID("addon2@tests.mozilla.org"),
+ });
+
+ equal(install.addon.install, install);
+
+ // Installation will continue when there is nothing returned.
+ await expectEvents(
+ {
+ addonEvents: {
+ "addon2@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => install.install()
+ );
+
+ await promiseRestartManager();
+
+ let installs2 = await AddonManager.getInstallsByTypes(null);
+ equal(installs2.length, 0);
+
+ let a2 = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ checkAddon("addon2@tests.mozilla.org", a2, {
+ type: "extension",
+ version: "3.0",
+ name: "Test 2",
+ isActive: true,
+ foreignInstall: false,
+ sourceURI: Services.io.newURI(url),
+ installDate: gInstallDate,
+ });
+
+ ok(isExtensionInBootstrappedList(profileDir, a2.id));
+ ok(XPIS.test_install2_2.exists());
+ do_check_in_crash_annotation(a2.id, a2.version);
+
+ // Update date should be later (or the same if this test is too fast)
+ ok(a2.installDate <= a2.updateDate);
+
+ await a2.uninstall();
+});
+
+// Tests that an install that requires a compatibility update works
+add_task(async function test_install_compat_update() {
+ let url = "http://example.com/addons/test_install3.xpi";
+ let [, install] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onNewInstall"),
+ await AddonManager.getInstallForURL(url, {
+ name: "Test 3",
+ version: "1.0",
+ }),
+ ]);
+
+ checkInstall(install, {
+ version: "1.0",
+ name: "Test 3",
+ state: AddonManager.STATE_AVAILABLE,
+ });
+
+ let activeInstalls = await AddonManager.getInstallsByTypes(null);
+ equal(activeInstalls.length, 1);
+ equal(activeInstalls[0], install);
+
+ await expectEvents(
+ {
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded", returnValue: false },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ checkInstall(install, {
+ version: "1.0",
+ name: "Test 3",
+ state: AddonManager.STATE_DOWNLOADED,
+ existingAddon: null,
+ });
+ checkAddon("addon3@tests.mozilla.org", install.addon, {
+ appDisabled: false,
+ });
+
+ // Continue the install
+ await expectEvents(
+ {
+ addonEvents: {
+ "addon3@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => install.install()
+ );
+
+ await promiseRestartManager();
+
+ let installs = await AddonManager.getAllInstalls();
+ equal(installs, 0);
+
+ let a3 = await AddonManager.getAddonByID("addon3@tests.mozilla.org");
+ checkAddon("addon3@tests.mozilla.org", a3, {
+ type: "extension",
+ version: "1.0",
+ name: "Test 3",
+ isActive: true,
+ appDisabled: false,
+ });
+ notEqual(a3.syncGUID, null);
+
+ ok(isExtensionInBootstrappedList(profileDir, a3.id));
+
+ ok(XPIS.test_install3.exists());
+ await a3.uninstall();
+});
+
+add_task(async function test_compat_update_local() {
+ let [, install] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onNewInstall"),
+ AddonManager.getInstallForFile(XPIS.test_install3),
+ ]);
+ ok(install.addon.isCompatible);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "addon3@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => install.install()
+ );
+
+ await promiseRestartManager();
+
+ let a3 = await AddonManager.getAddonByID("addon3@tests.mozilla.org");
+ checkAddon("addon3@tests.mozilla.org", a3, {
+ type: "extension",
+ version: "1.0",
+ name: "Test 3",
+ isActive: true,
+ appDisabled: false,
+ });
+ notEqual(a3.syncGUID, null);
+
+ ok(isExtensionInBootstrappedList(profileDir, a3.id));
+
+ ok(XPIS.test_install3.exists());
+ await a3.uninstall();
+});
+
+// Test that after cancelling a download it is removed from the active installs
+add_task(async function test_cancel() {
+ let url = "http://example.com/addons/test_install3.xpi";
+ let [, install] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onNewInstall"),
+ AddonManager.getInstallForURL(url, {
+ name: "Test 3",
+ version: "1.0",
+ }),
+ ]);
+
+ checkInstall(install, {
+ version: "1.0",
+ name: "Test 3",
+ state: AddonManager.STATE_AVAILABLE,
+ });
+
+ let activeInstalls = await AddonManager.getInstallsByTypes(null);
+ equal(activeInstalls.length, 1);
+ equal(activeInstalls[0], install);
+
+ let promise;
+ function cancel() {
+ promise = expectEvents(
+ {
+ installEvents: [{ event: "onDownloadCancelled" }],
+ },
+ () => {
+ install.cancel();
+ }
+ );
+ }
+
+ await expectEvents(
+ {
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded", callback: cancel },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ await promise;
+
+ let file = install.file;
+
+ // Allow the file removal to complete
+ activeInstalls = await AddonManager.getAllInstalls();
+ equal(activeInstalls.length, 0);
+ ok(!file.exists());
+});
+
+// Check that cancelling the install from onDownloadStarted actually cancels it
+add_task(async function test_cancel_onDownloadStarted() {
+ let url = "http://example.com/addons/test_install2_1.xpi";
+ let [, install] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onNewInstall"),
+ AddonManager.getInstallForURL(url),
+ ]);
+
+ equal(install.file, null);
+
+ install.addListener({
+ onDownloadStarted() {
+ install.removeListener(this);
+ executeSoon(() => install.cancel());
+ },
+ });
+
+ let promise = AddonTestUtils.promiseInstallEvent("onDownloadCancelled");
+ install.install();
+ await promise;
+
+ // Wait another tick to see if it continues downloading.
+ // The listener only really tests if we give it time to see progress, the
+ // file check isn't ideal either
+ install.addListener({
+ onDownloadProgress() {
+ do_throw("Download should not have continued");
+ },
+ onDownloadEnded() {
+ do_throw("Download should not have continued");
+ },
+ });
+
+ let file = install.file;
+ await Promise.resolve();
+ ok(!file.exists());
+});
+
+// Checks that cancelling the install from onDownloadEnded actually cancels it
+add_task(async function test_cancel_onDownloadEnded() {
+ let url = "http://example.com/addons/test_install2_1.xpi";
+ let [, install] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onNewInstall"),
+ AddonManager.getInstallForURL(url),
+ ]);
+
+ equal(install.file, null);
+
+ let promise;
+ function cancel() {
+ promise = expectEvents(
+ {
+ installEvents: [{ event: "onDownloadCancelled" }],
+ },
+ async () => {
+ install.cancel();
+ }
+ );
+ }
+
+ await expectEvents(
+ {
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded", callback: cancel },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ await promise;
+
+ install.addListener({
+ onInstallStarted() {
+ do_throw("Install should not have continued");
+ },
+ });
+});
+
+// Verify that the userDisabled value carries over to the upgrade by default
+add_task(async function test_userDisabled_update() {
+ let url = "http://example.com/addons/test_install2_1.xpi";
+ let [, install] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onNewInstall"),
+ AddonManager.getInstallForURL(url),
+ ]);
+
+ await install.install();
+
+ ok(!install.addon.userDisabled);
+ await install.addon.disable();
+
+ let addon = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ checkAddon("addon2@tests.mozilla.org", addon, {
+ userDisabled: true,
+ isActive: false,
+ });
+
+ url = "http://example.com/addons/test_install2_2.xpi";
+ install = await AddonManager.getInstallForURL(url);
+ await install.install();
+
+ checkAddon("addon2@tests.mozilla.org", install.addon, {
+ userDisabled: true,
+ isActive: false,
+ });
+
+ await promiseRestartManager();
+
+ addon = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ checkAddon("addon2@tests.mozilla.org", addon, {
+ userDisabled: true,
+ isActive: false,
+ });
+
+ await addon.uninstall();
+});
+
+// Verify that changing the userDisabled value before onInstallEnded works
+add_task(async function test_userDisabled() {
+ let url = "http://example.com/addons/test_install2_1.xpi";
+ let install = await AddonManager.getInstallForURL(url);
+ await install.install();
+
+ ok(!install.addon.userDisabled);
+
+ let addon = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ checkAddon("addon2@tests.mozilla.org", addon, {
+ userDisabled: false,
+ isActive: true,
+ });
+
+ url = "http://example.com/addons/test_install2_2.xpi";
+ install = await AddonManager.getInstallForURL(url);
+
+ install.addListener({
+ onInstallStarted() {
+ ok(!install.addon.userDisabled);
+ install.addon.disable();
+ },
+ });
+
+ await install.install();
+
+ addon = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ checkAddon("addon2@tests.mozilla.org", addon, {
+ userDisabled: true,
+ isActive: false,
+ });
+
+ await addon.uninstall();
+});
+
+// Checks that metadata is not stored if the pref is set to false
+add_task(async function test_18_1() {
+ AddonTestUtils.registerJSON(testserver, "/getaddons.json", GETADDONS_JSON);
+ Services.prefs.setCharPref(
+ PREF_GETADDONS_BYIDS,
+ "http://example.com/getaddons.json"
+ );
+
+ Services.prefs.setBoolPref("extensions.getAddons.cache.enabled", true);
+ Services.prefs.setBoolPref(
+ "extensions.addon2@tests.mozilla.org.getAddons.cache.enabled",
+ false
+ );
+
+ let url = "http://example.com/addons/test_install2_1.xpi";
+ let install = await AddonManager.getInstallForURL(url);
+ await install.install();
+
+ notEqual(install.addon.fullDescription, "Repository description");
+
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ notEqual(addon.fullDescription, "Repository description");
+
+ await addon.uninstall();
+});
+
+// Checks that metadata is downloaded for new installs and is visible before and
+// after restart
+add_task(async function test_metadata() {
+ Services.prefs.setBoolPref(
+ "extensions.addon2@tests.mozilla.org.getAddons.cache.enabled",
+ true
+ );
+
+ let url = "http://example.com/addons/test_install2_1.xpi";
+ let install = await AddonManager.getInstallForURL(url);
+ await install.install();
+
+ equal(install.addon.fullDescription, "Repository description");
+
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ equal(addon.fullDescription, "Repository description");
+
+ await addon.uninstall();
+});
+
+// Do the same again to make sure it works when the data is already in the cache
+add_task(async function test_metadata_again() {
+ let url = "http://example.com/addons/test_install2_1.xpi";
+ let install = await AddonManager.getInstallForURL(url);
+ await install.install();
+
+ equal(install.addon.fullDescription, "Repository description");
+
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ equal(addon.fullDescription, "Repository description");
+
+ await addon.uninstall();
+});
+
+// Tests that an install can be restarted after being cancelled
+add_task(async function test_restart() {
+ let url = "http://example.com/addons/test_install1.xpi";
+ let install = await AddonManager.getInstallForURL(url);
+ equal(install.state, AddonManager.STATE_AVAILABLE);
+
+ install.addListener({
+ onDownloadEnded() {
+ install.removeListener(this);
+ install.cancel();
+ },
+ });
+
+ try {
+ await install.install();
+ ok(false, "Install should not have succeeded");
+ } catch (err) {}
+
+ let promise = expectEvents(
+ {
+ addonEvents: {
+ "addon1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ await Promise.all([
+ promise,
+ promiseWebExtensionStartup("addon1@tests.mozilla.org"),
+ ]);
+
+ await install.addon.uninstall();
+});
+
+// Tests that an install can be restarted after being cancelled when a hash
+// was provided
+add_task(async function test_restart_hash() {
+ let url = "http://example.com/addons/test_install1.xpi";
+ let install = await AddonManager.getInstallForURL(url, {
+ hash: do_get_file_hash(XPIS.test_install1),
+ });
+ equal(install.state, AddonManager.STATE_AVAILABLE);
+
+ install.addListener({
+ onDownloadEnded() {
+ install.removeListener(this);
+ install.cancel();
+ },
+ });
+
+ try {
+ await install.install();
+ ok(false, "Install should not have succeeded");
+ } catch (err) {}
+
+ let promise = expectEvents(
+ {
+ addonEvents: {
+ "addon1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ await Promise.all([
+ promise,
+ promiseWebExtensionStartup("addon1@tests.mozilla.org"),
+ ]);
+
+ await install.addon.uninstall();
+});
+
+// Tests that an install with a bad hash can be restarted after it fails, though
+// it will only fail again
+add_task(async function test_restart_badhash() {
+ let url = "http://example.com/addons/test_install1.xpi";
+ let install = await AddonManager.getInstallForURL(url, { hash: "sha1:foo" });
+ equal(install.state, AddonManager.STATE_AVAILABLE);
+
+ install.addListener({
+ onDownloadEnded() {
+ install.removeListener(this);
+ install.cancel();
+ },
+ });
+
+ try {
+ await install.install();
+ ok(false, "Install should not have succeeded");
+ } catch (err) {}
+
+ try {
+ await install.install();
+ ok(false, "Install should not have succeeded");
+ } catch (err) {
+ ok(true, "Resumed install should have failed");
+ }
+});
+
+// Tests that installs with a hash for a local file work
+add_task(async function test_local_hash() {
+ let url = Services.io.newFileURI(XPIS.test_install1).spec;
+ let install = await AddonManager.getInstallForURL(url, {
+ hash: do_get_file_hash(XPIS.test_install1),
+ });
+
+ checkInstall(install, {
+ state: AddonManager.STATE_DOWNLOADED,
+ error: 0,
+ });
+
+ install.cancel();
+});
+
+// Test that an install cannot be canceled after the install is completed.
+add_task(async function test_cancel_completed() {
+ let url = "http://example.com/addons/test_install1.xpi";
+ let install = await AddonManager.getInstallForURL(url);
+
+ let cancelPromise = new Promise((resolve, reject) => {
+ install.addListener({
+ onInstallEnded() {
+ try {
+ install.cancel();
+ reject("Cancel should fail.");
+ } catch (e) {
+ resolve();
+ }
+ },
+ });
+ });
+
+ install.install();
+ await cancelPromise;
+
+ equal(install.state, AddonManager.STATE_INSTALLED);
+});
+
+// Test that an install may be canceled after a redirect.
+add_task(async function test_cancel_redirect() {
+ let url = "http://example.com/redirect?/addons/test_install1.xpi";
+ let install = await AddonManager.getInstallForURL(url);
+
+ install.addListener({
+ onDownloadProgress() {
+ install.cancel();
+ },
+ });
+
+ let promise = AddonTestUtils.promiseInstallEvent("onDownloadCancelled");
+
+ install.install();
+ await promise;
+
+ equal(install.state, AddonManager.STATE_CANCELLED);
+});
+
+// Tests that an install can be restarted during onDownloadCancelled after being
+// cancelled in mid-download
+add_task(async function test_restart2() {
+ let url = "http://example.com/addons/test_install1.xpi";
+ let install = await AddonManager.getInstallForURL(url);
+
+ equal(install.state, AddonManager.STATE_AVAILABLE);
+
+ install.addListener({
+ onDownloadProgress() {
+ install.removeListener(this);
+ install.cancel();
+ },
+ });
+
+ let promise = AddonTestUtils.promiseInstallEvent("onDownloadCancelled");
+ install.install();
+ await promise;
+
+ equal(install.state, AddonManager.STATE_CANCELLED);
+
+ promise = expectEvents(
+ {
+ addonEvents: {
+ "addon1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => {
+ let file = install.file;
+ install.install();
+ notEqual(file.path, install.file.path);
+ ok(!file.exists());
+ }
+ );
+
+ await Promise.all([
+ promise,
+ promiseWebExtensionStartup("addon1@tests.mozilla.org"),
+ ]);
+
+ await install.addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_installOrigins.js b/toolkit/mozapps/extensions/test/xpcshell/test_installOrigins.js
new file mode 100644
index 0000000000..49f3b336c7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_installOrigins.js
@@ -0,0 +1,535 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.usePrivilegedSignatures = false;
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+// This pref is not set in Thunderbird, and needs to be true for the test to pass.
+Services.prefs.setBoolPref("extensions.postDownloadThirdPartyPrompt", true);
+
+let server = AddonTestUtils.createHttpServer({
+ hosts: ["example.com", "example.org", "amo.example.com", "github.io"],
+});
+
+server.registerFile(
+ `/addons/origins.xpi`,
+ AddonTestUtils.createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Install Origins test",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "origins@example.com",
+ },
+ },
+ install_origins: ["http://example.com"],
+ },
+ })
+);
+
+server.registerFile(
+ `/addons/sitepermission.xpi`,
+ AddonTestUtils.createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Install Origins sitepermission test",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "sitepermission@example.com",
+ },
+ },
+ install_origins: ["http://example.com"],
+ site_permissions: ["midi"],
+ },
+ })
+);
+
+server.registerFile(
+ `/addons/sitepermission-suffix.xpi`,
+ AddonTestUtils.createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Install Origins sitepermission public suffix test",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "sitepermission-suffix@github.io",
+ },
+ },
+ install_origins: ["http://github.io"],
+ site_permissions: ["midi"],
+ },
+ })
+);
+
+server.registerFile(
+ `/addons/two_origins.xpi`,
+ AddonTestUtils.createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Install Origins test",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "two_origins@example.com",
+ },
+ },
+ install_origins: ["http://example.com", "http://example.org"],
+ },
+ })
+);
+
+server.registerFile(
+ `/addons/no_origins.xpi`,
+ AddonTestUtils.createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Install Origins test",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "no_origins@example.com",
+ },
+ },
+ },
+ })
+);
+
+server.registerFile(
+ `/addons/empty_origins.xpi`,
+ AddonTestUtils.createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Install Origins test",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "no_origins@example.com",
+ },
+ },
+ install_origins: [],
+ },
+ })
+);
+
+server.registerFile(
+ `/addons/v3_origins.xpi`,
+ AddonTestUtils.createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 3,
+ name: "Install Origins test",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "v3_origins@example.com",
+ },
+ },
+ install_origins: ["http://example.com"],
+ },
+ })
+);
+
+server.registerFile(
+ `/addons/v3_no_origins.xpi`,
+ AddonTestUtils.createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 3,
+ name: "Install Origins test",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "v3_no_origins@example.com",
+ },
+ },
+ },
+ })
+);
+
+function testInstallEvent(expectTelemetry) {
+ const snapshot = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ );
+
+ ok(
+ snapshot.parent && !!snapshot.parent.length,
+ "Got parent telemetry events in the snapshot"
+ );
+
+ let events = snapshot.parent
+ .filter(
+ ([timestamp, category, method, object, value, extra]) =>
+ category === "addonsManager" &&
+ method == "install" &&
+ extra.step == expectTelemetry.step
+ )
+ .map(event => event[5]);
+ equal(events.length, 1, "one event for install completion");
+ Assert.deepEqual(events[0], expectTelemetry, "telemetry matches");
+}
+
+function promiseCompleteWebInstall(
+ install,
+ triggeringPrincipal,
+ expectPrompts = true
+) {
+ let listener;
+ return new Promise(_resolve => {
+ let resolve = () => {
+ install.removeListener(listener);
+ _resolve();
+ };
+
+ listener = {
+ onDownloadFailed: resolve,
+ onDownloadCancelled: resolve,
+ onInstallFailed: resolve,
+ onInstallCancelled: resolve,
+ onInstallEnded: resolve,
+ onInstallPostponed: resolve,
+ };
+
+ install.addListener(listener);
+
+ // Observers to bypass panels and continue install.
+ if (expectPrompts) {
+ TestUtils.topicObserved("addon-install-blocked").then(([subject]) => {
+ let installInfo = subject.wrappedJSObject;
+ info(`==== test got addon-install-blocked ${subject} ${installInfo}`);
+ installInfo.install();
+ });
+
+ TestUtils.topicObserved("addon-install-confirmation").then(
+ (subject, data) => {
+ info(`==== test got addon-install-confirmation`);
+ let installInfo = subject.wrappedJSObject;
+ for (let installer of installInfo.installs) {
+ installer.install();
+ }
+ }
+ );
+ TestUtils.topicObserved("webextension-permission-prompt").then(
+ ([subject]) => {
+ const { info } = subject.wrappedJSObject || {};
+ info.resolve();
+ }
+ );
+ }
+
+ AddonManager.installAddonFromWebpage(
+ "application/x-xpinstall",
+ null /* aBrowser */,
+ triggeringPrincipal,
+ install
+ );
+ });
+}
+
+async function testAddonInstall(test) {
+ let { name, xpiUrl, installPrincipal, expectState, expectTelemetry } = test;
+ info(`testAddonInstall: ${name}`);
+ let expectInstall = expectState == AddonManager.STATE_INSTALLED;
+ let install = await AddonManager.getInstallForURL(xpiUrl, {
+ triggeringPrincipal: installPrincipal,
+ });
+ await promiseCompleteWebInstall(install, installPrincipal, expectInstall);
+
+ // Test origins telemetry
+ testInstallEvent(expectTelemetry);
+
+ if (expectInstall) {
+ equal(
+ install.state,
+ expectState,
+ `${name} ${install.addon.id} install was completed`
+ );
+ // Wait the extension startup to ensure manifest.json has been read,
+ // otherwise we get NS_ERROR_FILE_NOT_FOUND log spam.
+ await WebExtensionPolicy.getByID(install.addon.id)?.readyPromise;
+ await install.addon.uninstall();
+ } else {
+ equal(
+ install.state,
+ expectState,
+ `${name} ${install.addon?.id} install failed`
+ );
+ }
+}
+
+let ssm = Services.scriptSecurityManager;
+const PRINCIPAL_AMO = ssm.createContentPrincipalFromOrigin(
+ "https://amo.example.com"
+);
+const PRINCIPAL_COM =
+ ssm.createContentPrincipalFromOrigin("http://example.com");
+const SUB_PRINCIPAL_COM = ssm.createContentPrincipalFromOrigin(
+ "http://abc.example.com"
+);
+const THIRDPARTY_PRINCIPAL_COM = ssm.createContentPrincipalFromOrigin(
+ "http://fake-example.com"
+);
+const PRINCIPAL_ORG =
+ ssm.createContentPrincipalFromOrigin("http://example.org");
+const PRINCIPAL_ETLD = ssm.createContentPrincipalFromOrigin("http://github.io");
+
+const TESTS = [
+ {
+ name: "Install MV2 with install_origins",
+ xpiUrl: "http://example.com/addons/origins.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "origins@example.com",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install MV2 without install_origins",
+ xpiUrl: "http://example.com/addons/no_origins.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "no_origins@example.com",
+ install_origins: "0",
+ },
+ },
+ {
+ name: "Install valid xpi location from invalid website",
+ xpiUrl: "http://example.com/addons/origins.xpi",
+ installPrincipal: PRINCIPAL_ORG,
+ expectState: AddonManager.STATE_INSTALL_FAILED,
+ expectTelemetry: {
+ step: "failed",
+ addon_id: "origins@example.com",
+ error: "ERROR_INVALID_DOMAIN",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install invalid xpi location from valid website",
+ xpiUrl: "http://example.org/addons/origins.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALL_FAILED,
+ expectTelemetry: {
+ step: "failed",
+ addon_id: "origins@example.com",
+ error: "ERROR_INVALID_DOMAIN",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install MV3 with install_origins",
+ xpiUrl: "http://example.com/addons/v3_origins.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "v3_origins@example.com",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install MV3 with install_origins from AMO",
+ xpiUrl: "http://example.com/addons/v3_origins.xpi",
+ installPrincipal: PRINCIPAL_AMO,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "v3_origins@example.com",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install MV3 without install_origins",
+ xpiUrl: "http://example.com/addons/v3_no_origins.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALL_FAILED,
+ expectTelemetry: {
+ step: "failed",
+ addon_id: "v3_no_origins@example.com",
+ error: "ERROR_INVALID_DOMAIN",
+ install_origins: "0",
+ },
+ },
+ {
+ // An installing principal with install permission is
+ // considered "AMO" in code, and will always be allowed.
+ name: "Install MV3 without install_origins from AMO",
+ xpiUrl: "http://example.com/addons/v3_no_origins.xpi",
+ installPrincipal: PRINCIPAL_AMO,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "v3_no_origins@example.com",
+ install_origins: "0",
+ },
+ },
+ {
+ name: "Install MV3 without install_origins from null principal",
+ xpiUrl: "http://example.com/addons/v3_no_origins.xpi",
+ installPrincipal: Services.scriptSecurityManager.createNullPrincipal({}),
+ expectState: AddonManager.STATE_CANCELLED,
+ expectTelemetry: { step: "site_blocked", install_origins: "0" },
+ },
+ {
+ name: "Install addon with two install_origins",
+ xpiUrl: "http://example.com/addons/two_origins.xpi",
+ installPrincipal: PRINCIPAL_ORG,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "two_origins@example.com",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install addon with two install_origins",
+ xpiUrl: "http://example.com/addons/two_origins.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "two_origins@example.com",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install from site with empty install_origins",
+ xpiUrl: "http://example.com/addons/empty_origins.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALL_FAILED,
+ expectTelemetry: {
+ step: "failed",
+ addon_id: "no_origins@example.com",
+ error: "ERROR_INVALID_DOMAIN",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install from site with empty install_origins",
+ xpiUrl: "http://example.com/addons/empty_origins.xpi",
+ installPrincipal: PRINCIPAL_ORG,
+ expectState: AddonManager.STATE_INSTALL_FAILED,
+ expectTelemetry: {
+ step: "failed",
+ addon_id: "no_origins@example.com",
+ error: "ERROR_INVALID_DOMAIN",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install with empty install_origins from AMO",
+ xpiUrl: "http://amo.example.com/addons/empty_origins.xpi",
+ installPrincipal: PRINCIPAL_AMO,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "no_origins@example.com",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install sitepermission from domain",
+ xpiUrl: "http://example.com/addons/sitepermission.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "sitepermission@example.com",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install sitepermission from subdomain",
+ xpiUrl: "http://example.com/addons/sitepermission.xpi",
+ installPrincipal: SUB_PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "sitepermission@example.com",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install sitepermission from thirdparty domain should fail",
+ xpiUrl: "http://example.com/addons/sitepermission.xpi",
+ installPrincipal: THIRDPARTY_PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALL_FAILED,
+ expectTelemetry: {
+ step: "failed",
+ addon_id: "sitepermission@example.com",
+ error: "ERROR_INVALID_DOMAIN",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install sitepermission from different domain",
+ xpiUrl: "http://example.com/addons/sitepermission.xpi",
+ installPrincipal: PRINCIPAL_ORG,
+ expectState: AddonManager.STATE_INSTALL_FAILED,
+ expectTelemetry: {
+ step: "failed",
+ addon_id: "sitepermission@example.com",
+ error: "ERROR_INVALID_DOMAIN",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install sitepermission from public suffix domain",
+ xpiUrl: "http://github.io/addons/sitepermission-suffix.xpi",
+ installPrincipal: PRINCIPAL_ETLD,
+ expectState: AddonManager.STATE_INSTALL_FAILED,
+ expectTelemetry: {
+ step: "failed",
+ addon_id: "sitepermission-suffix@github.io",
+ error: "ERROR_INVALID_DOMAIN",
+ install_origins: "1",
+ },
+ },
+];
+
+add_task(async function test_install_url() {
+ Services.prefs.setBoolPref("extensions.install_origins.enabled", true);
+ PermissionTestUtils.add(
+ PRINCIPAL_AMO,
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+ await promiseStartupManager();
+
+ for (let test of TESTS) {
+ await testAddonInstall(test);
+ }
+});
+
+add_task(async function test_install_origins_disabled() {
+ Services.prefs.setBoolPref("extensions.install_origins.enabled", false);
+ await testAddonInstall({
+ name: "Install MV3 without install_origins, verification disabled",
+ xpiUrl: "http://example.com/addons/v3_no_origins.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "v3_no_origins@example.com",
+ },
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_install_cancel.js b/toolkit/mozapps/extensions/test/xpcshell/test_install_cancel.js
new file mode 100644
index 0000000000..0be6ec0359
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_install_cancel.js
@@ -0,0 +1,92 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+var testserver = createHttpServer({ hosts: ["example.com"] });
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1.9.2"
+);
+
+class TestListener {
+ constructor(listener) {
+ this.listener = listener;
+ }
+
+ onDataAvailable(...args) {
+ this.origListener.onDataAvailable(...args);
+ }
+
+ onStartRequest(request) {
+ this.origListener.onStartRequest(request);
+ }
+
+ onStopRequest(request, status) {
+ if (this.listener.onStopRequest) {
+ this.listener.onStopRequest(request, status);
+ }
+ this.origListener.onStopRequest(request, status);
+ }
+}
+
+function startListener(listener) {
+ let observer = {
+ observe(subject, topic, data) {
+ let channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ if (channel.URI.spec === "http://example.com/addons/test.xpi") {
+ let channelListener = new TestListener(listener);
+ channelListener.origListener = subject
+ .QueryInterface(Ci.nsITraceableChannel)
+ .setNewListener(channelListener);
+ Services.obs.removeObserver(observer, "http-on-modify-request");
+ }
+ },
+ };
+ Services.obs.addObserver(observer, "http-on-modify-request");
+}
+
+add_task(async function setup() {
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ name: "Test",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: "cancel@test" } },
+ },
+ });
+ testserver.registerFile(`/addons/test.xpi`, xpi);
+ await AddonTestUtils.promiseStartupManager();
+});
+
+// This test checks that canceling an install after the download is completed fails
+// and throws an exception as expected
+add_task(async function test_install_cancelled() {
+ let url = "http://example.com/addons/test.xpi";
+ let install = await AddonManager.getInstallForURL(url, {
+ name: "Test",
+ version: "1.0",
+ });
+
+ let cancelInstall = new Promise(resolve => {
+ startListener({
+ onStopRequest() {
+ resolve(Promise.resolve().then(() => install.cancel()));
+ },
+ });
+ });
+
+ await install.install().then(() => {
+ ok(true, "install succeeded");
+ });
+
+ await cancelInstall
+ .then(() => {
+ ok(false, "cancel should not succeed");
+ })
+ .catch(e => {
+ ok(!!e, "cancel threw an exception");
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_install_file_change.js b/toolkit/mozapps/extensions/test/xpcshell/test_install_file_change.js
new file mode 100644
index 0000000000..75cc91038e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_install_file_change.js
@@ -0,0 +1,180 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+/* globals browser */
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+async function createXPIWithID(addonId, version = "1.0") {
+ let xpiFile = await createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id: addonId } },
+ },
+ });
+ return xpiFile;
+}
+
+const ERROR_PATTERN_INSTALL_FAIL = /Failed to install .+ from .+ to /;
+const ERROR_PATTERN_POSTPONE_FAIL = /Failed to postpone install of /;
+
+async function promiseInstallFail(install, expectedErrorPattern) {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await Assert.rejects(
+ install.install(),
+ /^Error: Install failed: onInstallFailed$/
+ );
+ });
+ messages = messages.filter(msg => expectedErrorPattern.test(msg.message));
+ equal(messages.length, 1, "Expected log messages");
+ equal(install.state, AddonManager.STATE_INSTALL_FAILED);
+ equal(install.error, AddonManager.ERROR_FILE_ACCESS);
+ equal((await AddonManager.getAllInstalls()).length, 0, "no pending installs");
+}
+
+add_task(async function test_file_deleted() {
+ let xpiFile = await createXPIWithID("delete@me");
+ let install = await AddonManager.getInstallForFile(xpiFile);
+ equal(install.state, AddonManager.STATE_DOWNLOADED);
+
+ xpiFile.remove(false);
+
+ await promiseInstallFail(install, ERROR_PATTERN_INSTALL_FAIL);
+
+ equal(await AddonManager.getAddonByID("delete@me"), null);
+});
+
+add_task(async function test_file_emptied() {
+ let xpiFile = await createXPIWithID("empty@me");
+ let install = await AddonManager.getInstallForFile(xpiFile);
+ equal(install.state, AddonManager.STATE_DOWNLOADED);
+
+ await IOUtils.write(xpiFile.path, new Uint8Array());
+
+ await promiseInstallFail(install, ERROR_PATTERN_INSTALL_FAIL);
+
+ equal(await AddonManager.getAddonByID("empty@me"), null);
+});
+
+add_task(async function test_file_replaced() {
+ let xpiFile = await createXPIWithID("replace@me");
+ let install = await AddonManager.getInstallForFile(xpiFile);
+ equal(install.state, AddonManager.STATE_DOWNLOADED);
+
+ await IOUtils.copy(
+ (
+ await createXPIWithID("replace@me", "2")
+ ).path,
+ xpiFile.path
+ );
+
+ await promiseInstallFail(install, ERROR_PATTERN_INSTALL_FAIL);
+
+ equal(await AddonManager.getAddonByID("replace@me"), null);
+});
+
+async function do_test_update_with_file_replaced(wantPostponeTest) {
+ const ADDON_ID = wantPostponeTest ? "postpone@me" : "update@me";
+ function backgroundWithPostpone() {
+ // The registration of this listener postpones the update.
+ browser.runtime.onUpdateAvailable.addListener(() => {
+ browser.test.fail("Unusable update should not call onUpdateAvailable");
+ });
+ }
+ await promiseInstallWebExtension({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: ADDON_ID,
+ update_url: `http://example.com/update-${ADDON_ID}.json`,
+ },
+ },
+ },
+ background: wantPostponeTest ? backgroundWithPostpone : () => {},
+ });
+
+ server.registerFile(
+ `/update-${ADDON_ID}.xpi`,
+ await createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: ADDON_ID } },
+ },
+ })
+ );
+ AddonTestUtils.registerJSON(server, `/update-${ADDON_ID}.json`, {
+ addons: {
+ [ADDON_ID]: {
+ updates: [
+ {
+ version: "2.0",
+ update_link: `http://example.com/update-${ADDON_ID}.xpi`,
+ },
+ ],
+ },
+ },
+ });
+
+ // Setup completed, let's try to verify that file corruption halts the update.
+
+ let addon = await promiseAddonByID(ADDON_ID);
+ equal(addon.version, "1.0");
+
+ let update = await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ let install = update.updateAvailable;
+ equal(install.version, "2.0");
+ equal(install.state, AddonManager.STATE_AVAILABLE);
+ equal(install.existingAddon, addon);
+ equal(install.file, null);
+
+ let promptCount = 0;
+ let didReplaceFile = false;
+ install.promptHandler = async function () {
+ ++promptCount;
+ equal(install.state, AddonManager.STATE_DOWNLOADED);
+ await IOUtils.copy(
+ (
+ await createXPIWithID(ADDON_ID, "3")
+ ).path,
+ install.file.path
+ );
+ didReplaceFile = true;
+ equal(install.state, AddonManager.STATE_DOWNLOADED, "State not changed");
+ };
+
+ if (wantPostponeTest) {
+ await promiseInstallFail(install, ERROR_PATTERN_POSTPONE_FAIL);
+ } else {
+ await promiseInstallFail(install, ERROR_PATTERN_INSTALL_FAIL);
+ }
+
+ equal(promptCount, 1);
+ ok(didReplaceFile, "Replaced update with different file");
+
+ // Now verify that the add-on is still at the old version.
+ addon = await promiseAddonByID(ADDON_ID);
+ equal(addon.version, "1.0");
+
+ await addon.uninstall();
+}
+
+add_task(async function test_update_and_file_replaced() {
+ await do_test_update_with_file_replaced();
+});
+
+add_task(async function test_update_postponed_and_file_replaced() {
+ await do_test_update_with_file_replaced(/* wantPostponeTest = */ true);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_install_icons.js b/toolkit/mozapps/extensions/test/xpcshell/test_install_icons.js
new file mode 100644
index 0000000000..af88b55959
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_install_icons.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// use httpserver to find an available port
+var gServer = new HttpServer();
+gServer.start(-1);
+gPort = gServer.identity.primaryPort;
+
+var addon_url = "http://localhost:" + gPort + "/test.xpi";
+var icon32_url = "http://localhost:" + gPort + "/icon.png";
+var icon64_url = "http://localhost:" + gPort + "/icon64.png";
+
+async function run_test() {
+ do_test_pending();
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+ await promiseStartupManager();
+
+ test_1();
+}
+
+async function test_1() {
+ let aInstall = await AddonManager.getInstallForURL(addon_url);
+ Assert.equal(aInstall.iconURL, null);
+ Assert.notEqual(aInstall.icons, null);
+ Assert.equal(aInstall.icons[32], undefined);
+ Assert.equal(aInstall.icons[64], undefined);
+ test_2();
+}
+
+async function test_2() {
+ let aInstall = await AddonManager.getInstallForURL(addon_url, {
+ icons: icon32_url,
+ });
+ Assert.equal(aInstall.iconURL, icon32_url);
+ Assert.notEqual(aInstall.icons, null);
+ Assert.equal(aInstall.icons[32], icon32_url);
+ Assert.equal(aInstall.icons[64], undefined);
+ test_3();
+}
+
+async function test_3() {
+ let aInstall = await AddonManager.getInstallForURL(addon_url, {
+ icons: { 32: icon32_url },
+ });
+ Assert.equal(aInstall.iconURL, icon32_url);
+ Assert.notEqual(aInstall.icons, null);
+ Assert.equal(aInstall.icons[32], icon32_url);
+ Assert.equal(aInstall.icons[64], undefined);
+ test_4();
+}
+
+async function test_4() {
+ let aInstall = await AddonManager.getInstallForURL(addon_url, {
+ icons: { 32: icon32_url, 64: icon64_url },
+ });
+ Assert.equal(aInstall.iconURL, icon32_url);
+ Assert.notEqual(aInstall.icons, null);
+ Assert.equal(aInstall.icons[32], icon32_url);
+ Assert.equal(aInstall.icons[64], icon64_url);
+ executeSoon(do_test_finished);
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_installtrigger_deprecation.js b/toolkit/mozapps/extensions/test/xpcshell/test_installtrigger_deprecation.js
new file mode 100644
index 0000000000..dfaeaa44f2
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_installtrigger_deprecation.js
@@ -0,0 +1,346 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42");
+
+const testserver = createHttpServer({ hosts: ["example.com"] });
+
+function createTestPage(body) {
+ return `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ ${body}
+ </body>
+ </html>
+ `;
+}
+
+testserver.registerPathHandler(
+ "/installtrigger_ua_detection.html",
+ (request, response) => {
+ response.write(
+ createTestPage(`
+ <button/>
+ <script>
+ document.querySelector("button").onclick = () => {
+ typeof InstallTrigger;
+ };
+ </script>
+ `)
+ );
+ }
+);
+
+testserver.registerPathHandler(
+ "/installtrigger_install.html",
+ (request, response) => {
+ response.write(
+ createTestPage(`
+ <button/>
+ <script>
+ const install = InstallTrigger.install.bind(InstallTrigger);
+ document.querySelector("button").onclick = () => {
+ install({ fakeextensionurl: "http://example.com/fakeextensionurl.xpi" });
+ };
+ </script>
+ `)
+ );
+ }
+);
+
+async function testDeprecationWarning(testPageURL, expectedDeprecationWarning) {
+ const page = await ExtensionTestUtils.loadContentPage(testPageURL);
+
+ const { message, messageInnerWindowID, pageInnerWindowID } = await page.spawn(
+ [expectedDeprecationWarning],
+ expectedWarning => {
+ return new Promise(resolve => {
+ const consoleListener = consoleMsg => {
+ if (
+ consoleMsg instanceof Ci.nsIScriptError &&
+ consoleMsg.message?.includes(expectedWarning)
+ ) {
+ Services.console.unregisterListener(consoleListener);
+ resolve({
+ message: consoleMsg.message,
+ messageInnerWindowID: consoleMsg.innerWindowID,
+ pageInnerWindowID: this.content.windowGlobalChild.innerWindowId,
+ });
+ }
+ };
+
+ Services.console.registerListener(consoleListener);
+ this.content.document.querySelector("button").click();
+ });
+ }
+ );
+
+ equal(
+ typeof messageInnerWindowID,
+ "number",
+ `Warning message should be associated to an innerWindowID`
+ );
+ equal(
+ messageInnerWindowID,
+ pageInnerWindowID,
+ `Deprecation warning "${message}" has been logged and associated to the expected window`
+ );
+
+ await page.close();
+
+ return message;
+}
+
+add_task(
+ {
+ pref_set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ ],
+ },
+ function testDeprecationWarningsOnUADetection() {
+ return testDeprecationWarning(
+ "http://example.com/installtrigger_ua_detection.html",
+ "InstallTrigger is deprecated and will be removed in the future."
+ );
+ }
+);
+
+add_task(
+ {
+ pref_set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ ],
+ },
+ async function testDeprecationWarningsOnInstallTriggerInstall() {
+ const message = await testDeprecationWarning(
+ "http://example.com/installtrigger_install.html",
+ "InstallTrigger.install() is deprecated and will be removed in the future."
+ );
+
+ const moreInfoURL =
+ "https://extensionworkshop.com/documentation/publish/self-distribution/";
+
+ ok(
+ message.includes(moreInfoURL),
+ "Deprecation warning should include an url to self-distribution documentation"
+ );
+ }
+);
+
+async function testInstallTriggerDeprecationPrefs(expectedResults) {
+ const page = await ExtensionTestUtils.loadContentPage("http://example.com");
+ const promiseResults = page.spawn([], () => {
+ return {
+ uaDetectionResult: this.content.eval(
+ "typeof InstallTrigger !== 'undefined'"
+ ),
+ typeofInstallMethod: this.content.eval("typeof InstallTrigger?.install"),
+ };
+ });
+ if (expectedResults.error) {
+ await Assert.rejects(
+ promiseResults,
+ expectedResults.error,
+ "Got the expected error"
+ );
+ } else {
+ Assert.deepEqual(
+ await promiseResults,
+ expectedResults,
+ "Got the expected results"
+ );
+ }
+ await page.close();
+}
+
+add_task(
+ {
+ pref_set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", false],
+ ],
+ },
+ function testInstallTriggerImplDisabled() {
+ return testInstallTriggerDeprecationPrefs({
+ uaDetectionResult: true,
+ typeofInstallMethod: "undefined",
+ });
+ }
+);
+
+add_task(
+ {
+ pref_set: [["extensions.InstallTrigger.enabled", false]],
+ },
+ function testInstallTriggerDisabled() {
+ return testInstallTriggerDeprecationPrefs({
+ error: /ReferenceError: InstallTrigger is not defined/,
+ });
+ }
+);
+
+add_task(
+ {
+ pref_set: [
+ ["extensions.remoteSettings.disabled", false],
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ ],
+ },
+ async function testInstallTriggerDeprecatedFromRemoteSettings() {
+ await AddonTestUtils.promiseStartupManager();
+
+ // InstallTrigger is expected to be initially enabled.
+ await testInstallTriggerDeprecationPrefs({
+ uaDetectionResult: true,
+ typeofInstallMethod: "function",
+ });
+
+ info("Test remote settings update to hide InstallTrigger methods");
+
+ // InstallTrigger global is expected to still be enabled, the install method
+ // to have been hidden.
+ const unexpectedPrefsBranchName = "extensions.unexpectedPrefs";
+ await setAndEmitFakeRemoteSettingsData([
+ {
+ id: "AddonManagerSettings",
+ installTriggerDeprecation: {
+ "extensions.InstallTriggerImpl.enabled": false,
+ // Unexpected preferences names would be just ignored.
+ [`${unexpectedPrefsBranchName}.fromProcessedEntry`]: true,
+ },
+ otherFakeFutureSetting: {
+ [`${unexpectedPrefsBranchName}.fromFakeFutureSetting`]: true,
+ },
+ // This entry is expected to always be processed when running this
+ // xpcshell test, the appInfo platformVersion is always set to 42
+ // by the call to AddonTestUtils's createAppInfo.
+ filter_expression: "env.appinfo.platformVersion >= 42",
+ },
+ {
+ // Entries entirely unexpected should be ignored even if they may be
+ // including a property named as the ones that AMRemoteSettings (e.g.
+ // it may be a new type of entry introduced for a new Firefox version,
+ // which a previous version of Firefox shouldn't try to process avoid
+ // undefined behaviors).
+ id: "AddonManagerSettings-fxFutureVersion",
+ // This entry is expected to always be filtered out by RemoteSettings,
+ // while running this xpcshell test the platformInfo version is always set
+ // to 42 by the call to AddonTestUtils's createAppInfo.
+ filter_expression: "env.appinfo.platformVersion >= 200",
+ installTriggerDeprecation: {
+ // If processed, it would fail the assertion that follows
+ // because it does change the same pref that the previous entry did
+ // set to false.
+ "extensions.InstallTriggerImpl.enabled": true,
+ },
+ },
+ ]);
+ await testInstallTriggerDeprecationPrefs({
+ uaDetectionResult: true,
+ typeofInstallMethod: "undefined",
+ });
+
+ const unexpectedPrefBranch = Services.prefs.getBranch(
+ unexpectedPrefsBranchName
+ );
+ equal(
+ unexpectedPrefBranch.getPrefType("fromFakeFutureSetting"),
+ unexpectedPrefBranch.PREF_INVALID,
+ "Preferences included in an unexpected entry property should not be set"
+ );
+ equal(
+ unexpectedPrefBranch.getPrefType("fromProcessedEntry"),
+ unexpectedPrefBranch.PREF_INVALID,
+ undefined,
+ "Unexpected pref included in the installTriggerDeprecation entry should not be set"
+ );
+
+ info("Test remote settings update to hide InstallTrigger global");
+ // InstallTrigger global is expected to still be enabled, the install method
+ // to have been hidden.
+ await setAndEmitFakeRemoteSettingsData([
+ {
+ id: "AddonManagerSettings",
+ installTriggerDeprecation: {
+ "extensions.InstallTrigger.enabled": false,
+ },
+ },
+ ]);
+ await testInstallTriggerDeprecationPrefs({
+ error: /ReferenceError: InstallTrigger is not defined/,
+ });
+
+ info("Test remote settings update to re-enable InstallTrigger global");
+ // InstallTrigger global is expected to still be enabled, the install method
+ // to have been hidden.
+ await setAndEmitFakeRemoteSettingsData([
+ {
+ id: "AddonManagerSettings",
+ installTriggerDeprecation: {
+ "extensions.InstallTrigger.enabled": true,
+ "extensions.InstallTriggerImpl.enabled": false,
+ },
+ },
+ ]);
+ await testInstallTriggerDeprecationPrefs({
+ uaDetectionResult: true,
+ typeofInstallMethod: "undefined",
+ });
+
+ info("Test remote settings update to re-enable InstallTrigger methods");
+ // InstallTrigger global and method are both expected to be re-enabled.
+ await setAndEmitFakeRemoteSettingsData([
+ {
+ id: "AddonManagerSettings",
+ installTriggerDeprecation: {
+ "extensions.InstallTrigger.enabled": true,
+ "extensions.InstallTriggerImpl.enabled": true,
+ },
+ },
+ ]);
+ await testInstallTriggerDeprecationPrefs({
+ uaDetectionResult: true,
+ typeofInstallMethod: "function",
+ });
+
+ info("Test remote settings ignored when AMRemoteSettings is disabled");
+ // RemoteSettings are expected to be ignored.
+ Services.prefs.setBoolPref("extensions.remoteSettings.disabled", true);
+ await setAndEmitFakeRemoteSettingsData(
+ [
+ {
+ id: "AddonManagerSettings",
+ installTriggerDeprecation: {
+ "extensions.InstallTrigger.enabled": false,
+ "extensions.InstallTriggerImpl.enabled": false,
+ },
+ },
+ ],
+ false /* expectClientInitialized */
+ );
+ await testInstallTriggerDeprecationPrefs({
+ uaDetectionResult: true,
+ typeofInstallMethod: "function",
+ });
+
+ info(
+ "Test previously synchronized are processed on AOM started when AMRemoteSettings are enabled"
+ );
+ // RemoteSettings previously stored on disk are expected to disable InstallTrigger global and methods.
+ await AddonTestUtils.promiseShutdownManager();
+ Services.prefs.setBoolPref("extensions.remoteSettings.disabled", false);
+ await AddonTestUtils.promiseStartupManager();
+ await testInstallTriggerDeprecationPrefs({
+ error: /ReferenceError: InstallTrigger is not defined/,
+ });
+
+ await AddonTestUtils.promiseShutdownManager();
+ }
+);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_installtrigger_schemes.js b/toolkit/mozapps/extensions/test/xpcshell/test_installtrigger_schemes.js
new file mode 100644
index 0000000000..b219d2f55d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_installtrigger_schemes.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+createHttpServer({ hosts: ["example.com"] });
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1.9.2"
+);
+
+async function assertInstallTriggetRejected(page, xpi_url, expectedError) {
+ await Assert.rejects(
+ page.spawn([xpi_url], async url => {
+ this.content.eval(`InstallTrigger.install({extension: '${url}'});`);
+ }),
+ expectedError,
+ `InstallTrigger.install expected to throw on xpi url "${xpi_url}"`
+ );
+}
+
+add_task(
+ {
+ // Once InstallTrigger is removed, this test should be removed as well.
+ pref_set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ },
+ async function test_InstallTriggerThrows_on_unsupported_xpi_schemes_blob() {
+ const page = await ExtensionTestUtils.loadContentPage("http://example.com");
+ const blob_url = await page.spawn([], () => {
+ return this.content.eval(`(function () {
+ const blob = new Blob(['fakexpicontent']);
+ return URL.createObjectURL(blob);
+ })()`);
+ });
+ await assertInstallTriggetRejected(page, blob_url, /Unsupported scheme/);
+ await page.close();
+ }
+);
+
+add_task(
+ {
+ // Once InstallTrigger is removed, this test should be removed as well.
+ pref_set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ },
+ async function test_InstallTriggerThrows_on_unsupported_xpi_schemes_data() {
+ const page = await ExtensionTestUtils.loadContentPage("http://example.com");
+ const data_url = "data:;,fakexpicontent";
+ // This is actually rejected by the checkLoadURIWithPrincipal, which fails with
+ // NS_ERROR_DOM_BAD_URI triggered by CheckLoadURIWithPrincipal's call to
+ //
+ // DenyAccessIfURIHasFlags(aTargetURI, nsIProtocolHandler::URI_INHERITS_SECURITY_CONTEXT)
+ //
+ // and so it is not a site permission that the user can actually grant, unlike the error
+ // raised may suggest.
+ await assertInstallTriggetRejected(
+ page,
+ data_url,
+ /Insufficient permissions to install/
+ );
+ await page.close();
+ }
+);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_isDebuggable.js b/toolkit/mozapps/extensions/test/xpcshell/test_isDebuggable.js
new file mode 100644
index 0000000000..b23e94e7bb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_isDebuggable.js
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+var ID = "debuggable@tests.mozilla.org";
+
+add_task(async function () {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2", "2");
+
+ await promiseStartupManager();
+
+ await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ let addon = await AddonManager.getAddonByID(ID);
+ Assert.equal(addon.isDebuggable, true);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_isReady.js b/toolkit/mozapps/extensions/test/xpcshell/test_isReady.js
new file mode 100644
index 0000000000..e5e1649051
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_isReady.js
@@ -0,0 +1,71 @@
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+add_task(async function () {
+ equal(AddonManager.isReady, false, "isReady should be false before startup");
+
+ let gotStartupEvent = false;
+ let gotShutdownEvent = false;
+ let listener = {
+ onStartup() {
+ gotStartupEvent = true;
+ },
+ onShutdown() {
+ gotShutdownEvent = true;
+ },
+ };
+ AddonManager.addManagerListener(listener);
+
+ info("Starting manager...");
+ await promiseStartupManager();
+ equal(AddonManager.isReady, true, "isReady should be true after startup");
+ equal(
+ gotStartupEvent,
+ true,
+ "Should have seen onStartup event after startup"
+ );
+ equal(
+ gotShutdownEvent,
+ false,
+ "Should not have seen onShutdown event before shutdown"
+ );
+
+ gotStartupEvent = false;
+ gotShutdownEvent = false;
+
+ info("Shutting down manager...");
+ await promiseShutdownManager();
+
+ equal(AddonManager.isReady, false, "isReady should be false after shutdown");
+ equal(
+ gotStartupEvent,
+ false,
+ "Should not have seen onStartup event after shutdown"
+ );
+ equal(
+ gotShutdownEvent,
+ true,
+ "Should have seen onShutdown event after shutdown"
+ );
+
+ AddonManager.addManagerListener(listener);
+ gotStartupEvent = false;
+ gotShutdownEvent = false;
+
+ info("Starting manager again...");
+ await promiseStartupManager();
+ equal(
+ AddonManager.isReady,
+ true,
+ "isReady should be true after repeat startup"
+ );
+ equal(
+ gotStartupEvent,
+ true,
+ "Should have seen onStartup event after repeat startup"
+ );
+ equal(
+ gotShutdownEvent,
+ false,
+ "Should not have seen onShutdown event before shutdown, following repeat startup"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_loadManifest_isPrivileged.js b/toolkit/mozapps/extensions/test/xpcshell/test_loadManifest_isPrivileged.js
new file mode 100644
index 0000000000..0886a35c2f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_loadManifest_isPrivileged.js
@@ -0,0 +1,229 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { XPIInstall } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIInstall.jsm"
+);
+const {
+ XPIInternal: {
+ BuiltInLocation,
+ KEY_APP_PROFILE,
+ KEY_APP_SYSTEM_DEFAULTS,
+ KEY_APP_SYSTEM_PROFILE,
+ TemporaryInstallLocation,
+ XPIStates,
+ },
+} = ChromeUtils.import("resource://gre/modules/addons/XPIProvider.jsm");
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+// Disable "xpc::IsInAutomation()", since it would override the behavior
+// we're testing for.
+Services.prefs.setBoolPref(
+ "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer",
+ false
+);
+
+Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ // SCOPE_PROFILE is enabled by default,
+ // SCOPE_APPLICATION is to enable KEY_APP_SYSTEM_PROFILE, which we need to
+ // test the combination (isSystem && !isBuiltin) in test_system_location.
+ AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION
+);
+// test_builtin_system_location tests the (isSystem && isBuiltin) combination
+// (i.e. KEY_APP_SYSTEM_DEFAULTS). That location only exists if this directory
+// is found:
+const distroDir = FileUtils.getDir("ProfD", ["sysfeatures"], true);
+registerDirectory("XREAppFeat", distroDir);
+
+function getInstallLocation({
+ isBuiltin = false,
+ isSystem = false,
+ isTemporary = false,
+}) {
+ if (isTemporary) {
+ // Temporary installation. Signatures will not be verified.
+ return TemporaryInstallLocation; // KEY_APP_TEMPORARY
+ }
+ let location;
+ if (isSystem) {
+ if (isBuiltin) {
+ // System location. Signatures will not be verified.
+ location = XPIStates.getLocation(KEY_APP_SYSTEM_DEFAULTS);
+ } else {
+ // Normandy installations. Signatures will be verified.
+ location = XPIStates.getLocation(KEY_APP_SYSTEM_PROFILE);
+ }
+ } else if (isBuiltin) {
+ // Packaged with the application. Signatures will not be verified.
+ location = BuiltInLocation; // KEY_APP_BUILTINS
+ } else {
+ // By default - The profile directory. Signatures will be verified.
+ location = XPIStates.getLocation(KEY_APP_PROFILE);
+ }
+ // Sanity checks to make sure that the flags match the expected values.
+ if (location.isSystem !== isSystem) {
+ ok(false, `${location.name}, unexpected isSystem=${location.isSystem}`);
+ }
+ if (location.isBuiltin !== isBuiltin) {
+ ok(false, `${location.name}, unexpected isBuiltin=${location.isBuiltin}`);
+ }
+ return location;
+}
+
+async function testLoadManifest({ location, expectPrivileged }) {
+ location ??= getInstallLocation({});
+ let xpi = await AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@with-privileged-perm" } },
+ permissions: ["mozillaAddons", "cookies"],
+ },
+ });
+ let actualPermissions;
+ let { messages } = await AddonTestUtils.promiseConsoleOutput(async () => {
+ if (location.isTemporary && !expectPrivileged) {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await Assert.rejects(
+ XPIInstall.loadManifestFromFile(xpi, location),
+ /Extension is invalid/,
+ "load manifest failed with privileged permission"
+ );
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ return;
+ }
+ let addon = await XPIInstall.loadManifestFromFile(xpi, location);
+ actualPermissions = addon.userPermissions;
+ equal(addon.isPrivileged, expectPrivileged, "addon.isPrivileged");
+ });
+ if (expectPrivileged) {
+ AddonTestUtils.checkMessages(messages, {
+ expected: [],
+ forbidden: [
+ {
+ message: /Reading manifest: Invalid extension permission/,
+ },
+ ],
+ });
+ Assert.deepEqual(
+ actualPermissions,
+ { origins: [], permissions: ["mozillaAddons", "cookies"] },
+ "Privileged permission should exist"
+ );
+ } else if (location.isTemporary) {
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ {
+ message:
+ /Using the privileged permission 'mozillaAddons' requires a privileged add-on/,
+ },
+ ],
+ forbidden: [],
+ });
+ } else {
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ {
+ message:
+ /Reading manifest: Invalid extension permission: mozillaAddons/,
+ },
+ ],
+ forbidden: [],
+ });
+ Assert.deepEqual(
+ actualPermissions,
+ { origins: [], permissions: ["cookies"] },
+ "Privileged permission should be ignored"
+ );
+ }
+}
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_regular_addon() {
+ AddonTestUtils.usePrivilegedSignatures = false;
+ await testLoadManifest({
+ expectPrivileged: false,
+ });
+});
+
+add_task(async function test_privileged_signature() {
+ AddonTestUtils.usePrivilegedSignatures = true;
+ await testLoadManifest({
+ expectPrivileged: true,
+ });
+});
+
+add_task(async function test_system_signature() {
+ AddonTestUtils.usePrivilegedSignatures = "system";
+ await testLoadManifest({
+ expectPrivileged: true,
+ });
+});
+
+add_task(async function test_builtin_location() {
+ AddonTestUtils.usePrivilegedSignatures = false;
+ await testLoadManifest({
+ expectPrivileged: true,
+ location: getInstallLocation({ isBuiltin: true }),
+ });
+});
+
+add_task(async function test_system_location() {
+ AddonTestUtils.usePrivilegedSignatures = false;
+ await testLoadManifest({
+ expectPrivileged: false,
+ location: getInstallLocation({ isSystem: true }),
+ });
+});
+
+add_task(async function test_builtin_system_location() {
+ AddonTestUtils.usePrivilegedSignatures = false;
+ await testLoadManifest({
+ expectPrivileged: true,
+ location: getInstallLocation({ isSystem: true, isBuiltin: true }),
+ });
+});
+
+add_task(async function test_temporary_regular() {
+ AddonTestUtils.usePrivilegedSignatures = false;
+ Services.prefs.setBoolPref("extensions.experiments.enabled", false);
+ await testLoadManifest({
+ expectPrivileged: false,
+ location: getInstallLocation({ isTemporary: true }),
+ });
+});
+
+add_task(async function test_temporary_privileged_signature() {
+ AddonTestUtils.usePrivilegedSignatures = true;
+ Services.prefs.setBoolPref("extensions.experiments.enabled", false);
+ await testLoadManifest({
+ expectPrivileged: true,
+ location: getInstallLocation({ isTemporary: true }),
+ });
+});
+
+add_task(async function test_temporary_experiments_enabled() {
+ AddonTestUtils.usePrivilegedSignatures = false;
+ Services.prefs.setBoolPref("extensions.experiments.enabled", true);
+
+ // Experiments can only be used if AddonSettings.EXPERIMENTS_ENABLED is true.
+ // This is the condition behind the flag, minus Cu.isInAutomation. Currently
+ // that flag is false despite this being a test (see bug 1598804), but that
+ // is desired in this case because we want the test to confirm the real-world
+ // behavior instead of test-specific behavior.
+ const areTemporaryExperimentsAllowed =
+ !AppConstants.MOZ_REQUIRE_SIGNING ||
+ AppConstants.NIGHTLY_BUILD ||
+ AppConstants.MOZ_DEV_EDITION;
+
+ await testLoadManifest({
+ expectPrivileged: areTemporaryExperimentsAllowed,
+ location: getInstallLocation({ isTemporary: true }),
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_locale.js b/toolkit/mozapps/extensions/test/xpcshell/test_locale.js
new file mode 100644
index 0000000000..2824c489b4
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_locale.js
@@ -0,0 +1,103 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+add_task(async function setup() {
+ // Setup for test
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+ await promiseStartupManager();
+});
+
+// Tests that the localized properties are visible before installation
+add_task(async function test_1() {
+ await restartWithLocales(["fr-FR"]);
+
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ name: "__MSG_name__",
+ description: "__MSG_description__",
+ default_locale: "en",
+
+ browser_specific_settings: {
+ gecko: {
+ id: "addon1@tests.mozilla.org",
+ },
+ },
+ },
+
+ files: {
+ "_locales/en/messages.json": {
+ name: {
+ message: "Fallback Name",
+ description: "name",
+ },
+ description: {
+ message: "Fallback Description",
+ description: "description",
+ },
+ },
+ "_locales/fr_FR/messages.json": {
+ name: {
+ message: "fr-FR Name",
+ description: "name",
+ },
+ description: {
+ message: "fr-FR Description",
+ description: "description",
+ },
+ },
+ "_locales/de-DE/messages.json": {
+ name: {
+ message: "de-DE Name",
+ description: "name",
+ },
+ },
+ },
+ });
+
+ let install = await AddonManager.getInstallForFile(xpi);
+ Assert.equal(install.addon.name, "fr-FR Name");
+ Assert.equal(install.addon.description, "fr-FR Description");
+ await install.install();
+});
+
+// Tests that the localized properties are visible after installation
+add_task(async function test_2() {
+ let addon = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ Assert.notEqual(addon, null);
+
+ Assert.equal(addon.name, "fr-FR Name");
+ Assert.equal(addon.description, "fr-FR Description");
+
+ await addon.disable();
+});
+
+// Test that the localized properties are still there when disabled.
+add_task(async function test_3() {
+ let addon = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.name, "fr-FR Name");
+});
+
+// Test that changing locale works
+add_task(async function test_5() {
+ await restartWithLocales(["de-DE"]);
+
+ let addon = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ Assert.notEqual(addon, null);
+
+ Assert.equal(addon.name, "de-DE Name");
+ Assert.equal(addon.description, "Fallback Description");
+});
+
+// Test that missing locales use the fallbacks
+add_task(async function test_6() {
+ await restartWithLocales(["nl-NL"]);
+
+ let addon = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ Assert.notEqual(addon, null);
+
+ Assert.equal(addon.name, "Fallback Name");
+ Assert.equal(addon.description, "Fallback Description");
+
+ await addon.enable();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_moved_extension_metadata.js b/toolkit/mozapps/extensions/test/xpcshell/test_moved_extension_metadata.js
new file mode 100644
index 0000000000..13ac8a1e57
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_moved_extension_metadata.js
@@ -0,0 +1,186 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This test is disabled but is being kept around so it can eventualy
+// be modernized and re-enabled. But is uses obsolete test helpers that
+// fail lint, so just skip linting it for now.
+/* eslint-disable */
+
+// This verifies that moving an extension in the filesystem without any other
+// change still keeps updated compatibility information
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+// Enable loading extensions from the user and system scopes
+Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_USER
+);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2", "1.9.2");
+
+var testserver = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+testserver.registerDirectory("/data/", do_get_file("data"));
+
+var userDir = gProfD.clone();
+userDir.append("extensions2");
+userDir.append(gAppInfo.ID);
+
+var dirProvider = {
+ getFile(aProp, aPersistent) {
+ aPersistent.value = false;
+ if (aProp == "XREUSysExt") return userDir.parent;
+ return null;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]),
+};
+Services.dirsvc.registerProvider(dirProvider);
+
+var addon1 = {
+ id: "addon1@tests.mozilla.org",
+ version: "1.0",
+ name: "Test 1",
+ bootstrap: true,
+ updateURL: "http://example.com/data/test_bug655254.json",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "1",
+ },
+ ],
+};
+
+const ADDONS = [
+ {
+ "install.rdf": {
+ id: "addon2@tests.mozilla.org",
+ name: "Test 2",
+
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "2",
+ maxVersion: "2",
+ },
+ ],
+ },
+ "bootstrap.js": `
+ /* exported startup, shutdown */
+ function startup(data, reason) {
+ Services.prefs.setIntPref("bootstraptest.active_version", 1);
+ }
+
+ function shutdown(data, reason) {
+ Services.prefs.setIntPref("bootstraptest.active_version", 0);
+ }
+ `,
+ },
+];
+
+const XPIS = ADDONS.map(addon => AddonTestUtils.createTempXPIFile(addon));
+
+add_task(async function test_1() {
+ var time = Date.now();
+ var dir = await promiseWriteInstallRDFForExtension(addon1, userDir);
+ setExtensionModifiedTime(dir, time);
+
+ await manuallyInstall(XPIS[0], userDir, "addon2@tests.mozilla.org");
+
+ await promiseStartupManager();
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([
+ "addon1@tests.mozilla.org",
+ "addon2@tests.mozilla.org",
+ ]);
+ Assert.notEqual(a1, null);
+ Assert.ok(a1.appDisabled);
+ Assert.ok(!a1.isActive);
+ Assert.ok(!isExtensionInBootstrappedList(userDir, a1.id));
+
+ Assert.notEqual(a2, null);
+ Assert.ok(!a2.appDisabled);
+ Assert.ok(a2.isActive);
+ Assert.ok(isExtensionInBootstrappedList(userDir, a2.id));
+ Assert.equal(Services.prefs.getIntPref("bootstraptest.active_version"), 1);
+
+ await AddonTestUtils.promiseFindAddonUpdates(
+ a1,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+
+ await promiseRestartManager();
+
+ let a1_2 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ Assert.notEqual(a1_2, null);
+ Assert.ok(!a1_2.appDisabled);
+ Assert.ok(a1_2.isActive);
+ Assert.ok(isExtensionInBootstrappedList(userDir, a1_2.id));
+
+ await promiseShutdownManager();
+
+ Assert.equal(Services.prefs.getIntPref("bootstraptest.active_version"), 0);
+
+ userDir.parent.moveTo(gProfD, "extensions3");
+ userDir = gProfD.clone();
+ userDir.append("extensions3");
+ userDir.append(gAppInfo.ID);
+ Assert.ok(userDir.exists());
+
+ await promiseStartupManager();
+
+ let [a1_3, a2_3] = await AddonManager.getAddonsByIDs([
+ "addon1@tests.mozilla.org",
+ "addon2@tests.mozilla.org",
+ ]);
+ Assert.notEqual(a1_3, null);
+ Assert.ok(!a1_3.appDisabled);
+ Assert.ok(a1_3.isActive);
+ Assert.ok(isExtensionInBootstrappedList(userDir, a1_3.id));
+
+ Assert.notEqual(a2_3, null);
+ Assert.ok(!a2_3.appDisabled);
+ Assert.ok(a2_3.isActive);
+ Assert.ok(isExtensionInBootstrappedList(userDir, a2_3.id));
+ Assert.equal(Services.prefs.getIntPref("bootstraptest.active_version"), 1);
+});
+
+// Set up the profile
+add_task(async function test_2() {
+ let a2 = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ Assert.notEqual(a2, null);
+ Assert.ok(!a2.appDisabled);
+ Assert.ok(a2.isActive);
+ Assert.ok(isExtensionInBootstrappedList(userDir, a2.id));
+ Assert.equal(Services.prefs.getIntPref("bootstraptest.active_version"), 1);
+
+ await a2.disable();
+ Assert.equal(Services.prefs.getIntPref("bootstraptest.active_version"), 0);
+
+ await promiseShutdownManager();
+
+ userDir.parent.moveTo(gProfD, "extensions4");
+ userDir = gProfD.clone();
+ userDir.append("extensions4");
+ userDir.append(gAppInfo.ID);
+ Assert.ok(userDir.exists());
+
+ await promiseStartupManager();
+
+ let [a1_2, a2_2] = await AddonManager.getAddonsByIDs([
+ "addon1@tests.mozilla.org",
+ "addon2@tests.mozilla.org",
+ ]);
+ Assert.notEqual(a1_2, null);
+ Assert.ok(!a1_2.appDisabled);
+ Assert.ok(a1_2.isActive);
+ Assert.ok(isExtensionInBootstrappedList(userDir, a1_2.id));
+
+ Assert.notEqual(a2_2, null);
+ Assert.ok(a2_2.userDisabled);
+ Assert.ok(!a2_2.isActive);
+ Assert.ok(!isExtensionInBootstrappedList(userDir, a2_2.id));
+ Assert.equal(Services.prefs.getIntPref("bootstraptest.active_version"), 0);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_no_addons.js b/toolkit/mozapps/extensions/test/xpcshell/test_no_addons.js
new file mode 100644
index 0000000000..71425bec4a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_no_addons.js
@@ -0,0 +1,79 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test startup and restart when no add-ons are installed
+// bug 944006
+
+// Load XPI Provider to get schema version ID
+const {
+ XPIInternal: { DB_SCHEMA },
+} = ChromeUtils.import("resource://gre/modules/addons/XPIProvider.jsm");
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+// Test for a preference to either exist with a specified value, or not exist at all
+function checkPending() {
+ try {
+ Assert.ok(!Services.prefs.getBoolPref("extensions.pendingOperations"));
+ } catch (e) {
+ // OK
+ }
+}
+
+// Make sure all our extension state is empty/nonexistent
+function check_empty_state() {
+ Assert.equal(
+ Services.prefs.getIntPref("extensions.databaseSchema"),
+ DB_SCHEMA
+ );
+ checkPending();
+}
+
+// After first run with no add-ons, we expect:
+// no extensions.json is created
+// no extensions.ini
+// database schema version preference is set
+// bootstrap add-ons preference is not found
+// add-on directory state preference is an empty array
+// no pending operations
+add_task(async function first_run() {
+ await promiseStartupManager();
+ check_empty_state();
+ await true;
+});
+
+// Now do something that causes a DB load, and re-check
+async function trigger_db_load() {
+ let addonList = await AddonManager.getAddonsByTypes(["extension"]);
+
+ Assert.equal(addonList.length, 0);
+ check_empty_state();
+
+ await true;
+}
+add_task(trigger_db_load);
+
+// Now restart the manager and check again
+add_task(async function restart_and_recheck() {
+ await promiseRestartManager();
+ check_empty_state();
+ await true;
+});
+
+// and reload the DB again
+add_task(trigger_db_load);
+
+// When we start up with no DB and an old database schema, we should update the
+// schema number but not create a database
+add_task(async function upgrade_schema_version() {
+ await promiseShutdownManager();
+ Services.prefs.setIntPref("extensions.databaseSchema", 1);
+
+ await promiseStartupManager();
+ Assert.equal(
+ Services.prefs.getIntPref("extensions.databaseSchema"),
+ DB_SCHEMA
+ );
+ check_empty_state();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_nodisable_hidden.js b/toolkit/mozapps/extensions/test/xpcshell/test_nodisable_hidden.js
new file mode 100644
index 0000000000..a7bd6f52bb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_nodisable_hidden.js
@@ -0,0 +1,99 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This test verifies that hidden add-ons cannot be user disabled.
+
+// for system add-ons
+const distroDir = FileUtils.getDir("ProfD", ["sysfeatures"], true);
+registerDirectory("XREAppFeat", distroDir);
+
+const NORMAL_ID = "normal@tests.mozilla.org";
+const SYSTEM_ID = "system@tests.mozilla.org";
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+// normal add-ons can be user disabled.
+add_task(async function () {
+ await promiseStartupManager();
+
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test disabling hidden add-ons, non-hidden add-on case.",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: NORMAL_ID } },
+ },
+ });
+
+ let addon = await promiseAddonByID(NORMAL_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(
+ addon.name,
+ "Test disabling hidden add-ons, non-hidden add-on case."
+ );
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(!addon.userDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ // normal add-ons can be disabled by the user.
+ await addon.disable();
+
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(
+ addon.name,
+ "Test disabling hidden add-ons, non-hidden add-on case."
+ );
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.userDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ await addon.uninstall();
+
+ await promiseShutdownManager();
+});
+
+// system add-ons can never be user disabled.
+add_task(async function () {
+ let xpi = createTempWebExtensionFile({
+ manifest: {
+ name: "Test disabling hidden add-ons, hidden system add-on case.",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: SYSTEM_ID } },
+ },
+ });
+ xpi.copyTo(distroDir, `${SYSTEM_ID}.xpi`);
+ await overrideBuiltIns({ system: [SYSTEM_ID] });
+
+ await promiseStartupManager();
+
+ let addon = await promiseAddonByID(SYSTEM_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(
+ addon.name,
+ "Test disabling hidden add-ons, hidden system add-on case."
+ );
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(!addon.userDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ // system add-ons cannot be disabled by the user.
+ await Assert.rejects(
+ addon.disable(),
+ err => err.message == `Cannot disable system add-on ${SYSTEM_ID}`,
+ "disable() on a hidden add-on should fail"
+ );
+
+ Assert.ok(!addon.userDisabled);
+ Assert.ok(addon.isActive);
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_onPropertyChanged_appDisabled.js b/toolkit/mozapps/extensions/test/xpcshell/test_onPropertyChanged_appDisabled.js
new file mode 100644
index 0000000000..7a7cd6543e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_onPropertyChanged_appDisabled.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const ID = "addon1@tests.mozilla.org";
+add_task(async function run_test() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ let xpi = createAddon({
+ id: ID,
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "0.1",
+ maxVersion: "0.2",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, AddonTestUtils.profileExtensions, ID);
+
+ AddonManager.strictCompatibility = false;
+ await promiseStartupManager();
+
+ let addon = await AddonManager.getAddonByID(ID);
+ Assert.notEqual(addon, null);
+ await addon.disable();
+
+ Assert.ok(addon.userDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.ok(!addon.appDisabled);
+
+ let promise = promiseAddonEvent("onPropertyChanged");
+ AddonManager.strictCompatibility = true;
+ let [, properties] = await promise;
+
+ Assert.deepEqual(
+ properties,
+ ["appDisabled"],
+ "Got onPropertyChanged for appDisabled"
+ );
+ Assert.ok(addon.appDisabled);
+
+ promise = promiseAddonEvent("onPropertyChanged");
+ AddonManager.strictCompatibility = false;
+ [, properties] = await promise;
+
+ Assert.deepEqual(
+ properties,
+ ["appDisabled"],
+ "Got onPropertyChanged for appDisabled"
+ );
+ Assert.ok(!addon.appDisabled);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_permissions.js b/toolkit/mozapps/extensions/test/xpcshell/test_permissions.js
new file mode 100644
index 0000000000..30c9aa92b0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_permissions.js
@@ -0,0 +1,199 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Checks that permissions set in preferences are correctly imported but can
+// be removed by the user.
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const XPI_MIMETYPE = "application/x-xpinstall";
+
+function newPrincipal(uri) {
+ return Services.scriptSecurityManager.createContentPrincipal(
+ NetUtil.newURI(uri),
+ {}
+ );
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2", "2");
+
+ Services.prefs.setCharPref(
+ "xpinstall.whitelist.add",
+ "https://test1.com,https://test2.com"
+ );
+ Services.prefs.setCharPref(
+ "xpinstall.whitelist.add.36",
+ "https://test3.com,https://www.test4.com"
+ );
+ Services.prefs.setCharPref(
+ "xpinstall.whitelist.add.test5",
+ "https://test5.com"
+ );
+
+ PermissionTestUtils.add(
+ "https://www.test9.com",
+ "install",
+ Ci.nsIPermissionManager.ALLOW_ACTION
+ );
+
+ await promiseStartupManager();
+
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("http://test1.com")
+ )
+ );
+ Assert.ok(
+ AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://test1.com")
+ )
+ );
+ Assert.ok(
+ AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test2.com")
+ )
+ );
+ Assert.ok(
+ AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://test3.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://test4.com")
+ )
+ );
+ Assert.ok(
+ AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test4.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("http://www.test5.com")
+ )
+ );
+ Assert.ok(
+ AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test5.com")
+ )
+ );
+
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("http://www.test6.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test6.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://test7.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test8.com")
+ )
+ );
+
+ // This should remain unaffected
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("http://www.test9.com")
+ )
+ );
+ Assert.ok(
+ AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test9.com")
+ )
+ );
+
+ Services.perms.removeAll();
+
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://test1.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test2.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://test3.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test4.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test5.com")
+ )
+ );
+
+ // Upgrade the application and verify that the permissions are still not there
+ await promiseRestartManager("2");
+
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://test1.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test2.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://test3.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test4.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test5.com")
+ )
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_permissions_prefs.js b/toolkit/mozapps/extensions/test/xpcshell/test_permissions_prefs.js
new file mode 100644
index 0000000000..d7bcaa038c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_permissions_prefs.js
@@ -0,0 +1,99 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that xpinstall.[whitelist|blacklist].add preferences are emptied when
+// converted into permissions.
+
+const PREF_XPI_WHITELIST_PERMISSIONS = "xpinstall.whitelist.add";
+const PREF_XPI_BLACKLIST_PERMISSIONS = "xpinstall.blacklist.add";
+
+const { PermissionsTestUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PermissionsUtils.sys.mjs"
+);
+
+function newPrincipal(uri) {
+ return Services.scriptSecurityManager.createContentPrincipal(
+ NetUtil.newURI(uri),
+ {}
+ );
+}
+
+function do_check_permission_prefs(preferences) {
+ // Check preferences were emptied
+ for (let pref of preferences) {
+ try {
+ Assert.equal(Services.prefs.getCharPref(pref), "");
+ } catch (e) {
+ // Successfully emptied
+ }
+ }
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9");
+
+ // Create own preferences to test
+ Services.prefs.setCharPref("xpinstall.whitelist.add.EMPTY", "");
+ Services.prefs.setCharPref(
+ "xpinstall.whitelist.add.TEST",
+ "http://whitelist.example.com"
+ );
+ Services.prefs.setCharPref("xpinstall.blacklist.add.EMPTY", "");
+ Services.prefs.setCharPref(
+ "xpinstall.blacklist.add.TEST",
+ "http://blacklist.example.com"
+ );
+
+ // Get list of preferences to check
+ var whitelistPreferences = Services.prefs.getChildList(
+ PREF_XPI_WHITELIST_PERMISSIONS
+ );
+ var blacklistPreferences = Services.prefs.getChildList(
+ PREF_XPI_BLACKLIST_PERMISSIONS
+ );
+ var preferences = whitelistPreferences.concat(blacklistPreferences);
+
+ await promiseStartupManager();
+
+ // Permissions are imported lazily - act as thought we're checking an install,
+ // to trigger on-deman importing of the permissions.
+ AddonManager.isInstallAllowed(
+ "application/x-xpinstall",
+ newPrincipal("http://example.com/file.xpi")
+ );
+ do_check_permission_prefs(preferences);
+
+ // Import can also be triggered by an observer notification by any other area
+ // of code, such as a permissions management UI.
+
+ // First, request to flush all permissions
+ PermissionsTestUtils.clearImportedPrefBranches();
+ Services.prefs.setCharPref(
+ "xpinstall.whitelist.add.TEST2",
+ "https://whitelist2.example.com"
+ );
+ Services.obs.notifyObservers(null, "flush-pending-permissions", "install");
+ do_check_permission_prefs(preferences);
+
+ // Then, request to flush just install permissions
+ PermissionsTestUtils.clearImportedPrefBranches();
+ Services.prefs.setCharPref(
+ "xpinstall.whitelist.add.TEST3",
+ "https://whitelist3.example.com"
+ );
+ Services.obs.notifyObservers(null, "flush-pending-permissions");
+ do_check_permission_prefs(preferences);
+
+ // And a request to flush some other permissions sholdn't flush install permissions
+ PermissionsTestUtils.clearImportedPrefBranches();
+ Services.prefs.setCharPref(
+ "xpinstall.whitelist.add.TEST4",
+ "https://whitelist4.example.com"
+ );
+ Services.obs.notifyObservers(null, "flush-pending-permissions", "lolcats");
+ Assert.equal(
+ Services.prefs.getCharPref("xpinstall.whitelist.add.TEST4"),
+ "https://whitelist4.example.com"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_pref_properties.js b/toolkit/mozapps/extensions/test/xpcshell/test_pref_properties.js
new file mode 100644
index 0000000000..cb816dcd8d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_pref_properties.js
@@ -0,0 +1,221 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests the preference-related properties of AddonManager
+// eg: AddonManager.checkCompatibility, AddonManager.updateEnabled, etc
+
+var gManagerEventsListener = {
+ seenEvents: [],
+ init() {
+ let events = [
+ "onCompatibilityModeChanged",
+ "onCheckUpdateSecurityChanged",
+ "onUpdateModeChanged",
+ ];
+ events.forEach(function (aEvent) {
+ this[aEvent] = function () {
+ info("Saw event " + aEvent);
+ this.seenEvents.push(aEvent);
+ };
+ }, this);
+ AddonManager.addManagerListener(this);
+ // Try to add twice, to test that the second time silently fails.
+ AddonManager.addManagerListener(this);
+ },
+ shutdown() {
+ AddonManager.removeManagerListener(this);
+ },
+ expect(aEvents) {
+ this.expectedEvents = aEvents;
+ },
+ checkExpected() {
+ info("Checking expected events...");
+ while (this.expectedEvents.length) {
+ let event = this.expectedEvents.pop();
+ info("Looking for expected event " + event);
+ let matchingEvents = this.seenEvents.filter(function (aSeenEvent) {
+ return aSeenEvent == event;
+ });
+ Assert.equal(matchingEvents.length, 1);
+ }
+ this.seenEvents = [];
+ },
+};
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ Services.prefs.setBoolPref("extensions.update.enabled", true);
+ Services.prefs.setBoolPref("extensions.update.autoUpdateDefault", true);
+ Services.prefs.setBoolPref("extensions.strictCompatibility", true);
+ Services.prefs.setBoolPref("extensions.checkUpdatesecurity", true);
+
+ await promiseStartupManager();
+ gManagerEventsListener.init();
+
+ // AddonManager.updateEnabled
+ gManagerEventsListener.expect(["onUpdateModeChanged"]);
+ AddonManager.updateEnabled = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.updateEnabled);
+ Assert.ok(!Services.prefs.getBoolPref("extensions.update.enabled"));
+
+ gManagerEventsListener.expect([]);
+ AddonManager.updateEnabled = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.updateEnabled);
+ Assert.ok(!Services.prefs.getBoolPref("extensions.update.enabled"));
+
+ gManagerEventsListener.expect(["onUpdateModeChanged"]);
+ AddonManager.updateEnabled = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.updateEnabled);
+ Assert.ok(Services.prefs.getBoolPref("extensions.update.enabled"));
+
+ gManagerEventsListener.expect([]);
+ AddonManager.updateEnabled = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.updateEnabled);
+ Assert.ok(Services.prefs.getBoolPref("extensions.update.enabled"));
+
+ // AddonManager.autoUpdateDefault
+ gManagerEventsListener.expect(["onUpdateModeChanged"]);
+ AddonManager.autoUpdateDefault = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.autoUpdateDefault);
+ Assert.ok(!Services.prefs.getBoolPref("extensions.update.autoUpdateDefault"));
+
+ gManagerEventsListener.expect([]);
+ AddonManager.autoUpdateDefault = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.autoUpdateDefault);
+ Assert.ok(!Services.prefs.getBoolPref("extensions.update.autoUpdateDefault"));
+
+ gManagerEventsListener.expect(["onUpdateModeChanged"]);
+ AddonManager.autoUpdateDefault = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.autoUpdateDefault);
+ Assert.ok(Services.prefs.getBoolPref("extensions.update.autoUpdateDefault"));
+
+ gManagerEventsListener.expect([]);
+ AddonManager.autoUpdateDefault = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.autoUpdateDefault);
+ Assert.ok(Services.prefs.getBoolPref("extensions.update.autoUpdateDefault"));
+
+ // AddonManager.strictCompatibility
+ gManagerEventsListener.expect(["onCompatibilityModeChanged"]);
+ AddonManager.strictCompatibility = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.strictCompatibility);
+ Assert.ok(!Services.prefs.getBoolPref("extensions.strictCompatibility"));
+
+ gManagerEventsListener.expect([]);
+ AddonManager.strictCompatibility = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.strictCompatibility);
+ Assert.ok(!Services.prefs.getBoolPref("extensions.strictCompatibility"));
+
+ gManagerEventsListener.expect(["onCompatibilityModeChanged"]);
+ AddonManager.strictCompatibility = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.strictCompatibility);
+ Assert.ok(Services.prefs.getBoolPref("extensions.strictCompatibility"));
+
+ gManagerEventsListener.expect([]);
+ AddonManager.strictCompatibility = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.strictCompatibility);
+ Assert.ok(Services.prefs.getBoolPref("extensions.strictCompatibility"));
+
+ // AddonManager.checkCompatibility
+ if (isNightlyChannel()) {
+ var version = "nightly";
+ } else {
+ version = Services.appinfo.version.replace(
+ /^([^\.]+\.[0-9]+[a-z]*).*/gi,
+ "$1"
+ );
+ }
+ const COMPATIBILITY_PREF = "extensions.checkCompatibility." + version;
+
+ gManagerEventsListener.expect(["onCompatibilityModeChanged"]);
+ AddonManager.checkCompatibility = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.checkCompatibility);
+ Assert.ok(!Services.prefs.getBoolPref(COMPATIBILITY_PREF));
+
+ gManagerEventsListener.expect([]);
+ AddonManager.checkCompatibility = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.checkCompatibility);
+ Assert.ok(!Services.prefs.getBoolPref(COMPATIBILITY_PREF));
+
+ gManagerEventsListener.expect(["onCompatibilityModeChanged"]);
+ AddonManager.checkCompatibility = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.checkCompatibility);
+ Assert.ok(!Services.prefs.prefHasUserValue(COMPATIBILITY_PREF));
+
+ gManagerEventsListener.expect([]);
+ AddonManager.checkCompatibility = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.checkCompatibility);
+ Assert.ok(!Services.prefs.prefHasUserValue(COMPATIBILITY_PREF));
+
+ // AddonManager.checkUpdateSecurity
+ gManagerEventsListener.expect(["onCheckUpdateSecurityChanged"]);
+ AddonManager.checkUpdateSecurity = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.checkUpdateSecurity);
+ if (AddonManager.checkUpdateSecurityDefault) {
+ Assert.ok(!Services.prefs.getBoolPref("extensions.checkUpdateSecurity"));
+ } else {
+ Assert.ok(
+ !Services.prefs.prefHasUserValue("extensions.checkUpdateSecurity")
+ );
+ }
+
+ gManagerEventsListener.expect([]);
+ AddonManager.checkUpdateSecurity = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.checkUpdateSecurity);
+ if (AddonManager.checkUpdateSecurityDefault) {
+ Assert.ok(!Services.prefs.getBoolPref("extensions.checkUpdateSecurity"));
+ } else {
+ Assert.ok(
+ !Services.prefs.prefHasUserValue("extensions.checkUpdateSecurity")
+ );
+ }
+
+ gManagerEventsListener.expect(["onCheckUpdateSecurityChanged"]);
+ AddonManager.checkUpdateSecurity = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.checkUpdateSecurity);
+ if (!AddonManager.checkUpdateSecurityDefault) {
+ Assert.ok(Services.prefs.getBoolPref("extensions.checkUpdateSecurity"));
+ } else {
+ Assert.ok(
+ !Services.prefs.prefHasUserValue("extensions.checkUpdateSecurity")
+ );
+ }
+
+ gManagerEventsListener.expect([]);
+ AddonManager.checkUpdateSecurity = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.checkUpdateSecurity);
+ if (!AddonManager.checkUpdateSecurityDefault) {
+ Assert.ok(Services.prefs.getBoolPref("extensions.checkUpdateSecurity"));
+ } else {
+ Assert.ok(
+ !Services.prefs.prefHasUserValue("extensions.checkUpdateSecurity")
+ );
+ }
+
+ gManagerEventsListener.shutdown();
+
+ // After removing the listener, ensure we get no further events.
+ gManagerEventsListener.expect([]);
+ AddonManager.updateEnabled = false;
+ gManagerEventsListener.checkExpected();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_provider_markSafe.js b/toolkit/mozapps/extensions/test/xpcshell/test_provider_markSafe.js
new file mode 100644
index 0000000000..e8062a2caf
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_provider_markSafe.js
@@ -0,0 +1,43 @@
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+function mockAddonProvider(name) {
+ let mockProvider = {
+ markSafe: false,
+ apiAccessed: false,
+
+ startup() {
+ if (this.markSafe) {
+ AddonManagerPrivate.markProviderSafe(this);
+ }
+
+ AddonManager.isInstallEnabled("made-up-mimetype");
+ },
+ supportsMimetype(mimetype) {
+ this.apiAccessed = true;
+ return false;
+ },
+
+ get name() {
+ return name;
+ },
+ };
+
+ return mockProvider;
+}
+
+add_task(async function testMarkSafe() {
+ info("Starting with provider normally");
+ let provider = mockAddonProvider("Mock1");
+ AddonManagerPrivate.registerProvider(provider);
+ await promiseStartupManager();
+ ok(!provider.apiAccessed, "Provider API should not have been accessed");
+ AddonManagerPrivate.unregisterProvider(provider);
+ await promiseShutdownManager();
+
+ info("Starting with provider that marks itself safe");
+ provider.apiAccessed = false;
+ provider.markSafe = true;
+ AddonManagerPrivate.registerProvider(provider);
+ await promiseStartupManager();
+ ok(provider.apiAccessed, "Provider API should have been accessed");
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_provider_shutdown.js b/toolkit/mozapps/extensions/test/xpcshell/test_provider_shutdown.js
new file mode 100644
index 0000000000..498b28a0c9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_provider_shutdown.js
@@ -0,0 +1,96 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Verify that we report shutdown status for Addon Manager providers
+// and AddonRepository correctly.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+// Make a mock AddonRepository that just lets us hang shutdown.
+// Needs two promises - one to let us know that AM has called shutdown,
+// and one for us to let AM know that shutdown is done.
+function mockAddonProvider(aName) {
+ let mockProvider = {
+ donePromise: null,
+ doneResolve: null,
+ doneReject: null,
+ shutdownPromise: null,
+ shutdownResolve: null,
+
+ get name() {
+ return aName;
+ },
+
+ shutdown() {
+ this.shutdownResolve();
+ return this.donePromise;
+ },
+ };
+ mockProvider.donePromise = new Promise((resolve, reject) => {
+ mockProvider.doneResolve = resolve;
+ mockProvider.doneReject = reject;
+ });
+ mockProvider.shutdownPromise = new Promise((resolve, reject) => {
+ mockProvider.shutdownResolve = resolve;
+ });
+ return mockProvider;
+}
+
+// Helper to find a particular shutdown blocker's status in the JSON blob
+function findInStatus(aStatus, aName) {
+ for (let { name, state } of aStatus.state) {
+ if (name == aName) {
+ return state;
+ }
+ }
+ return null;
+}
+
+/*
+ * Make sure we report correctly when an add-on provider or AddonRepository block shutdown
+ */
+add_task(async function blockRepoShutdown() {
+ // the mock provider behaves enough like AddonRepository for the purpose of this test
+ let mockRepo = mockAddonProvider("Mock repo");
+ AddonManagerPrivate.overrideAddonRepository(mockRepo);
+
+ let mockProvider = mockAddonProvider("Mock provider");
+
+ await promiseStartupManager();
+ AddonManagerPrivate.registerProvider(mockProvider);
+
+ let { fetchState } =
+ MockAsyncShutdown.profileBeforeChange.blockers[0].options;
+
+ // Start shutting the manager down
+ let managerDown = promiseShutdownManager();
+
+ // Wait for manager to call provider shutdown.
+ await mockProvider.shutdownPromise;
+ // check AsyncShutdown state
+ let status = fetchState();
+ equal(findInStatus(status[1], "Mock provider"), "(none)");
+ equal(status[2].name, "AddonRepository: async shutdown");
+ equal(status[2].state, "pending");
+ // let the provider finish
+ mockProvider.doneResolve();
+
+ // Wait for manager to call repo shutdown and start waiting for it
+ await mockRepo.shutdownPromise;
+ // Check the shutdown state
+ status = fetchState();
+ equal(status[1].name, "AddonManager: Waiting for providers to shut down.");
+ equal(status[1].state, "Complete");
+ equal(status[2].name, "AddonRepository: async shutdown");
+ equal(status[2].state, "in progress");
+
+ // Now finish our shutdown, and wait for the manager to wrap up
+ mockRepo.doneResolve();
+ await managerDown;
+
+ // Check the shutdown state again
+ status = fetchState();
+ equal(status[0].name, "AddonRepository: async shutdown");
+ equal(status[0].state, "done");
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_provider_unsafe_access_shutdown.js b/toolkit/mozapps/extensions/test/xpcshell/test_provider_unsafe_access_shutdown.js
new file mode 100644
index 0000000000..720ccaf0c4
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_provider_unsafe_access_shutdown.js
@@ -0,0 +1,65 @@
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+var shutdownOrder = [];
+
+function mockAddonProvider(name) {
+ let mockProvider = {
+ hasShutdown: false,
+ unsafeAccess: false,
+
+ shutdownCallback: null,
+
+ startup() {},
+ shutdown() {
+ this.hasShutdown = true;
+ shutdownOrder.push(this.name);
+ if (this.shutdownCallback) {
+ return this.shutdownCallback();
+ }
+ return undefined;
+ },
+ getAddonByID(id, callback) {
+ if (this.hasShutdown) {
+ this.unsafeAccess = true;
+ }
+ callback(null);
+ },
+
+ get name() {
+ return name;
+ },
+ };
+
+ return mockProvider;
+}
+
+add_task(async function unsafeProviderShutdown() {
+ let firstProvider = mockAddonProvider("Mock1");
+ AddonManagerPrivate.registerProvider(firstProvider);
+ let secondProvider = mockAddonProvider("Mock2");
+ AddonManagerPrivate.registerProvider(secondProvider);
+
+ await promiseStartupManager();
+
+ let shutdownPromise = null;
+ await new Promise(resolve => {
+ secondProvider.shutdownCallback = function () {
+ return AddonManager.getAddonByID("does-not-exist").then(() => {
+ resolve();
+ });
+ };
+
+ shutdownPromise = promiseShutdownManager();
+ });
+ await shutdownPromise;
+
+ equal(
+ shutdownOrder.join(","),
+ ["Mock1", "Mock2"].join(","),
+ "Mock providers should have shutdown in expected order"
+ );
+ ok(
+ !firstProvider.unsafeAccess,
+ "First registered mock provider should not have been accessed unsafely"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_provider_unsafe_access_startup.js b/toolkit/mozapps/extensions/test/xpcshell/test_provider_unsafe_access_startup.js
new file mode 100644
index 0000000000..8e066973f2
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_provider_unsafe_access_startup.js
@@ -0,0 +1,59 @@
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+var startupOrder = [];
+
+function mockAddonProvider(name) {
+ let mockProvider = {
+ hasStarted: false,
+ unsafeAccess: false,
+
+ startupCallback: null,
+
+ startup() {
+ this.hasStarted = true;
+ startupOrder.push(this.name);
+ if (this.startupCallback) {
+ this.startupCallback();
+ }
+ },
+ getAddonByID(id, callback) {
+ if (!this.hasStarted) {
+ this.unsafeAccess = true;
+ }
+ callback(null);
+ },
+
+ get name() {
+ return name;
+ },
+ };
+
+ return mockProvider;
+}
+
+add_task(async function unsafeProviderStartup() {
+ let secondProvider = null;
+
+ await new Promise(resolve => {
+ let firstProvider = mockAddonProvider("Mock1");
+ firstProvider.startupCallback = function () {
+ resolve(AddonManager.getAddonByID("does-not-exist"));
+ };
+ AddonManagerPrivate.registerProvider(firstProvider);
+
+ secondProvider = mockAddonProvider("Mock2");
+ AddonManagerPrivate.registerProvider(secondProvider);
+
+ promiseStartupManager();
+ });
+
+ equal(
+ startupOrder.join(","),
+ ["Mock1", "Mock2"].join(","),
+ "Mock providers should have hasStarted in expected order"
+ );
+ ok(
+ !secondProvider.unsafeAccess,
+ "Second registered mock provider should not have been accessed unsafely"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_proxies.js b/toolkit/mozapps/extensions/test/xpcshell/test_proxies.js
new file mode 100644
index 0000000000..2f40147f83
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_proxies.js
@@ -0,0 +1,235 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests the semantics of extension proxy files and symlinks
+
+var ADDONS = [
+ {
+ id: "proxy1@tests.mozilla.org",
+ dirId: "proxy1@tests.mozilla.com",
+ type: "proxy",
+ },
+ {
+ id: "proxy2@tests.mozilla.org",
+ type: "proxy",
+ },
+ {
+ id: "symlink1@tests.mozilla.org",
+ dirId: "symlink1@tests.mozilla.com",
+ type: "symlink",
+ },
+ {
+ id: "symlink2@tests.mozilla.org",
+ type: "symlink",
+ },
+];
+
+const gHaveSymlinks = AppConstants.platform != "win";
+
+function createSymlink(aSource, aDest) {
+ if (aSource instanceof Ci.nsIFile) {
+ aSource = aSource.path;
+ }
+ if (aDest instanceof Ci.nsIFile) {
+ aDest = aDest.path;
+ }
+
+ const ln = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ ln.initWithPath("/bin/ln");
+
+ const lnProcess = Cc["@mozilla.org/process/util;1"].createInstance(
+ Ci.nsIProcess
+ );
+ lnProcess.init(ln);
+
+ const args = ["-s", aSource, aDest];
+ lnProcess.run(true, args, args.length);
+ Assert.equal(lnProcess.exitValue, 0);
+}
+
+async function promiseWriteFile(aFile, aData) {
+ if (!(await IOUtils.exists(aFile.parent.path))) {
+ await IOUtils.makeDirectory(aFile.parent.path);
+ }
+
+ return IOUtils.writeUTF8(aFile.path, aData);
+}
+
+function checkAddonsExist() {
+ for (let addon of ADDONS) {
+ let file = addon.directory.clone();
+ file.append("manifest.json");
+ Assert.ok(file.exists());
+ }
+}
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+function run_test() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2", "2");
+
+ // Unpacked extensions are never signed, so this can only run with
+ // signature checks disabled.
+ Services.prefs.setBoolPref(PREF_XPI_SIGNATURES_REQUIRED, false);
+
+ add_task(run_proxy_tests);
+
+ if (gHaveSymlinks) {
+ add_task(run_symlink_tests);
+ }
+
+ run_next_test();
+}
+
+async function run_proxy_tests() {
+ if (!gHaveSymlinks) {
+ ADDONS = ADDONS.filter(a => a.type != "symlink");
+ }
+
+ for (let addon of ADDONS) {
+ addon.directory = gTmpD.clone();
+ addon.directory.append(addon.id);
+
+ addon.proxyFile = profileDir.clone();
+ addon.proxyFile.append(addon.dirId || addon.id);
+
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ name: addon.id,
+ browser_specific_settings: { gecko: { id: addon.id } },
+ },
+ });
+ let path = PathUtils.join(gTmpD.path, addon.id);
+ await AddonTestUtils.promiseWriteFilesToDir(path, files);
+
+ if (addon.type == "proxy") {
+ await promiseWriteFile(addon.proxyFile, addon.directory.path);
+ } else if (addon.type == "symlink") {
+ await createSymlink(addon.directory, addon.proxyFile);
+ }
+ }
+
+ await promiseStartupManager();
+
+ // Check that all add-ons original sources still exist after invalid
+ // add-ons have been removed at startup.
+ checkAddonsExist();
+
+ let addons = await AddonManager.getAddonsByIDs(ADDONS.map(addon => addon.id));
+ try {
+ for (let [i, addon] of addons.entries()) {
+ // Ensure that valid proxied add-ons were installed properly on
+ // platforms that support the installation method.
+ print(
+ ADDONS[i].id,
+ ADDONS[i].dirId,
+ ADDONS[i].dirId != null,
+ ADDONS[i].type == "symlink"
+ );
+ Assert.equal(addon == null, ADDONS[i].dirId != null);
+
+ if (addon != null) {
+ let fixURL = url => {
+ if (AppConstants.platform == "macosx") {
+ return url.replace(RegExp(`^file:///private/`), "file:///");
+ }
+ return url;
+ };
+
+ // Check that proxied add-ons do not have upgrade permissions.
+ Assert.equal(addon.permissions & AddonManager.PERM_CAN_UPGRADE, 0);
+
+ // Check that getResourceURI points to the right place.
+ Assert.equal(
+ Services.io.newFileURI(ADDONS[i].directory).spec,
+ fixURL(addon.getResourceURI().spec),
+ `Base resource URL resolves as expected`
+ );
+
+ let file = ADDONS[i].directory.clone();
+ file.append("manifest.json");
+
+ Assert.equal(
+ Services.io.newFileURI(file).spec,
+ fixURL(addon.getResourceURI("manifest.json").spec),
+ `Resource URLs resolve as expected`
+ );
+
+ await addon.uninstall();
+ }
+ }
+
+ // Check that original sources still exist after explicit uninstall.
+ await promiseRestartManager();
+ checkAddonsExist();
+
+ await promiseShutdownManager();
+
+ // Check that all of the proxy files have been removed and remove
+ // the original targets.
+ for (let addon of ADDONS) {
+ equal(
+ addon.proxyFile.exists(),
+ addon.dirId != null,
+ `Proxy file ${addon.proxyFile.path} should exist?`
+ );
+ addon.directory.remove(true);
+ try {
+ addon.proxyFile.remove(false);
+ } catch (e) {}
+ }
+ } catch (e) {
+ do_throw(e);
+ }
+}
+
+// Check that symlinks are not followed out of a directory tree
+// when deleting an add-on.
+async function run_symlink_tests() {
+ const ID = "unpacked@test.mozilla.org";
+
+ let tempDirectory = gTmpD.clone();
+ tempDirectory.append(ID);
+
+ let tempFile = tempDirectory.clone();
+ tempFile.append("test.txt");
+ tempFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644);
+
+ let addonDirectory = profileDir.clone();
+ addonDirectory.append(ID);
+
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: { browser_specific_settings: { gecko: { id: ID } } },
+ });
+ await AddonTestUtils.promiseWriteFilesToDir(addonDirectory.path, files);
+
+ let symlink = addonDirectory.clone();
+ symlink.append(tempDirectory.leafName);
+ await createSymlink(tempDirectory, symlink);
+
+ // Make sure that the symlink was created properly.
+ let file = symlink.clone();
+ file.append(tempFile.leafName);
+ file.normalize();
+ Assert.equal(file.path.replace(/^\/private\//, "/"), tempFile.path);
+
+ await promiseStartupManager();
+
+ let addon = await AddonManager.getAddonByID(ID);
+ Assert.notEqual(addon, null);
+
+ await addon.uninstall();
+
+ await promiseRestartManager();
+ await promiseShutdownManager();
+
+ // Check that the install directory is gone.
+ Assert.ok(!addonDirectory.exists());
+
+ // Check that the temp file is not gone.
+ Assert.ok(tempFile.exists());
+
+ tempDirectory.remove(true);
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_recommendations.js b/toolkit/mozapps/extensions/test/xpcshell/test_recommendations.js
new file mode 100644
index 0000000000..a650bafccc
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_recommendations.js
@@ -0,0 +1,712 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { XPIInstall } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIInstall.jsm"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
+});
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Management",
+ "resource://gre/modules/Extension.jsm"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.usePrivilegedSignatures = false;
+
+const testStartTime = Date.now();
+const not_before = new Date(testStartTime - 3600000).toISOString();
+const not_after = new Date(testStartTime + 3600000).toISOString();
+const RECOMMENDATION_FILE_NAME = "mozilla-recommendation.json";
+
+const server = AddonTestUtils.createHttpServer();
+const SERVER_BASE_URL = `http://localhost:${server.identity.primaryPort}`;
+// Allow the test extensions to be updated from an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+Services.prefs.setCharPref(
+ "extensions.update.background.url",
+ `${SERVER_BASE_URL}/upgrade.json`
+);
+
+function createFileWithRecommendations(id, recommendation, version = "1.0.0") {
+ let files = {};
+ if (recommendation) {
+ files[RECOMMENDATION_FILE_NAME] = recommendation;
+ }
+ return AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id } },
+ },
+ files,
+ });
+}
+
+async function installAddonWithRecommendations(id, recommendation) {
+ let xpi = createFileWithRecommendations(id, recommendation);
+ let install = await AddonTestUtils.promiseInstallFile(xpi);
+ return install.addon;
+}
+
+function checkRecommended(addon, recommended = true) {
+ equal(
+ addon.isRecommended,
+ recommended,
+ "The add-on isRecommended state is correct"
+ );
+ equal(
+ addon.recommendationStates.includes("recommended"),
+ recommended,
+ "The add-on recommendationStates is correct"
+ );
+}
+
+function waitForPendingExtension(extId) {
+ return new Promise(resolve => {
+ Management.on("startup", function startupListener() {
+ const pendingExtensionsMap =
+ Services.ppmm.sharedData.get("extensions/pending");
+ if (pendingExtensionsMap.has(extId)) {
+ Management.off("startup", startupListener);
+ resolve(pendingExtensionsMap.get(extId));
+ }
+ });
+ });
+}
+
+async function assertPendingExtensionIgnoreQuarantined({
+ addonId,
+ expectedIgnoreQuarantined,
+}) {
+ info(
+ `Reload ${addonId} and verify ignoreQuarantine in extensions/pending sharedData`
+ );
+ const promisePendingExtension = waitForPendingExtension(addonId);
+ const addon = await AddonManager.getAddonByID(addonId);
+ await addon.disable();
+ await addon.enable();
+ Assert.deepEqual(
+ (await promisePendingExtension).ignoreQuarantine,
+ expectedIgnoreQuarantined,
+ `Expect ignoreQuarantine to be true in pending/extensions details for ${addon.id}`
+ );
+}
+
+function assertQuarantinedFromURI({ domain, expected }) {
+ const { processType, PROCESS_TYPE_DEFAULT } = Services.appinfo;
+ const processTypeStr =
+ processType === PROCESS_TYPE_DEFAULT ? "Main Process" : "Child Process";
+ const testURI = Services.io.newURI(`https://${domain}/`);
+ for (const [addonId, expectedQuarantinedFromURI] of Object.entries(
+ expected
+ )) {
+ Assert.equal(
+ WebExtensionPolicy.getByID(addonId).quarantinedFromURI(testURI),
+ expectedQuarantinedFromURI,
+ `Expect ${addonId} to ${
+ expectedQuarantinedFromURI ? "not be" : "be"
+ } quarantined from ${domain} in ${processTypeStr}`
+ );
+ }
+}
+
+async function assertQuarantinedFromURIInChildProcessAsync({
+ domain,
+ expected,
+}) {
+ // Doesn't matter what content url we us here, as long as we are
+ // using a content url to be able to run the assertions from a
+ // child process.
+ const testUrl = SERVER_BASE_URL;
+ const page = await ExtensionTestUtils.loadContentPage(testUrl);
+ // TODO(rpl): look into Bug 1648545 changes and determine what
+ // would need to change to use page.spawn instead.
+ await page.legacySpawn({ domain, expected }, assertQuarantinedFromURI);
+ await page.close();
+}
+
+function getUpdatesJSONFor(id, version) {
+ return {
+ updates: [
+ {
+ version,
+ update_link: `${SERVER_BASE_URL}/addons/${id}.xpi`,
+ },
+ ],
+ };
+}
+
+function registerUpdateXPIFile({ id, version, recommendationStates }) {
+ const recommendation = {
+ addon_id: id,
+ states: recommendationStates,
+ validity: { not_before, not_after },
+ };
+ let xpi = createFileWithRecommendations(id, recommendation, version);
+ server.registerFile(`/addons/${id}.xpi`, xpi);
+}
+
+function waitForBootstrapUpdateMethod(addonId, newVersion) {
+ return new Promise(resolve => {
+ function listener(_evt, { method, params }) {
+ if (
+ method === "update" &&
+ params.id === addonId &&
+ params.newVersion === newVersion
+ ) {
+ AddonTestUtils.off("bootstrap-method", listener);
+ info(`Update bootstrap method called for ${addonId} ${newVersion}`);
+ resolve({ addonId, method, params });
+ }
+ }
+ AddonTestUtils.on("bootstrap-method", listener);
+ });
+}
+
+function assertUpdateBootstrapCall(detailsBootstrapUpdates, expected) {
+ const actualPerAddonId = detailsBootstrapUpdates
+ .map(({ addonId, params }) => {
+ return [addonId, params.recommendationState?.states];
+ })
+ .reduce((acc, [addonId, states]) => {
+ acc[addonId] = states;
+ return acc;
+ }, {});
+ Assert.deepEqual(
+ actualPerAddonId,
+ expected,
+ `Got the expected recommendation states in the update bootstrap calls`
+ );
+}
+
+add_setup(async () => {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function text_no_file() {
+ const id = "no-recommendations-file@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, null);
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function text_malformed_file() {
+ const id = "no-recommendations-file@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, "This is not JSON");
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_valid_recommendation_file() {
+ const id = "recommended@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ });
+
+ checkRecommended(addon);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_multiple_valid_recommendation_file() {
+ const id = "recommended@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended", "something"],
+ validity: { not_before, not_after },
+ });
+
+ checkRecommended(addon);
+ ok(
+ addon.recommendationStates.includes("something"),
+ "The add-on recommendationStates contains something"
+ );
+
+ await addon.uninstall();
+});
+
+add_task(async function test_unsigned() {
+ // Don't override the certificate, so that the test add-on is unsigned.
+ AddonTestUtils.useRealCertChecks = true;
+ // Allow unsigned add-on to be installed.
+ Services.prefs.setBoolPref("xpinstall.signatures.required", false);
+
+ const id = "unsigned@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ });
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+ AddonTestUtils.useRealCertChecks = false;
+ Services.prefs.setBoolPref("xpinstall.signatures.required", true);
+});
+
+add_task(async function test_temporary() {
+ const id = "temporary@test.web.extension";
+ let xpi = createFileWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ });
+ let addon = await XPIInstall.installTemporaryAddon(xpi);
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+// Tests that unpacked temporary add-ons are not recommended.
+add_task(async function test_temporary_directory() {
+ const id = "temporary-dir@test.web.extension";
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ },
+ files: {
+ [RECOMMENDATION_FILE_NAME]: {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ },
+ },
+ });
+ let extDir = await AddonTestUtils.promiseWriteFilesToExtension(
+ gTmpD.path,
+ id,
+ files,
+ true
+ );
+
+ let addon = await XPIInstall.installTemporaryAddon(extDir);
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+ extDir.remove(true);
+});
+
+add_task(async function test_builtin() {
+ const id = "builtin@test.web.extension";
+ let extension = await installBuiltinExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ },
+ background: `browser.test.sendMessage("started");`,
+ files: {
+ [RECOMMENDATION_FILE_NAME]: {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ },
+ },
+ });
+ await extension.awaitMessage("started");
+
+ checkRecommended(extension.addon, false);
+
+ await extension.unload();
+});
+
+add_task(async function test_theme() {
+ const id = "theme@test.web.extension";
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ theme: {},
+ },
+ files: {
+ [RECOMMENDATION_FILE_NAME]: {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ },
+ },
+ });
+ let { addon } = await AddonTestUtils.promiseInstallFile(xpi);
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_not_recommended() {
+ const id = "not-recommended@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["something"],
+ validity: { not_before, not_after },
+ });
+
+ checkRecommended(addon, false);
+ ok(
+ addon.recommendationStates.includes("something"),
+ "The add-on recommendationStates contains something"
+ );
+
+ await addon.uninstall();
+});
+
+add_task(async function test_id_missing() {
+ const id = "no-id@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ });
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_expired() {
+ const id = "expired@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended", "something"],
+ validity: { not_before, not_after: not_before },
+ });
+
+ checkRecommended(addon, false);
+ ok(
+ !addon.recommendationStates.length,
+ "The add-on recommendationStates does not contain anything"
+ );
+
+ await addon.uninstall();
+});
+
+add_task(async function test_not_valid_yet() {
+ const id = "expired@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_before: not_after, not_after },
+ });
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_states_missing() {
+ const id = "states-missing@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ validity: { not_before, not_after },
+ });
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_validity_missing() {
+ const id = "validity-missing@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended"],
+ });
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_not_before_missing() {
+ const id = "not-before-missing@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_after },
+ });
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_bad_states() {
+ const id = "bad-states@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: { recommended: true },
+ validity: { not_before, not_after },
+ });
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_recommendation_persist_restart() {
+ const id = "persisted-recommendation@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ });
+
+ checkRecommended(addon);
+
+ await AddonTestUtils.promiseRestartManager();
+
+ addon = await AddonManager.getAddonByID(id);
+
+ checkRecommended(addon);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_isLineExtension_internal_svg_permission() {
+ async function assertLineExtensionStateAndPermission(
+ addonId,
+ expectLineExtension,
+ isRestart
+ ) {
+ const { extension } = WebExtensionPolicy.getByID(addonId);
+
+ const msgShould = expectLineExtension ? "should" : "should not";
+
+ equal(
+ extension.hasPermission("internal:svgContextPropertiesAllowed"),
+ expectLineExtension,
+ `"${addonId}" ${msgShould} have permission internal:svgContextPropertiesAllowed`
+ );
+ if (isRestart) {
+ const { permissions } = await ExtensionPermissions.get(addonId);
+ Assert.deepEqual(
+ permissions,
+ expectLineExtension ? ["internal:svgContextPropertiesAllowed"] : [],
+ `ExtensionPermission.get("${addonId}") result ${msgShould} include internal:svgContextPropertiesAllowed permission`
+ );
+ }
+ }
+
+ const idLineExt = "line-extension@test.web.extension";
+ await installAddonWithRecommendations(idLineExt, {
+ addon_id: idLineExt,
+ states: ["line"],
+ validity: { not_before, not_after },
+ });
+
+ info(`Test line extension ${idLineExt}`);
+ await assertLineExtensionStateAndPermission(idLineExt, true, false);
+ await AddonTestUtils.promiseRestartManager();
+ info(`Test ${idLineExt} again after AOM restart`);
+ await assertLineExtensionStateAndPermission(idLineExt, true, true);
+ let addon = await AddonManager.getAddonByID(idLineExt);
+ await addon.uninstall();
+
+ const idNonLineExt = "non-line-extension@test.web.extension";
+ await installAddonWithRecommendations(idNonLineExt, {
+ addon_id: idNonLineExt,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ });
+
+ info(`Test non line extension: ${idNonLineExt}`);
+ await assertLineExtensionStateAndPermission(idNonLineExt, false, false);
+ await AddonTestUtils.promiseRestartManager();
+ info(`Test ${idNonLineExt} again after AOM restart`);
+ await assertLineExtensionStateAndPermission(idNonLineExt, false, true);
+ addon = await AddonManager.getAddonByID(idNonLineExt);
+ await addon.uninstall();
+});
+
+add_task(
+ {
+ pref_set: [
+ ["extensions.quarantinedDomains.enabled", true],
+ ["extensions.quarantinedDomains.list", "quarantined.example.org"],
+ ],
+ },
+ async function test_recommended_exempt_from_quarantined() {
+ const invalidRecommendedId = "invalid-recommended@test.web.extension";
+ const validRecommendedId = "recommended@test.web.extension";
+ const validAndroidRecommendedId = "recommended-android@test.web.extension";
+ const lineExtensionId = "line@test.web.extension";
+ const validMultiRecommendedId = "recommended-multi@test.web.extension";
+ // NOTE: confirm that any future recommendation state that was considered
+ // valid and signed by AMO is also going to be exempt, which does also include
+ // recommendation states that we are not using anymore but are still technically
+ // supported by autograph (e.g. verified), see:
+ // https://github.com/mozilla-services/autograph/blob/8a34847a/autograph.yaml#L1456-L1460
+ const validFutureRecStateId = "fake-future-valid-state@test.web.extension";
+
+ const recommendationStatesPerId = {
+ [invalidRecommendedId]: null,
+ [validRecommendedId]: ["recommended"],
+ [validAndroidRecommendedId]: ["recommended-android"],
+ [lineExtensionId]: ["line"],
+ [validFutureRecStateId]: ["fake-future-valid-state"],
+ [validMultiRecommendedId]: ["recommended", "recommended-android"],
+ };
+
+ for (const [extId, expectedRecStates] of Object.entries(
+ recommendationStatesPerId
+ )) {
+ const recommendationData = expectedRecStates
+ ? {
+ addon_id: extId,
+ states: expectedRecStates,
+ validity: { not_before, not_after },
+ }
+ : null;
+ await installAddonWithRecommendations(extId, recommendationData);
+ // Check that the expected recommendation states are reflected by the
+ // value returned by the AddonWrapper.recommendationStates getter.
+ const addon = await AddonManager.getAddonByID(extId);
+ Assert.deepEqual(
+ addon.recommendationStates,
+ expectedRecStates ?? [],
+ `Addon ${extId} has the expected recommendation states`
+ );
+ }
+
+ assertQuarantinedFromURI({
+ domain: "quarantined.example.org",
+ expected: {
+ [invalidRecommendedId]: true,
+ [validRecommendedId]: false,
+ [validAndroidRecommendedId]: false,
+ [lineExtensionId]: false,
+ [validFutureRecStateId]: false,
+ [validMultiRecommendedId]: false,
+ },
+ });
+
+ await assertQuarantinedFromURIInChildProcessAsync({
+ domain: "quarantined.example.org",
+ expected: {
+ [invalidRecommendedId]: true,
+ [validRecommendedId]: false,
+ [validAndroidRecommendedId]: false,
+ [lineExtensionId]: false,
+ [validFutureRecStateId]: false,
+ [validMultiRecommendedId]: false,
+ },
+ });
+
+ // NOTE: we only cover the 3 basic cases in the rest of this test case
+ // (we have verified that ignoreQuarantine is being set to the expected
+ // value and so the other cases shouldn't matter for the behaviors being
+ // explicitly covered by the remaining part of this test task).
+
+ // Make sure the ignoreQuarantine property is also propagated in the child
+ // processes while the extensions may still be not fully initialized (and
+ // so listed in the `extensions/pending` sharedData entry).
+ await assertPendingExtensionIgnoreQuarantined({
+ addonId: validRecommendedId,
+ expectedIgnoreQuarantined: true,
+ });
+ await assertPendingExtensionIgnoreQuarantined({
+ addonId: lineExtensionId,
+ expectedIgnoreQuarantined: true,
+ });
+ await assertPendingExtensionIgnoreQuarantined({
+ addonId: invalidRecommendedId,
+ expectedIgnoreQuarantined: false,
+ });
+
+ info("Verify ignoreQuarantine again after application restart");
+
+ await AddonTestUtils.promiseRestartManager();
+ assertQuarantinedFromURI({
+ domain: "quarantined.example.org",
+ expected: {
+ [invalidRecommendedId]: true,
+ [validRecommendedId]: false,
+ [lineExtensionId]: false,
+ },
+ });
+
+ info("Verify ignoreQuarantine again after addon updates");
+
+ AddonTestUtils.registerJSON(server, "/upgrade.json", {
+ addons: {
+ [invalidRecommendedId]: getUpdatesJSONFor(
+ invalidRecommendedId,
+ "2.0.0"
+ ),
+ [validRecommendedId]: getUpdatesJSONFor(validRecommendedId, "2.0.0"),
+ [lineExtensionId]: getUpdatesJSONFor(lineExtensionId, "2.0.0"),
+ },
+ });
+ registerUpdateXPIFile({
+ id: invalidRecommendedId,
+ version: "2.0.0",
+ recommendationStates: recommendationStatesPerId[invalidRecommendedId],
+ });
+ registerUpdateXPIFile({
+ id: validRecommendedId,
+ version: "2.0.0",
+ recommendationStates: recommendationStatesPerId[validRecommendedId],
+ });
+ registerUpdateXPIFile({
+ id: lineExtensionId,
+ version: "2.0.0",
+ recommendationStates: recommendationStatesPerId[lineExtensionId],
+ });
+
+ const promiseUpdatesInstalled = Promise.all([
+ waitForBootstrapUpdateMethod(invalidRecommendedId, "2.0.0"),
+ waitForBootstrapUpdateMethod(validRecommendedId, "2.0.0"),
+ waitForBootstrapUpdateMethod(lineExtensionId, "2.0.0"),
+ ]);
+
+ const promiseBackgroundUpdatesFound = TestUtils.topicObserved(
+ "addons-background-updates-found"
+ );
+ let [
+ extensionInvalidRecommended,
+ extensionValidRecommended,
+ extensionLine,
+ ] = [
+ ExtensionTestUtils.expectExtension(invalidRecommendedId),
+ ExtensionTestUtils.expectExtension(validRecommendedId),
+ ExtensionTestUtils.expectExtension(lineExtensionId),
+ ];
+
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ await promiseBackgroundUpdatesFound;
+
+ assertUpdateBootstrapCall(await promiseUpdatesInstalled, {
+ [invalidRecommendedId]: null,
+ [validRecommendedId]: ["recommended"],
+ [lineExtensionId]: ["line"],
+ });
+
+ // Wait the test extension to be fully started (prevents logspam
+ // due to the AOM trying to uninstall them while being started).
+ await Promise.all([
+ extensionInvalidRecommended.awaitStartup(),
+ extensionValidRecommended.awaitStartup(),
+ extensionLine.awaitStartup(),
+ ]);
+
+ // Uninstall all test extensions.
+ await Promise.all(
+ Object.keys(recommendationStatesPerId).map(async addonId => {
+ const addon = await AddonManager.getAddonByID(addonId);
+ await addon.uninstall();
+ })
+ );
+ }
+);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_registerchrome.js b/toolkit/mozapps/extensions/test/xpcshell/test_registerchrome.js
new file mode 100644
index 0000000000..5d45a1a273
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_registerchrome.js
@@ -0,0 +1,88 @@
+"use strict";
+
+function getFileURI(path) {
+ let file = do_get_file(".");
+ file.append(path);
+ return Services.io.newFileURI(file);
+}
+
+add_task(async function () {
+ const registry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+
+ let file1 = getFileURI("file1");
+ let file2 = getFileURI("file2");
+
+ let uri1 = getFileURI("chrome.manifest");
+ let uri2 = getFileURI("manifest.json");
+
+ let overrideURL = Services.io.newURI("chrome://global/content/foo");
+ let contentURL = Services.io.newURI("chrome://test/content/foo");
+ let localeURL = Services.io.newURI("chrome://global/locale/foo");
+
+ let origOverrideURL = registry.convertChromeURL(overrideURL);
+ let origLocaleURL = registry.convertChromeURL(localeURL);
+
+ let entry1 = aomStartup.registerChrome(uri1, [
+ ["override", "chrome://global/content/foo", file1.spec],
+ ["content", "test", file2.spec + "/"],
+ ["locale", "global", "en-US", file2.spec + "/"],
+ ]);
+
+ let entry2 = aomStartup.registerChrome(uri2, [
+ ["override", "chrome://global/content/foo", file2.spec],
+ ["content", "test", file1.spec + "/"],
+ ["locale", "global", "en-US", file1.spec + "/"],
+ ]);
+
+ // Initially, the second entry should override the first.
+ equal(registry.convertChromeURL(overrideURL).spec, file2.spec);
+ let file = file1.spec + "/foo";
+ equal(registry.convertChromeURL(contentURL).spec, file);
+ equal(registry.convertChromeURL(localeURL).spec, file);
+
+ // After destroying the second entry, the first entry should now take
+ // precedence.
+ entry2.destruct();
+ equal(registry.convertChromeURL(overrideURL).spec, file1.spec);
+ file = file2.spec + "/foo";
+ equal(registry.convertChromeURL(contentURL).spec, file);
+ equal(registry.convertChromeURL(localeURL).spec, file);
+
+ // After dropping the reference to the first entry and allowing it to
+ // be GCed, we should be back to the original entries.
+ entry1 = null; // eslint-disable-line no-unused-vars
+ Cu.forceGC();
+ Cu.forceCC();
+ equal(registry.convertChromeURL(overrideURL).spec, origOverrideURL.spec);
+ equal(registry.convertChromeURL(localeURL).spec, origLocaleURL.spec);
+ Assert.throws(
+ () => registry.convertChromeURL(contentURL),
+ e => e.result == Cr.NS_ERROR_FILE_NOT_FOUND,
+ "chrome://test/ should no longer be registered"
+ );
+});
+
+add_task(async function () {
+ const INVALID_VALUES = [
+ {},
+ "foo",
+ ["foo"],
+ [{}],
+ [[]],
+ [["locale", "global"]],
+ [["locale", "global", "en", "foo", "foo"]],
+ [["override", "en"]],
+ [["override", "en", "US", "OR"]],
+ ];
+
+ let uri = getFileURI("chrome.manifest");
+ for (let arg of INVALID_VALUES) {
+ Assert.throws(
+ () => aomStartup.registerChrome(uri, arg),
+ e => e.result == Cr.NS_ERROR_INVALID_ARG,
+ `Arg ${uneval(arg)} should throw`
+ );
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_registry.js b/toolkit/mozapps/extensions/test/xpcshell/test_registry.js
new file mode 100644
index 0000000000..22909e6362
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_registry.js
@@ -0,0 +1,160 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that extensions installed through the registry work as expected
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+// Enable loading extensions from the user and system scopes
+Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ AddonManager.SCOPE_PROFILE +
+ AddonManager.SCOPE_USER +
+ AddonManager.SCOPE_SYSTEM
+);
+
+Services.prefs.setIntPref("extensions.sideloadScopes", AddonManager.SCOPE_ALL);
+
+const ID1 = "addon1@tests.mozilla.org";
+const ID2 = "addon2@tests.mozilla.org";
+let xpi1, xpi2;
+
+let registry;
+
+add_task(async function setup() {
+ xpi1 = await createTempWebExtensionFile({
+ manifest: { browser_specific_settings: { gecko: { id: ID1 } } },
+ });
+
+ xpi2 = await createTempWebExtensionFile({
+ manifest: { browser_specific_settings: { gecko: { id: ID2 } } },
+ });
+
+ registry = new MockRegistry();
+ registerCleanupFunction(() => {
+ registry.shutdown();
+ });
+});
+
+// Tests whether basic registry install works
+add_task(async function test_1() {
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID1,
+ xpi1.path
+ );
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID2,
+ xpi2.path
+ );
+
+ await promiseStartupManager();
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+ notEqual(a1, null);
+ ok(a1.isActive);
+ ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ equal(a1.scope, AddonManager.SCOPE_SYSTEM);
+
+ notEqual(a2, null);
+ ok(a2.isActive);
+ ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ equal(a2.scope, AddonManager.SCOPE_USER);
+});
+
+// Tests whether uninstalling from the registry works
+add_task(async function test_2() {
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID1,
+ null
+ );
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID2,
+ null
+ );
+
+ await promiseRestartManager();
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+ equal(a1, null);
+ equal(a2, null);
+});
+
+// Checks that the ID in the registry must match that in the install manifest
+add_task(async function test_3() {
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID1,
+ xpi2.path
+ );
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID2,
+ xpi1.path
+ );
+
+ await promiseRestartManager();
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+ equal(a1, null);
+ equal(a2, null);
+});
+
+// Tests whether an extension's ID can change without its directory changing
+add_task(async function test_4() {
+ // Restarting with bad items in the registry should not force an EM restart
+ await promiseRestartManager();
+
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID1,
+ null
+ );
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID2,
+ null
+ );
+
+ await promiseRestartManager();
+
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID1,
+ xpi1.path
+ );
+
+ await promiseShutdownManager();
+
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID1,
+ null
+ );
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID2,
+ xpi1.path
+ );
+ xpi2.copyTo(xpi1.parent, xpi1.leafName);
+
+ await promiseStartupManager();
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+ equal(a1, null);
+ notEqual(a2, null);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_reinstall_disabled_addon.js b/toolkit/mozapps/extensions/test/xpcshell/test_reinstall_disabled_addon.js
new file mode 100644
index 0000000000..f231397072
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_reinstall_disabled_addon.js
@@ -0,0 +1,213 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const ID = "test_addon@tests.mozilla.org";
+
+const ADDONS = {
+ test_install1_1: {
+ name: "Test 1 Addon",
+ description: "Test 1 addon description",
+ manifest_version: 2,
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ },
+ test_install1_2: {
+ name: "Test 1 Addon",
+ description: "Test 1 addon description",
+ manifest_version: 2,
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ },
+};
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+ await promiseStartupManager();
+});
+
+// User intentionally reinstalls existing disabled addon of the same version.
+// No onInstalling nor onInstalled are fired.
+add_task(async function reinstallExistingDisabledAddonSameVersion() {
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ [ID]: [{ event: "onInstalling" }, { event: "onInstalled" }],
+ },
+ installEvents: [
+ { event: "onNewInstall" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ async () => {
+ const xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: ADDONS.test_install1_1,
+ });
+ let install = await AddonManager.getInstallForFile(xpi);
+ await install.install();
+ }
+ );
+
+ let addon = await promiseAddonByID(ID);
+ notEqual(addon, null);
+ equal(addon.pendingOperations, AddonManager.PENDING_NONE);
+ ok(addon.isActive);
+ ok(!addon.userDisabled);
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ [ID]: [{ event: "onDisabling" }, { event: "onDisabled" }],
+ },
+ },
+ () => addon.disable()
+ );
+
+ addon = await promiseAddonByID(ID);
+ notEqual(addon, null);
+ equal(addon.pendingOperations, AddonManager.PENDING_NONE);
+ ok(!addon.isActive);
+ ok(addon.userDisabled);
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ [ID]: [{ event: "onEnabling" }, { event: "onEnabled" }],
+ },
+ installEvents: [
+ { event: "onNewInstall" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ async () => {
+ const xpi2 = AddonTestUtils.createTempWebExtensionFile({
+ manifest: ADDONS.test_install1_1,
+ });
+ let install = await AddonManager.getInstallForFile(xpi2);
+ await install.install();
+ }
+ );
+
+ addon = await promiseAddonByID(ID);
+ notEqual(addon, null);
+ equal(addon.pendingOperations, AddonManager.PENDING_NONE);
+ ok(addon.isActive);
+ ok(!addon.userDisabled);
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ [ID]: [{ event: "onUninstalling" }, { event: "onUninstalled" }],
+ },
+ },
+ () => addon.uninstall()
+ );
+
+ addon = await promiseAddonByID(ID);
+ equal(addon, null);
+
+ await promiseRestartManager();
+});
+
+// User intentionally reinstalls existing disabled addon of different version,
+// but addon *still should be disabled*.
+add_task(async function reinstallExistingDisabledAddonDifferentVersion() {
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ [ID]: [{ event: "onInstalling" }, { event: "onInstalled" }],
+ },
+ installEvents: [
+ { event: "onNewInstall" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ async () => {
+ const xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: ADDONS.test_install1_1,
+ });
+ let install = await AddonManager.getInstallForFile(xpi);
+
+ await install.install();
+ }
+ );
+
+ let addon = await promiseAddonByID(ID);
+ notEqual(addon, null);
+ equal(addon.pendingOperations, AddonManager.PENDING_NONE);
+ ok(addon.isActive);
+ ok(!addon.userDisabled);
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ [ID]: [{ event: "onDisabling" }, { event: "onDisabled" }],
+ },
+ },
+ () => addon.disable()
+ );
+
+ addon = await promiseAddonByID(ID);
+ notEqual(addon, null);
+ equal(addon.pendingOperations, AddonManager.PENDING_NONE);
+ ok(!addon.isActive);
+ ok(addon.userDisabled);
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ [ID]: [{ event: "onInstalling" }, { event: "onInstalled" }],
+ },
+ installEvents: [
+ { event: "onNewInstall" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ async () => {
+ let xpi2 = AddonTestUtils.createTempWebExtensionFile({
+ manifest: ADDONS.test_install1_2,
+ });
+ let install = await AddonManager.getInstallForFile(xpi2);
+ await install.install();
+ }
+ );
+
+ addon = await promiseAddonByID(ID);
+ notEqual(addon, null);
+ equal(addon.pendingOperations, AddonManager.PENDING_NONE);
+ ok(!addon.isActive);
+ ok(addon.userDisabled);
+ equal(addon.version, "2.0");
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ [ID]: [{ event: "onUninstalling" }, { event: "onUninstalled" }],
+ },
+ },
+ () => addon.uninstall()
+ );
+
+ addon = await promiseAddonByID(ID);
+ equal(addon, null);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_reload.js b/toolkit/mozapps/extensions/test/xpcshell/test_reload.js
new file mode 100644
index 0000000000..993c4a9c53
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_reload.js
@@ -0,0 +1,188 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+const ID = "webextension1@tests.mozilla.org";
+
+const ADDONS = {
+ webextension_1: {
+ "manifest.json": {
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ icons: {
+ 48: "icon48.png",
+ 64: "icon64.png",
+ },
+ },
+ "chrome.manifest": "content webex ./\n",
+ },
+};
+
+async function tearDownAddon(addon) {
+ await addon.uninstall();
+ await promiseShutdownManager();
+}
+
+add_task(async function test_reloading_a_temp_addon() {
+ await promiseRestartManager();
+ let xpi = AddonTestUtils.createTempXPIFile(ADDONS.webextension_1);
+ const addon = await AddonManager.installTemporaryAddon(xpi);
+
+ var receivedOnUninstalled = false;
+ var receivedOnUninstalling = false;
+ var receivedOnInstalled = false;
+ var receivedOnInstalling = false;
+
+ const onReload = new Promise(resolve => {
+ const listener = {
+ onUninstalling: addonObj => {
+ if (addonObj.id === ID) {
+ receivedOnUninstalling = true;
+ }
+ },
+ onUninstalled: addonObj => {
+ if (addonObj.id === ID) {
+ receivedOnUninstalled = true;
+ }
+ },
+ onInstalling: addonObj => {
+ receivedOnInstalling = true;
+ equal(addonObj.id, ID);
+ },
+ onInstalled: addonObj => {
+ receivedOnInstalled = true;
+ equal(addonObj.id, ID);
+ // This should be the last event called.
+ AddonManager.removeAddonListener(listener);
+ resolve();
+ },
+ };
+ AddonManager.addAddonListener(listener);
+ });
+
+ await addon.reload();
+ await onReload;
+
+ // Make sure reload() doesn't trigger uninstall events.
+ equal(
+ receivedOnUninstalled,
+ false,
+ "reload should not trigger onUninstalled"
+ );
+ equal(
+ receivedOnUninstalling,
+ false,
+ "reload should not trigger onUninstalling"
+ );
+
+ // Make sure reload() triggers install events, like an upgrade.
+ equal(receivedOnInstalling, true, "reload should trigger onInstalling");
+ equal(receivedOnInstalled, true, "reload should trigger onInstalled");
+
+ await tearDownAddon(addon);
+});
+
+add_task(async function test_can_reload_permanent_addon() {
+ await promiseRestartManager();
+ const { addon } = await AddonTestUtils.promiseInstallXPI(
+ ADDONS.webextension_1
+ );
+
+ let disabledCalled = false;
+ let enabledCalled = false;
+ AddonManager.addAddonListener({
+ onDisabled: aAddon => {
+ Assert.ok(!enabledCalled);
+ disabledCalled = true;
+ },
+ onEnabled: aAddon => {
+ Assert.ok(disabledCalled);
+ enabledCalled = true;
+ },
+ });
+
+ await addon.reload();
+
+ Assert.ok(disabledCalled);
+ Assert.ok(enabledCalled);
+
+ notEqual(addon, null);
+ equal(addon.appDisabled, false);
+ equal(addon.userDisabled, false);
+
+ await tearDownAddon(addon);
+});
+
+add_task(async function test_reload_to_invalid_version_fails() {
+ await promiseRestartManager();
+ let tempdir = gTmpD.clone();
+
+ // The initial version of the add-on will be compatible, and will therefore load
+ const addonId = "invalid_version_cannot_be_reloaded@tests.mozilla.org";
+ let manifest = {
+ name: "invalid_version_cannot_be_reloaded",
+ description: "test invalid_version_cannot_be_reloaded",
+ manifest_version: 2,
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ },
+ },
+ };
+
+ let addonDir = await promiseWriteWebManifestForExtension(
+ manifest,
+ tempdir,
+ "invalid_version"
+ );
+ await AddonManager.installTemporaryAddon(addonDir);
+
+ let addon = await promiseAddonByID(addonId);
+ notEqual(addon, null);
+ equal(addon.id, addonId);
+ equal(addon.version, "1.0");
+ equal(addon.appDisabled, false);
+ equal(addon.userDisabled, false);
+ addonDir.remove(true);
+
+ // update the manifest to make the add-on version incompatible, so the reload will reject
+ manifest.browser_specific_settings.gecko.strict_min_version = "1";
+ manifest.browser_specific_settings.gecko.strict_max_version = "1";
+ manifest.version = "2.0";
+
+ addonDir = await promiseWriteWebManifestForExtension(
+ manifest,
+ tempdir,
+ "invalid_version",
+ false
+ );
+ let expectedMsg = new RegExp(
+ "Add-on invalid_version_cannot_be_reloaded@tests.mozilla.org is not compatible with application version. " +
+ "add-on minVersion: 1. add-on maxVersion: 1."
+ );
+
+ await Assert.rejects(
+ addon.reload(),
+ expectedMsg,
+ "Reload rejects when application version does not fall between minVersion and maxVersion"
+ );
+
+ let reloadedAddon = await promiseAddonByID(addonId);
+ notEqual(reloadedAddon, null);
+ equal(reloadedAddon.id, addonId);
+ equal(reloadedAddon.version, "1.0");
+ equal(reloadedAddon.appDisabled, false);
+ equal(reloadedAddon.userDisabled, false);
+
+ await tearDownAddon(reloadedAddon);
+ addonDir.remove(true);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_safemode.js b/toolkit/mozapps/extensions/test/xpcshell/test_safemode.js
new file mode 100644
index 0000000000..30f4564e09
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_safemode.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that extensions behave correctly in safe mode
+let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION;
+Services.prefs.setIntPref("extensions.enabledScopes", scopes);
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const ID = "addon1@tests.mozilla.org";
+const BUILTIN_ID = "builtin@tests.mozilla.org";
+const VERSION = "1.0";
+
+// Sets up the profile by installing an add-on.
+add_task(async function setup() {
+ AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1.9.2"
+ );
+ gAppInfo.inSafeMode = true;
+
+ await promiseStartupManager();
+
+ let a1 = await AddonManager.getAddonByID(ID);
+ Assert.equal(a1, null);
+ do_check_not_in_crash_annotation(ID, VERSION);
+
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test 1",
+ version: VERSION,
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+ let wrapper = await installBuiltinExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: BUILTIN_ID } },
+ },
+ });
+
+ let builtin = await AddonManager.getAddonByID(BUILTIN_ID);
+ Assert.notEqual(builtin, null, "builtin extension is installed");
+
+ await promiseRestartManager();
+
+ a1 = await AddonManager.getAddonByID(ID);
+ Assert.notEqual(a1, null);
+ Assert.ok(!a1.isActive);
+ Assert.ok(!a1.userDisabled);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, ID));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_DISABLE));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_ENABLE));
+ do_check_not_in_crash_annotation(ID, VERSION);
+
+ builtin = await AddonManager.getAddonByID(BUILTIN_ID);
+ Assert.notEqual(builtin, null, "builtin extension is installed");
+ Assert.ok(builtin.isActive, "builtin extension is active");
+ await wrapper.unload();
+});
+
+// Disabling an add-on should work
+add_task(async function test_disable() {
+ let a1 = await AddonManager.getAddonByID(ID);
+ Assert.ok(
+ !hasFlag(
+ a1.operationsRequiringRestart,
+ AddonManager.OP_NEEDS_RESTART_DISABLE
+ )
+ );
+ await a1.disable();
+ Assert.ok(!a1.isActive);
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_DISABLE));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_ENABLE));
+ do_check_not_in_crash_annotation(ID, VERSION);
+});
+
+// Enabling an add-on should happen but not become active.
+add_task(async function test_enable() {
+ let a1 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ await a1.enable();
+ Assert.ok(!a1.isActive);
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_DISABLE));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_ENABLE));
+
+ do_check_not_in_crash_annotation(ID, VERSION);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_schema_change.js b/toolkit/mozapps/extensions/test/xpcshell/test_schema_change.js
new file mode 100644
index 0000000000..591bf6eb56
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_schema_change.js
@@ -0,0 +1,157 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const PREF_DB_SCHEMA = "extensions.databaseSchema";
+const PREF_IS_EMBEDDED = "extensions.isembedded";
+
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(PREF_IS_EMBEDDED);
+});
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
+
+add_task(async function test_setup() {
+ await promiseStartupManager();
+});
+
+add_task(async function run_tests() {
+ // Fake installTelemetryInfo used in the addon installation,
+ // to verify that they are preserved after the DB is updated
+ // from the addon manifests.
+ const fakeInstallTelemetryInfo = { source: "amo", method: "amWebAPI" };
+
+ const ID = "schema-change@tests.mozilla.org";
+
+ const xpi1 = createTempWebExtensionFile({
+ manifest: {
+ name: "Test Add-on",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ const xpi2 = createTempWebExtensionFile({
+ manifest: {
+ name: "Test Add-on 2",
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ let xpiPath = PathUtils.join(profileDir.path, `${ID}.xpi`);
+
+ const TESTS = [
+ {
+ what: "Schema change with no application update reloads metadata.",
+ expectedVersion: "2.0",
+ action() {
+ Services.prefs.setIntPref(PREF_DB_SCHEMA, 0);
+ },
+ },
+ {
+ what: "Application update with no schema change does not reload metadata.",
+ expectedVersion: "1.0",
+ action() {
+ gAppInfo.version = "2";
+ },
+ },
+ {
+ what: "App update and a schema change causes a reload of the manifest.",
+ expectedVersion: "2.0",
+ action() {
+ gAppInfo.version = "3";
+ Services.prefs.setIntPref(PREF_DB_SCHEMA, 0);
+ },
+ },
+ {
+ what: "No schema change, no manifest reload.",
+ expectedVersion: "1.0",
+ action() {},
+ },
+ {
+ what: "Modified timestamp on the XPI causes a reload of the manifest.",
+ expectedVersion: "2.0",
+ async action() {
+ let stat = await IOUtils.stat(xpiPath);
+ let newLastModTime = stat.lastModified + 60 * 1000;
+ await IOUtils.setModificationTime(xpiPath, newLastModTime);
+ },
+ },
+ ];
+
+ for (let test of TESTS) {
+ info(test.what);
+ await promiseInstallFile(xpi1, false, fakeInstallTelemetryInfo);
+
+ let addon = await promiseAddonByID(ID);
+ notEqual(addon, null, "Got an addon object as expected");
+ equal(addon.version, "1.0", "Got the expected version");
+ Assert.deepEqual(
+ addon.installTelemetryInfo,
+ fakeInstallTelemetryInfo,
+ "Got the expected installTelemetryInfo after installing the addon"
+ );
+
+ await promiseShutdownManager();
+
+ let fileInfo = await IOUtils.stat(xpiPath);
+
+ xpi2.copyTo(profileDir, `${ID}.xpi`);
+
+ // Make sure the timestamp of the extension is unchanged, so it is not
+ // re-scanned for that reason.
+ await IOUtils.setModificationTime(xpiPath, fileInfo.lastModified);
+
+ await test.action();
+
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ notEqual(addon, null, "Got an addon object as expected");
+ equal(addon.version, test.expectedVersion, "Got the expected version");
+ Assert.deepEqual(
+ addon.installTelemetryInfo,
+ fakeInstallTelemetryInfo,
+ "Got the expected installTelemetryInfo after rebuilding the DB"
+ );
+
+ await addon.uninstall();
+ }
+});
+
+add_task(async function embedder_disabled_stays_disabled() {
+ Services.prefs.setBoolPref(PREF_IS_EMBEDDED, true);
+
+ const ID = "embedder-disabled@tests.mozilla.org";
+
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Add-on",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ let addon = await promiseAddonByID(ID);
+
+ equal(addon.embedderDisabled, false);
+
+ await addon.setEmbedderDisabled(true);
+ equal(addon.embedderDisabled, true);
+
+ await promiseShutdownManager();
+
+ // Change db schema to force reload
+ Services.prefs.setIntPref(PREF_DB_SCHEMA, 0);
+
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ equal(addon.embedderDisabled, true);
+
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_seen.js b/toolkit/mozapps/extensions/test/xpcshell/test_seen.js
new file mode 100644
index 0000000000..fbf43f5cc0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_seen.js
@@ -0,0 +1,277 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const ID = "addon@tests.mozilla.org";
+
+let profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+// By default disable add-ons from the profile and the system-wide scope
+const SCOPES = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_SYSTEM;
+Services.prefs.setIntPref("extensions.enabledScopes", SCOPES);
+Services.prefs.setIntPref("extensions.autoDisableScopes", SCOPES);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+const XPIS = {};
+
+// Installing an add-on through the API should mark it as seen
+add_task(async function () {
+ await promiseStartupManager();
+
+ for (let n of [1, 2]) {
+ XPIS[n] = await createTempWebExtensionFile({
+ manifest: {
+ name: "Test",
+ version: `${n}.0`,
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+ }
+
+ await promiseInstallFile(XPIS[1]);
+
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "1.0");
+ Assert.ok(!addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(!addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ // Installing an update should retain that
+ await promiseInstallFile(XPIS[2]);
+
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "2.0");
+ Assert.ok(!addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(!addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ await addon.uninstall();
+
+ await promiseShutdownManager();
+});
+
+// Sideloading an add-on in the systemwide location should mark it as unseen
+add_task(async function () {
+ let savedStartupScanScopes = Services.prefs.getIntPref(
+ "extensions.startupScanScopes"
+ );
+ Services.prefs.setIntPref("extensions.startupScanScopes", 0);
+ Services.prefs.setIntPref(
+ "extensions.sideloadScopes",
+ AddonManager.SCOPE_ALL
+ );
+
+ let systemParentDir = gTmpD.clone();
+ systemParentDir.append("systemwide-extensions");
+ registerDirectory("XRESysSExtPD", systemParentDir.clone());
+ registerCleanupFunction(() => {
+ systemParentDir.remove(true);
+ });
+
+ let systemDir = systemParentDir.clone();
+ systemDir.append(Services.appinfo.ID);
+
+ let path = await manuallyInstall(XPIS[1], systemDir, ID);
+ // Make sure the startup code will detect sideloaded updates
+ setExtensionModifiedTime(path, Date.now() - 10000);
+
+ await promiseStartupManager();
+ await AddonManagerPrivate.getNewSideloads();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "1.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ await promiseShutdownManager();
+ Services.obs.notifyObservers(path, "flush-cache-entry");
+ path.remove(true);
+
+ Services.prefs.setIntPref(
+ "extensions.startupScanScopes",
+ savedStartupScanScopes
+ );
+ Services.prefs.clearUserPref("extensions.sideloadScopes");
+});
+
+// Sideloading an add-on in the profile should mark it as unseen and it should
+// remain unseen after an update is sideloaded.
+add_task(async function () {
+ let path = await manuallyInstall(XPIS[1], profileDir, ID);
+ // Make sure the startup code will detect sideloaded updates
+ setExtensionModifiedTime(path, Date.now() - 10000);
+
+ await promiseStartupManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "1.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ await promiseShutdownManager();
+
+ // Sideloading an update shouldn't change the state
+ manuallyUninstall(profileDir, ID);
+ await manuallyInstall(XPIS[2], profileDir, ID);
+ setExtensionModifiedTime(path, Date.now());
+
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "2.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
+
+// Sideloading an add-on in the profile should mark it as unseen and it should
+// remain unseen after a regular update.
+add_task(async function () {
+ let path = await manuallyInstall(XPIS[1], profileDir, ID);
+ // Make sure the startup code will detect sideloaded updates
+ setExtensionModifiedTime(path, Date.now() - 10000);
+
+ await promiseStartupManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "1.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ // Updating through the API shouldn't change the state
+ let install = await promiseInstallFile(XPIS[2]);
+ Assert.equal(install.state, AddonManager.STATE_INSTALLED);
+ Assert.ok(
+ !hasFlag(install.addon.pendingOperations, AddonManager.PENDING_INSTALL)
+ );
+
+ addon = install.addon;
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "2.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
+
+// After a sideloaded addon has been seen, sideloading an update should
+// not reset it to unseen.
+add_task(async function () {
+ let path = await manuallyInstall(XPIS[1], profileDir, ID);
+ // Make sure the startup code will detect sideloaded updates
+ setExtensionModifiedTime(path, Date.now() - 10000);
+
+ await promiseStartupManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "1.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+ addon.markAsSeen();
+ Assert.ok(addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ await promiseShutdownManager();
+
+ // Sideloading an update shouldn't change the state
+ manuallyUninstall(profileDir, ID);
+ await manuallyInstall(XPIS[2], profileDir, ID);
+ setExtensionModifiedTime(path, Date.now());
+
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "2.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
+
+// After a sideloaded addon has been seen, manually applying an update should
+// not reset it to unseen.
+add_task(async function () {
+ let path = await manuallyInstall(XPIS[1], profileDir, ID);
+ // Make sure the startup code will detect sideloaded updates
+ setExtensionModifiedTime(path, Date.now() - 10000);
+
+ await promiseStartupManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "1.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+ addon.markAsSeen();
+ Assert.ok(addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ // Updating through the API shouldn't change the state
+ let install = await promiseInstallFile(XPIS[2]);
+ Assert.equal(install.state, AddonManager.STATE_INSTALLED);
+ Assert.ok(
+ !hasFlag(install.addon.pendingOperations, AddonManager.PENDING_INSTALL)
+ );
+
+ addon = install.addon;
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "2.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js b/toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
new file mode 100644
index 0000000000..d6fe082666
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
@@ -0,0 +1,131 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Verify that API functions fail if the Add-ons Manager isn't initialised.
+
+const IGNORE = [
+ "getPreferredIconURL",
+ "escapeAddonURI",
+ "shouldAutoUpdate",
+ "getStartupChanges",
+ "addAddonListener",
+ "removeAddonListener",
+ "addInstallListener",
+ "removeInstallListener",
+ "addManagerListener",
+ "removeManagerListener",
+ "addExternalExtensionLoader",
+ "beforeShutdown",
+ "init",
+ "stateToString",
+ "errorToString",
+ "getUpgradeListener",
+ "addUpgradeListener",
+ "removeUpgradeListener",
+ "getInstallSourceFromHost",
+ "stageLangpacksForAppUpdate",
+];
+
+const IGNORE_PRIVATE = [
+ "AddonAuthor",
+ "AddonScreenshot",
+ "startup",
+ "shutdown",
+ "addonIsActive",
+ "registerProvider",
+ "unregisterProvider",
+ "addStartupChange",
+ "removeStartupChange",
+ "getNewSideloads",
+ "finalShutdown",
+ "recordTimestamp",
+ "recordSimpleMeasure",
+ "recordException",
+ "getSimpleMeasures",
+ "simpleTimer",
+ "setTelemetryDetails",
+ "getTelemetryDetails",
+ "callNoUpdateListeners",
+ "backgroundUpdateTimerHandler",
+ "hasUpgradeListener",
+ "getUpgradeListener",
+ "isDBLoaded",
+ "recordTiming",
+ "BOOTSTRAP_REASONS",
+ "notifyAddonChanged",
+ "overrideAddonRepository",
+ "overrideAsyncShutdown",
+];
+
+async function test_functions() {
+ for (let prop in AddonManager) {
+ if (IGNORE.includes(prop)) {
+ continue;
+ }
+ if (typeof AddonManager[prop] != "function") {
+ continue;
+ }
+
+ let args = [];
+
+ // Getter functions need a callback and in some cases not having one will
+ // throw before checking if the add-ons manager is initialized so pass in
+ // an empty one.
+ if (prop.startsWith("get")) {
+ // For now all getter functions with more than one argument take the
+ // callback in the second argument.
+ if (AddonManager[prop].length > 1) {
+ args.push(undefined, () => {});
+ } else {
+ args.push(() => {});
+ }
+ }
+
+ // Clean this up in bug 1365720
+ if (prop == "getActiveAddons") {
+ args = [];
+ }
+
+ try {
+ info("AddonManager." + prop);
+ await AddonManager[prop](...args);
+ do_throw(prop + " did not throw an exception");
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_NOT_INITIALIZED) {
+ do_throw(prop + " threw an unexpected exception: " + e);
+ }
+ }
+ }
+
+ for (let prop in AddonManagerPrivate) {
+ if (IGNORE_PRIVATE.includes(prop)) {
+ continue;
+ }
+ if (typeof AddonManagerPrivate[prop] != "function") {
+ continue;
+ }
+
+ try {
+ info("AddonManagerPrivate." + prop);
+ AddonManagerPrivate[prop]();
+ do_throw(prop + " did not throw an exception");
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_NOT_INITIALIZED) {
+ do_throw(prop + " threw an unexpected exception: " + e);
+ }
+ }
+ }
+}
+
+add_task(async function () {
+ await test_functions();
+ await promiseStartupManager();
+ await promiseShutdownManager();
+ await test_functions();
+});
+
+function run_test() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+ run_next_test();
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_shutdown_barriers.js b/toolkit/mozapps/extensions/test/xpcshell/test_shutdown_barriers.js
new file mode 100644
index 0000000000..87dbcc9638
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_shutdown_barriers.js
@@ -0,0 +1,218 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ Management: "resource://gre/modules/Extension.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+add_task(async function test_shutdown_barriers() {
+ await promiseStartupManager();
+
+ const ID = "thing@xpcshell.addons.mozilla.org";
+ const VERSION = "1.42";
+
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ version: VERSION,
+ },
+ });
+
+ await AddonManager.installTemporaryAddon(xpi);
+
+ let blockersComplete = 0;
+ AddonManager.beforeShutdown.addBlocker("Before shutdown", async () => {
+ equal(blockersComplete, 0, "No blockers have run yet");
+
+ // Delay a bit to make sure this doesn't succeed because of a timing
+ // fluke.
+ await delay(1000);
+
+ let addon = await AddonManager.getAddonByID(ID);
+ checkAddon(ID, addon, {
+ version: VERSION,
+ userDisabled: false,
+ appDisabled: false,
+ });
+
+ await addon.disable();
+
+ // Delay again for the same reasons.
+ await delay(1000);
+
+ addon = await AddonManager.getAddonByID(ID);
+ checkAddon(ID, addon, {
+ version: VERSION,
+ userDisabled: true,
+ appDisabled: false,
+ });
+
+ blockersComplete++;
+ });
+ AddonManagerPrivate.finalShutdown.addBlocker("Before shutdown", async () => {
+ equal(blockersComplete, 1, "Before shutdown blocker has run");
+
+ // Should probably try to access XPIDatabase here to make sure it
+ // doesn't work correctly, but it's a bit hairy.
+
+ // Delay a bit to make sure this doesn't succeed because of a timing
+ // fluke.
+ await delay(1000);
+
+ blockersComplete++;
+ });
+
+ await promiseShutdownManager();
+ equal(
+ blockersComplete,
+ 2,
+ "Both shutdown blockers ran before manager shutdown completed"
+ );
+});
+
+// Regression test for Bug 1814104.
+add_task(async function test_wait_addons_startup_before_granting_quit() {
+ await promiseStartupManager();
+
+ const extensions = [];
+ for (let i = 0; i < 20; i++) {
+ extensions.push(
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background() {},
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: `test-extension-${i}@mozilla` },
+ },
+ },
+ })
+ );
+ }
+
+ info("Wait for all test extension to have been started once");
+ await Promise.all(extensions.map(ext => ext.startup()));
+ await promiseShutdownManager();
+
+ info("Test early shutdown while enabled addons are still being started");
+ const { XPIProvider } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIProvider.jsm"
+ );
+ function listener(_evt, extension) {
+ ok(
+ !XPIProvider._closing,
+ `Unxpected addon startup for "${extension.id}" after XPIProvider have been closed and shutting down`
+ );
+ }
+ Management.on("startup", listener);
+ promiseStartupManager();
+ await promiseShutdownManager();
+
+ info("Uninstall test extensions");
+ Management.off("startup", listener);
+ await promiseStartupManager();
+ await Promise.all(extensions.map(ext => ext.awaitStartup));
+ await Promise.all(
+ extensions.map(ext => {
+ return AddonManager.getAddonByID(ext.id).then(addon =>
+ addon?.uninstall()
+ );
+ })
+ );
+ await promiseShutdownManager();
+});
+
+// Regression test for Bug 1799421.
+add_task(async function test_late_XPIDB_load_rejected() {
+ const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+ );
+ const sandbox = sinon.createSandbox();
+ await AddonTestUtils.promiseStartupManager();
+
+ // Mock a late XPIDB load and expect to be rejected.
+ const { XPIProvider, XPIInternal } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIProvider.jsm"
+ );
+ const { XPIDatabase } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIDatabase.jsm"
+ );
+
+ const resolveDBReadySpy = sinon.spy(XPIInternal, "resolveDBReady");
+ XPIProvider._closing = true;
+ XPIDatabase._dbPromise = null;
+
+ Assert.equal(
+ await XPIDatabase.getAddonByID("test@addon"),
+ null,
+ "Expect a late getAddonByID call to be sucessfully resolved to null"
+ );
+
+ await Assert.rejects(
+ XPIDatabase._dbPromise,
+ /XPIDatabase.asyncLoadDB attempt after XPIProvider shutdown/,
+ "Expect XPIDatebase._dbPromise to be set to the expected rejected promise"
+ );
+
+ Assert.equal(
+ resolveDBReadySpy.callCount,
+ 1,
+ "Expect resolveDBReadySpy to have been called once"
+ );
+
+ Assert.equal(
+ resolveDBReadySpy.getCall(0).args[0],
+ XPIDatabase._dbPromise,
+ "Got the expected promise instance passed to the XPIProvider.resolveDBReady call"
+ );
+
+ // Cleanup sinon spy, AOM mocked status and shutdown AOM before exit test.
+ sandbox.restore();
+ XPIProvider._closing = false;
+ XPIDatabase._dbPromise = null;
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+// Regression test for Bug 1799421.
+//
+// NOTE: this test calls Services.startup.advanceShutdownPhase
+// with SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED to mock the scenario
+// and so using promiseStartupManager/promiseShutdownManager will
+// fail to bring the AddonManager back into a working status
+// because it would be detected as too late on the shutdown path.
+add_task(async function test_late_bootstrapscope_startup_rejected() {
+ await AddonTestUtils.promiseStartupManager();
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "test@addon" },
+ },
+ },
+ });
+
+ await extension.startup();
+ const { addon } = extension;
+ await addon.disable();
+ // Mock a shutdown which already got to shutdown confirmed
+ // and expect a rejection from trying to startup the BootstrapScope
+ // too late on shutdown.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+
+ await Assert.rejects(
+ addon.enable(),
+ /XPIProvider can't start bootstrap scope for test@addon after shutdown was already granted/,
+ "Got the expected rejection on trying to enable the extension after shutdown granted"
+ );
+
+ info("Cleanup mocked late bootstrap scope before exit test");
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_shutdown_early.js b/toolkit/mozapps/extensions/test/xpcshell/test_shutdown_early.js
new file mode 100644
index 0000000000..2af6d42365
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_shutdown_early.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { TelemetryEnvironment } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryEnvironment.sys.mjs"
+);
+
+// Regression test for bug 1665568: verifies that AddonManager unblocks shutdown
+// when startup is interrupted very early.
+add_task(async function test_shutdown_immediately_after_startup() {
+ // Set as migrated to prevent sync DB load at startup.
+ Services.prefs.setCharPref("extensions.lastAppVersion", "42");
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42");
+
+ Cc["@mozilla.org/addons/integration;1"]
+ .getService(Ci.nsIObserver)
+ .observe(null, "addons-startup", null);
+
+ // Above, we have configured the runtime to avoid a forced synchronous load
+ // of the database. Confirm that this is indeed the case.
+ equal(AddonManagerPrivate.isDBLoaded(), false, "DB not loaded synchronously");
+
+ let shutdownCount = 0;
+ AddonManager.beforeShutdown.addBlocker("count", async () => ++shutdownCount);
+
+ let databaseLoaded = false;
+ AddonManagerPrivate.databaseReady.then(() => {
+ databaseLoaded = true;
+ });
+
+ // Accessing TelemetryEnvironment.currentEnvironment triggers initialization
+ // of TelemetryEnvironment / EnvironmentAddonBuilder, which registers a
+ // shutdown blocker.
+ equal(
+ TelemetryEnvironment.currentEnvironment.addons,
+ undefined,
+ "TelemetryEnvironment.currentEnvironment.addons is uninitialized"
+ );
+
+ info("Immediate exit at startup, without quit-application-granted");
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWN
+ );
+ let shutdownPromise = MockAsyncShutdown.profileBeforeChange.trigger();
+ equal(shutdownCount, 1, "AddonManager.beforeShutdown has started");
+
+ // Note: Until now everything ran in the same tick of the event loop.
+
+ // Waiting for AddonManager to have shut down.
+ await shutdownPromise;
+
+ ok(databaseLoaded, "Addon DB loaded for use by TelemetryEnvironment");
+ equal(AddonManagerPrivate.isDBLoaded(), false, "DB unloaded after shutdown");
+
+ Assert.deepEqual(
+ TelemetryEnvironment.currentEnvironment.addons.activeAddons,
+ {},
+ "TelemetryEnvironment.currentEnvironment.addons is initialized"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_sideload_scopes.js b/toolkit/mozapps/extensions/test/xpcshell/test_sideload_scopes.js
new file mode 100644
index 0000000000..6631fa47c5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_sideload_scopes.js
@@ -0,0 +1,188 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+// We refer to addons that were sideloaded prior to disabling sideloading as legacy. We
+// determine that they are legacy because they are in a SCOPE that is not included
+// in AddonSettings.SCOPES_SIDELOAD.
+//
+// test_startup.js tests the legacy sideloading functionality still works as it should
+// for ESR and some 3rd party distributions, which is to allow sideloading in all scopes.
+//
+// This file tests that locking down the sideload functionality works as we expect it to, which
+// is to allow sideloading only in SCOPE_APPLICATION and SCOPE_PROFILE. We also allow the legacy
+// sideloaded addons to be updated or removed.
+//
+// We first change the sideload scope so we can sideload some addons into locations outside
+// the profile (ie. we create some legacy sideloads). We then reset it to test new sideloads.
+//
+// We expect new sideloads to only work in profile.
+// We expect new sideloads to fail elsewhere.
+// We expect to be able to change/uninstall legacy sideloads.
+
+// This test uses add-on versions that follow the toolkit version but we
+// started to encourage the use of a simpler format in Bug 1793925. We disable
+// the pref below to avoid install errors.
+Services.prefs.setBoolPref(
+ "extensions.webextensions.warnings-as-errors",
+ false
+);
+
+// IDs for scopes that should sideload when sideloading
+// is not disabled.
+let legacyIDs = [
+ getID(`legacy-global`),
+ getID(`legacy-user`),
+ getID(`legacy-app`),
+ getID(`legacy-profile`),
+];
+
+add_task(async function test_sideloads_legacy() {
+ let IDs = [];
+
+ // Create a "legacy" addon for each scope.
+ for (let [name, dir] of Object.entries(scopeDirectories)) {
+ let id = getID(`legacy-${name}`);
+ IDs.push(id);
+ await createWebExtension(id, initialVersion(name), dir);
+ }
+
+ await promiseStartupManager();
+
+ // SCOPE_APPLICATION will never sideload, so we expect 3 addons.
+ let sideloaded = await AddonManagerPrivate.getNewSideloads();
+ Assert.equal(sideloaded.length, 4, "four sideloaded addons");
+ let sideloadedIds = sideloaded.map(a => a.id);
+ for (let id of legacyIDs) {
+ Assert.ok(sideloadedIds.includes(id), `${id} is sideloaded`);
+ }
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+
+ await promiseShutdownManager();
+});
+
+// Test that a sideload install in SCOPE_PROFILE is allowed, all others are
+// disallowed.
+add_task(async function test_sideloads_disabled() {
+ // First, reset our scope pref to disable sideloading. head_sideload.js set this to ALL.
+ Services.prefs.setIntPref(
+ "extensions.sideloadScopes",
+ AddonManager.SCOPE_PROFILE
+ );
+
+ // Create 4 new addons, only one of these, "profile" should
+ // sideload.
+ for (let [name, dir] of Object.entries(scopeDirectories)) {
+ await createWebExtension(getID(name), initialVersion(name), dir);
+ }
+
+ await promiseStartupManager();
+
+ // Test that the "profile" addon has been sideloaded.
+ let sideloaded = await AddonManagerPrivate.getNewSideloads();
+ Assert.equal(sideloaded.length, 1, "one sideloaded addon");
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, [
+ getID("profile"),
+ ]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+
+ for (let [name] of Object.entries(scopeDirectories)) {
+ let id = getID(name);
+ let addon = await promiseAddonByID(id);
+ if (name === "profile") {
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.id, id);
+ Assert.ok(addon.foreignInstall);
+ Assert.equal(addon.scope, AddonManager.SCOPE_PROFILE);
+ Assert.ok(addon.userDisabled);
+ Assert.ok(!addon.seen);
+ } else {
+ Assert.equal(addon, null, `addon ${id} is not installed`);
+ }
+ }
+
+ // Test that we still have the 3 legacy addons from the prior test, plus
+ // the new "profile" addon from this test.
+ let extensionAddons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(
+ extensionAddons.length,
+ 5,
+ "five addons expected to be installed"
+ );
+ let IDs = extensionAddons.map(ext => ext.id);
+ for (let id of [getID("profile"), ...legacyIDs]) {
+ Assert.ok(IDs.includes(id), `${id} is installed`);
+ }
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_sideloads_changed() {
+ // Upgrade the manifest version
+ for (let [name, dir] of Object.entries(scopeDirectories)) {
+ let id = getID(name);
+ await createWebExtension(id, `${name}.1`, dir);
+
+ id = getID(`legacy-${name}`);
+ await createWebExtension(id, `${name}.1`, dir);
+ }
+
+ await promiseStartupManager();
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 5, "addons installed");
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, [
+ getID("profile"),
+ ...legacyIDs,
+ ]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+
+ await promiseShutdownManager();
+});
+
+// Remove one just to test the startup changes
+add_task(async function test_sideload_removal() {
+ let id = getID(`legacy-profile`);
+ let file = AddonTestUtils.getFileForAddon(profileDir, id);
+ file.remove(false);
+ Assert.ok(!file.exists());
+
+ await promiseStartupManager();
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, [id]);
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_sideload_uninstall() {
+ await promiseStartupManager();
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 4, "addons installed");
+ for (let addon of addons) {
+ let file = AddonTestUtils.getFileForAddon(
+ scopeToDir.get(addon.scope),
+ addon.id
+ );
+ await addon.uninstall();
+ // Addon file should still exist in non-profile directories.
+ Assert.equal(
+ addon.scope !== AddonManager.SCOPE_PROFILE,
+ file.exists(),
+ `file remains after uninstall for non-profile sideloads, scope ${addon.scope}`
+ );
+ }
+ addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 0, "addons left");
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_sideloads.js b/toolkit/mozapps/extensions/test/xpcshell/test_sideloads.js
new file mode 100644
index 0000000000..f2ffe8e855
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_sideloads.js
@@ -0,0 +1,117 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
+
+const ID1 = "addon1@tests.mozilla.org";
+const ID2 = "addon2@tests.mozilla.org";
+const ID3 = "addon3@tests.mozilla.org";
+
+async function createWebExtension(details) {
+ let options = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: details.id } },
+
+ name: details.name,
+
+ permissions: details.permissions,
+ },
+ };
+
+ if (details.iconURL) {
+ options.manifest.icons = { 64: details.iconURL };
+ }
+
+ let xpi = AddonTestUtils.createTempWebExtensionFile(options);
+
+ await AddonTestUtils.manuallyInstall(xpi);
+}
+
+add_task(async function test_sideloading() {
+ Services.prefs.setIntPref("extensions.autoDisableScopes", 15);
+ Services.prefs.setIntPref("extensions.startupScanScopes", 0);
+
+ await createWebExtension({
+ id: ID1,
+ name: "Test 1",
+ userDisabled: true,
+ permissions: ["tabs", "https://*/*"],
+ iconURL: "foo-icon.png",
+ });
+
+ await createWebExtension({
+ id: ID2,
+ name: "Test 2",
+ permissions: ["<all_urls>"],
+ });
+
+ await createWebExtension({
+ id: ID3,
+ name: "Test 3",
+ permissions: ["<all_urls>"],
+ });
+
+ await promiseStartupManager();
+
+ let sideloaded = await AddonManagerPrivate.getNewSideloads();
+
+ sideloaded.sort((a, b) => a.id.localeCompare(b.id));
+
+ deepEqual(
+ sideloaded.map(a => a.id),
+ [ID1, ID2, ID3],
+ "Got the correct sideload add-ons"
+ );
+
+ deepEqual(
+ sideloaded.map(a => a.userDisabled),
+ [true, true, true],
+ "All sideloaded add-ons are disabled"
+ );
+});
+
+add_task(async function test_getNewSideload_on_invalid_extension() {
+ let destDir = AddonTestUtils.profileExtensions.clone();
+
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@invalid-extension" } },
+ name: "Invalid Extension",
+ },
+ });
+
+ // Create an invalid sideload by creating a file name that doesn't match the
+ // actual extension id.
+ await IOUtils.copy(
+ xpi.path,
+ PathUtils.join(destDir.path, "@wrong-extension-filename.xpi")
+ );
+
+ // Verify that getNewSideloads does not reject or throw when one of the sideloaded extensions
+ // is invalid.
+ const newSideloads = await AddonManagerPrivate.getNewSideloads();
+
+ const sideloadsInfo = newSideloads
+ .sort((a, b) => a.id.localeCompare(b.id))
+ .map(({ id, seen, userDisabled, permissions }) => {
+ return {
+ id,
+ seen,
+ userDisabled,
+ canEnable: Boolean(permissions & AddonManager.PERM_CAN_ENABLE),
+ };
+ });
+
+ const expectedInfo = { seen: false, userDisabled: true, canEnable: true };
+
+ Assert.deepEqual(
+ sideloadsInfo,
+ [
+ { id: ID1, ...expectedInfo },
+ { id: ID2, ...expectedInfo },
+ { id: ID3, ...expectedInfo },
+ ],
+ "Got the expected sideloaded extensions"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_sideloads_after_rebuild.js b/toolkit/mozapps/extensions/test/xpcshell/test_sideloads_after_rebuild.js
new file mode 100644
index 0000000000..24aa8b228a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_sideloads_after_rebuild.js
@@ -0,0 +1,149 @@
+// Any copyright is dedicated to the Public Domain.
+// http://creativecommons.org/publicdomain/zero/1.0/
+
+"use strict";
+
+// This test uses add-on versions that follow the toolkit version but we
+// started to encourage the use of a simpler format in Bug 1793925. We disable
+// the pref below to avoid install errors.
+Services.prefs.setBoolPref(
+ "extensions.webextensions.warnings-as-errors",
+ false
+);
+
+// IDs for scopes that should sideload when sideloading
+// is not disabled.
+let legacyIDs = [
+ getID(`legacy-global`),
+ getID(`legacy-user`),
+ getID(`legacy-app`),
+ getID(`legacy-profile`),
+];
+
+// This tests that, on a rebuild after addonStartup.json and extensions.json
+// are lost, we only sideload from the profile.
+add_task(async function test_sideloads_after_rebuild() {
+ let IDs = [];
+
+ // Create a sideloaded addon for each scope before the restriction is put
+ // in place (by updating the sideloadScopes preference).
+ for (let [name, dir] of Object.entries(scopeDirectories)) {
+ let id = getID(`legacy-${name}`);
+ IDs.push(id);
+ await createWebExtension(id, initialVersion(name), dir);
+ }
+
+ await promiseStartupManager();
+
+ // SCOPE_APPLICATION will never sideload, so we expect 3
+ let sideloaded = await AddonManagerPrivate.getNewSideloads();
+ Assert.equal(sideloaded.length, 4, "four sideloaded addon");
+ let sideloadedIds = sideloaded.map(a => a.id);
+ for (let id of legacyIDs) {
+ Assert.ok(sideloadedIds.includes(id));
+ }
+
+ // After a restart that causes a database rebuild, we should have
+ // the same addons available
+ await promiseShutdownManager();
+ // Reset our scope pref so the scope limitation works.
+ Services.prefs.setIntPref(
+ "extensions.sideloadScopes",
+ AddonManager.SCOPE_PROFILE
+ );
+
+ // Try to sideload from a non-profile directory.
+ await createWebExtension(
+ getID(`sideload-global-1`),
+ initialVersion("sideload-global"),
+ globalDir
+ );
+
+ await promiseStartupManager("2");
+
+ // We should still only have 4 addons.
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 4, "addons remain installed");
+
+ await promiseShutdownManager();
+
+ // Install a sideload that will not load because it is not in
+ // appStartup.json and is not in a sideloadScope.
+ await createWebExtension(
+ getID(`sideload-global-2`),
+ initialVersion("sideload-global"),
+ globalDir
+ );
+ await createWebExtension(
+ getID(`sideload-app-2`),
+ initialVersion("sideload-global"),
+ globalDir
+ );
+ // Install a sideload that will load. We cannot currently prevent
+ // this situation.
+ await createWebExtension(
+ getID(`sideload-profile`),
+ initialVersion("sideload-profile"),
+ profileDir
+ );
+
+ // Replace the extensions.json with something bogus so we lose our xpidatabase.
+ // On AOM startup, addons are restored with help from XPIState. Existing
+ // sideloads should all remain. One new sideloaded addon should be added from
+ // the profile.
+ await IOUtils.writeJSON(gExtensionsJSON.path, {
+ not: "what we expect to find",
+ });
+ info(`**** restart AOM and rebuild XPI database`);
+ await promiseStartupManager();
+
+ addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 5, "addons installed");
+
+ await promiseShutdownManager();
+
+ // Install a sideload that will not load.
+ await createWebExtension(
+ getID(`sideload-global-3`),
+ initialVersion("sideload-global"),
+ globalDir
+ );
+ // Install a sideload that will load. We cannot currently prevent
+ // this situation.
+ await createWebExtension(
+ getID(`sideload-profile-2`),
+ initialVersion("sideload-profile"),
+ profileDir
+ );
+
+ // Replace the extensions.json with something bogus so we lose our xpidatabase.
+ await IOUtils.writeJSON(gExtensionsJSON.path, {
+ not: "what we expect to find",
+ });
+ // Delete our appStartup/XPIState data. Now we should only be able to
+ // restore extensions in the profile.
+ gAddonStartup.remove(true);
+ info(`**** restart AOM and rebuild XPI database`);
+
+ await promiseStartupManager();
+
+ addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 3, "addons installed");
+
+ let [a1, a2, a3] = await promiseAddonsByIDs([
+ getID(`legacy-profile`),
+ getID(`sideload-profile`),
+ getID(`sideload-profile-2`),
+ ]);
+
+ Assert.notEqual(a1, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a1.id));
+
+ Assert.notEqual(a2, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a2.id));
+
+ Assert.notEqual(a3, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a3.id));
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_signed_inject.js b/toolkit/mozapps/extensions/test/xpcshell/test_signed_inject.js
new file mode 100644
index 0000000000..10bf7402d6
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_signed_inject.js
@@ -0,0 +1,429 @@
+// Enable signature checks for these tests
+gUseRealCertChecks = true;
+// Disable update security
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+const DATA = "data/signing_checks/";
+const ADDONS = {
+ bootstrap: {
+ unsigned: "unsigned_bootstrap_2.xpi",
+ badid: "signed_bootstrap_badid_2.xpi",
+ signed: "signed_bootstrap_2.xpi",
+ preliminary: "preliminary_bootstrap_2.xpi",
+ },
+ nonbootstrap: {
+ unsigned: "unsigned_nonbootstrap_2.xpi",
+ badid: "signed_nonbootstrap_badid_2.xpi",
+ signed: "signed_nonbootstrap_2.xpi",
+ },
+};
+const ID = "test@tests.mozilla.org";
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+// Deletes a file from the test add-on in the profile
+function breakAddon(file) {
+ if (TEST_UNPACKED) {
+ let f = file.clone();
+ f.append("test.txt");
+ f.remove(true);
+
+ f = file.clone();
+ f.append("install.rdf");
+ f.lastModifiedTime = Date.now();
+ } else {
+ var zipW = Cc["@mozilla.org/zipwriter;1"].createInstance(Ci.nsIZipWriter);
+ zipW.open(file, FileUtils.MODE_RDWR | FileUtils.MODE_APPEND);
+ zipW.removeEntry("test.txt", false);
+ zipW.close();
+ }
+}
+
+function resetPrefs() {
+ Services.prefs.setIntPref("bootstraptest.active_version", -1);
+ Services.prefs.setIntPref("bootstraptest.installed_version", -1);
+ Services.prefs.setIntPref("bootstraptest.startup_reason", -1);
+ Services.prefs.setIntPref("bootstraptest.shutdown_reason", -1);
+ Services.prefs.setIntPref("bootstraptest.install_reason", -1);
+ Services.prefs.setIntPref("bootstraptest.uninstall_reason", -1);
+ Services.prefs.setIntPref("bootstraptest.startup_oldversion", -1);
+ Services.prefs.setIntPref("bootstraptest.shutdown_newversion", -1);
+ Services.prefs.setIntPref("bootstraptest.install_oldversion", -1);
+ Services.prefs.setIntPref("bootstraptest.uninstall_newversion", -1);
+}
+
+function clearCache(file) {
+ if (TEST_UNPACKED) {
+ return;
+ }
+
+ Services.obs.notifyObservers(file, "flush-cache-entry");
+}
+
+function getActiveVersion() {
+ return Services.prefs.getIntPref("bootstraptest.active_version");
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "4", "4");
+
+ // Start and stop the manager to initialise everything in the profile before
+ // actual testing
+ await promiseStartupManager();
+ await promiseShutdownManager();
+ resetPrefs();
+});
+
+// Injecting into profile (bootstrap)
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.bootstrap.unsigned),
+ profileDir,
+ ID
+ );
+
+ await promiseStartupManager();
+
+ // Currently we leave the sideloaded add-on there but just don't run it
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+ Assert.equal(getActiveVersion(), -1);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ resetPrefs();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.bootstrap.signed),
+ profileDir,
+ ID
+ );
+ breakAddon(file);
+
+ await promiseStartupManager();
+
+ // Currently we leave the sideloaded add-on there but just don't run it
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_BROKEN);
+ Assert.equal(getActiveVersion(), -1);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ resetPrefs();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.bootstrap.badid),
+ profileDir,
+ ID
+ );
+
+ await promiseStartupManager();
+
+ // Currently we leave the sideloaded add-on there but just don't run it
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_BROKEN);
+ Assert.equal(getActiveVersion(), -1);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ resetPrefs();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+// Installs a signed add-on then modifies it in place breaking its signing
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.bootstrap.signed),
+ profileDir,
+ ID
+ );
+
+ // Make it appear to come from the past so when we modify it later it is
+ // detected during startup. Obviously malware can bypass this method of
+ // detection but the periodic scan will catch that
+ await promiseSetExtensionModifiedTime(file.path, Date.now() - 600000);
+
+ await promiseStartupManager();
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_SIGNED);
+ Assert.equal(getActiveVersion(), 2);
+
+ await promiseShutdownManager();
+ Assert.equal(getActiveVersion(), 0);
+
+ clearCache(file);
+ breakAddon(file);
+ resetPrefs();
+
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_BROKEN);
+ Assert.equal(getActiveVersion(), -1);
+
+ let ids = AddonManager.getStartupChanges(
+ AddonManager.STARTUP_CHANGE_DISABLED
+ );
+ Assert.equal(ids.length, 1);
+ Assert.equal(ids[0], ID);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ resetPrefs();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+// Injecting into profile (non-bootstrap)
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.nonbootstrap.unsigned),
+ profileDir,
+ ID
+ );
+
+ await promiseStartupManager();
+
+ // Currently we leave the sideloaded add-on there but just don't run it
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+
+ await addon.uninstall();
+ await promiseRestartManager();
+ await promiseShutdownManager();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.nonbootstrap.signed),
+ profileDir,
+ ID
+ );
+ breakAddon(file);
+
+ await promiseStartupManager();
+
+ // Currently we leave the sideloaded add-on there but just don't run it
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_BROKEN);
+
+ await addon.uninstall();
+ await promiseRestartManager();
+ await promiseShutdownManager();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.nonbootstrap.badid),
+ profileDir,
+ ID
+ );
+
+ await promiseStartupManager();
+
+ // Currently we leave the sideloaded add-on there but just don't run it
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_BROKEN);
+
+ await addon.uninstall();
+ await promiseRestartManager();
+ await promiseShutdownManager();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+// Installs a signed add-on then modifies it in place breaking its signing
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.nonbootstrap.signed),
+ profileDir,
+ ID
+ );
+
+ // Make it appear to come from the past so when we modify it later it is
+ // detected during startup. Obviously malware can bypass this method of
+ // detection but the periodic scan will catch that
+ await promiseSetExtensionModifiedTime(file.path, Date.now() - 60000);
+
+ await promiseStartupManager();
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_SIGNED);
+
+ await promiseShutdownManager();
+
+ clearCache(file);
+ breakAddon(file);
+
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_BROKEN);
+
+ let ids = AddonManager.getStartupChanges(
+ AddonManager.STARTUP_CHANGE_DISABLED
+ );
+ Assert.equal(ids.length, 1);
+ Assert.equal(ids[0], ID);
+
+ await addon.uninstall();
+ await promiseRestartManager();
+ await promiseShutdownManager();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+// Stage install then modify before startup (non-bootstrap)
+add_task(async function () {
+ await promiseStartupManager();
+ await promiseInstallAllFiles([
+ do_get_file(DATA + ADDONS.nonbootstrap.signed),
+ ]);
+ await promiseShutdownManager();
+
+ let staged = profileDir.clone();
+ staged.append("staged");
+ staged.append(do_get_expected_addon_name(ID));
+ Assert.ok(staged.exists());
+
+ breakAddon(staged);
+ await promiseStartupManager();
+
+ // Should have refused to install the broken staged version
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon, null);
+
+ clearCache(staged);
+
+ await promiseShutdownManager();
+});
+
+// Manufacture staged install (bootstrap)
+add_task(async function () {
+ let stage = profileDir.clone();
+ stage.append("staged");
+
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.bootstrap.signed),
+ stage,
+ ID
+ );
+ breakAddon(file);
+
+ await promiseStartupManager();
+
+ // Should have refused to install the broken staged version
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon, null);
+ Assert.equal(getActiveVersion(), -1);
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+
+ await promiseShutdownManager();
+ resetPrefs();
+});
+
+// Preliminarily-signed sideloaded add-ons should work
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.bootstrap.preliminary),
+ profileDir,
+ ID
+ );
+
+ await promiseStartupManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_PRELIMINARY);
+ Assert.equal(getActiveVersion(), 2);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ resetPrefs();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+// Preliminarily-signed sideloaded add-ons should work via staged install
+add_task(async function () {
+ let stage = profileDir.clone();
+ stage.append("staged");
+
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.bootstrap.preliminary),
+ stage,
+ ID
+ );
+
+ await promiseStartupManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_PRELIMINARY);
+ Assert.equal(getActiveVersion(), 2);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ resetPrefs();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_signed_install.js b/toolkit/mozapps/extensions/test/xpcshell/test_signed_install.js
new file mode 100644
index 0000000000..065463864d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_signed_install.js
@@ -0,0 +1,337 @@
+// Enable signature checks for these tests
+gUseRealCertChecks = true;
+
+const DATA = "data/signing_checks/";
+const ADDONS = {
+ unsigned: "unsigned.xpi",
+ signed1: "signed1.xpi",
+ signed2: "signed2.xpi",
+ privileged: "privileged.xpi",
+
+ // Bug 1509093
+ // sha256Signed: "signed_bootstrap_sha256_1.xpi",
+};
+
+// The ID in signed1.xpi and signed2.xpi
+const ID = "test@somewhere.com";
+const PR_USEC_PER_MSEC = 1000;
+
+let testserver = createHttpServer({ hosts: ["example.com"] });
+
+Services.prefs.setCharPref(
+ "extensions.update.background.url",
+ "http://example.com/update.json"
+);
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+// Creates an add-on with a broken signature by changing an existing file
+function createBrokenAddonModify(file) {
+ let brokenFile = gTmpD.clone();
+ brokenFile.append("broken.xpi");
+ file.copyTo(brokenFile.parent, brokenFile.leafName);
+
+ var stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ stream.setData("FOOBAR", -1);
+ var zipW = Cc["@mozilla.org/zipwriter;1"].createInstance(Ci.nsIZipWriter);
+ zipW.open(brokenFile, FileUtils.MODE_RDWR | FileUtils.MODE_APPEND);
+ zipW.removeEntry("test.txt", false);
+ zipW.addEntryStream(
+ "test.txt",
+ new Date() * PR_USEC_PER_MSEC,
+ Ci.nsIZipWriter.COMPRESSION_NONE,
+ stream,
+ false
+ );
+ zipW.close();
+
+ return brokenFile;
+}
+
+// Creates an add-on with a broken signature by adding a new file
+function createBrokenAddonAdd(file) {
+ let brokenFile = gTmpD.clone();
+ brokenFile.append("broken.xpi");
+ file.copyTo(brokenFile.parent, brokenFile.leafName);
+
+ var stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ stream.setData("FOOBAR", -1);
+ var zipW = Cc["@mozilla.org/zipwriter;1"].createInstance(Ci.nsIZipWriter);
+ zipW.open(brokenFile, FileUtils.MODE_RDWR | FileUtils.MODE_APPEND);
+ zipW.addEntryStream(
+ "test2.txt",
+ new Date() * PR_USEC_PER_MSEC,
+ Ci.nsIZipWriter.COMPRESSION_NONE,
+ stream,
+ false
+ );
+ zipW.close();
+
+ return brokenFile;
+}
+
+// Creates an add-on with a broken signature by removing an existing file
+function createBrokenAddonRemove(file) {
+ let brokenFile = gTmpD.clone();
+ brokenFile.append("broken.xpi");
+ file.copyTo(brokenFile.parent, brokenFile.leafName);
+
+ var stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ stream.setData("FOOBAR", -1);
+ var zipW = Cc["@mozilla.org/zipwriter;1"].createInstance(Ci.nsIZipWriter);
+ zipW.open(brokenFile, FileUtils.MODE_RDWR | FileUtils.MODE_APPEND);
+ zipW.removeEntry("test.txt", false);
+ zipW.close();
+
+ return brokenFile;
+}
+
+function serveUpdate(filename) {
+ const RESPONSE = {
+ addons: {
+ [ID]: {
+ updates: [
+ {
+ version: "2.0",
+ update_link: `http://example.com/${filename}`,
+ applications: {
+ gecko: {
+ strict_min_version: "4",
+ advisory_max_version: "6",
+ },
+ },
+ },
+ ],
+ },
+ },
+ };
+ AddonTestUtils.registerJSON(testserver, "/update.json", RESPONSE);
+}
+
+async function test_install_broken(
+ file,
+ expectedError,
+ expectNullAddon = true
+) {
+ let install = await AddonManager.getInstallForFile(file);
+ await Assert.rejects(
+ install.install(),
+ /Install failed/,
+ "Install of an improperly signed extension should throw"
+ );
+
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install.error, expectedError);
+
+ if (expectNullAddon) {
+ Assert.equal(install.addon, null);
+ }
+}
+
+async function test_install_working(file, expectedSignedState) {
+ let install = await AddonManager.getInstallForFile(file);
+ await install.install();
+
+ Assert.equal(install.state, AddonManager.STATE_INSTALLED);
+ Assert.notEqual(install.addon, null);
+ Assert.equal(install.addon.signedState, expectedSignedState);
+
+ await install.addon.uninstall();
+}
+
+async function test_update_broken(file1, file2, expectedError) {
+ // First install the older version
+ await Promise.all([
+ promiseInstallFile(file1),
+ promiseWebExtensionStartup(ID),
+ ]);
+
+ testserver.registerFile("/" + file2.leafName, file2);
+ serveUpdate(file2.leafName);
+
+ let addon = await promiseAddonByID(ID);
+ let update = await promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+ await Assert.rejects(
+ install.install(),
+ /Install failed/,
+ "Update to an improperly signed extension should throw"
+ );
+
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install.error, expectedError);
+ Assert.equal(install.addon, null);
+
+ testserver.registerFile("/" + file2.leafName, null);
+ testserver.registerPathHandler("/update.json", null);
+
+ await addon.uninstall();
+}
+
+async function test_update_working(file1, file2, expectedSignedState) {
+ // First install the older version
+ await promiseInstallFile(file1);
+
+ testserver.registerFile("/" + file2.leafName, file2);
+ serveUpdate(file2.leafName);
+
+ let addon = await promiseAddonByID(ID);
+ let update = await promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+ await Promise.all([install.install(), promiseWebExtensionStartup(ID)]);
+
+ Assert.equal(install.state, AddonManager.STATE_INSTALLED);
+ Assert.notEqual(install.addon, null);
+ Assert.equal(install.addon.signedState, expectedSignedState);
+
+ testserver.registerFile("/" + file2.leafName, null);
+ testserver.registerPathHandler("/update.json", null);
+
+ await install.addon.uninstall();
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "4", "4");
+ await promiseStartupManager();
+});
+
+// Try to install a broken add-on
+add_task(async function test_install_invalid_modified() {
+ let file = createBrokenAddonModify(do_get_file(DATA + ADDONS.signed1));
+ await test_install_broken(file, AddonManager.ERROR_CORRUPT_FILE);
+ file.remove(true);
+});
+
+add_task(async function test_install_invalid_added() {
+ let file = createBrokenAddonAdd(do_get_file(DATA + ADDONS.signed1));
+ await test_install_broken(file, AddonManager.ERROR_CORRUPT_FILE);
+ file.remove(true);
+});
+
+add_task(async function test_install_invalid_removed() {
+ let file = createBrokenAddonRemove(do_get_file(DATA + ADDONS.signed1));
+ await test_install_broken(file, AddonManager.ERROR_CORRUPT_FILE);
+ file.remove(true);
+});
+
+// Try to install an unsigned add-on
+add_task(async function test_install_invalid_unsigned() {
+ let file = do_get_file(DATA + ADDONS.unsigned);
+ await test_install_broken(file, AddonManager.ERROR_SIGNEDSTATE_REQUIRED);
+});
+
+// Try to install a signed add-on
+add_task(async function test_install_valid() {
+ let file = do_get_file(DATA + ADDONS.signed1);
+ await test_install_working(file, AddonManager.SIGNEDSTATE_SIGNED);
+});
+
+add_task(
+ {
+ pref_set: [["xpinstall.signatures.dev-root", true]],
+ // `xpinstall.signatures.dev-root` is not taken into account on release
+ // builds because `MOZ_REQUIRE_SIGNING` is set to `true`.
+ skip_if: () => AppConstants.MOZ_REQUIRE_SIGNING,
+ },
+ async function test_install_valid_file_with_different_root_cert() {
+ const TEST_CASES = [
+ {
+ title: "XPI without ID in manifest",
+ xpi: "data/webext-implicit-id.xpi",
+ expectedMessage:
+ /Cannot find id for addon .+ Preference xpinstall.signatures.dev-root is set/,
+ },
+ {
+ title: "XPI with ID in manifest",
+ xpi: DATA + ADDONS.signed1,
+ expectedMessage: /Add-on test@somewhere.com is not correctly signed/,
+ },
+ ];
+
+ for (const { title, xpi, expectedMessage } of TEST_CASES) {
+ info(`test_install_valid_file_with_different_root_cert: ${title}`);
+
+ const file = do_get_file(xpi);
+
+ const awaitConsole = new Promise(resolve => {
+ Services.console.registerListener(function listener(message) {
+ if (expectedMessage.test(message.message)) {
+ Services.console.unregisterListener(listener);
+ resolve();
+ }
+ });
+ });
+
+ await test_install_broken(
+ file,
+ AddonManager.ERROR_CORRUPT_FILE,
+ // We don't expect the `addon` property on the `install` object to be
+ // `null` because that seems to happen later (when the signature is
+ // checked).
+ false
+ );
+
+ await awaitConsole;
+ }
+ }
+);
+
+// Try to install an add-on signed with SHA-256
+add_task(async function test_install_valid_sha256() {
+ // Bug 1509093
+ // let file = do_get_file(DATA + ADDONS.sha256Signed);
+ // await test_install_working(file, AddonManager.SIGNEDSTATE_SIGNED);
+});
+
+// Try to install an add-on with the "Mozilla Extensions" OU
+add_task(async function test_install_valid_privileged() {
+ let file = do_get_file(DATA + ADDONS.privileged);
+ await test_install_working(file, AddonManager.SIGNEDSTATE_PRIVILEGED);
+});
+
+// Try to update to a broken add-on
+add_task(async function test_update_invalid_modified() {
+ let file1 = do_get_file(DATA + ADDONS.signed1);
+ let file2 = createBrokenAddonModify(do_get_file(DATA + ADDONS.signed2));
+ await test_update_broken(file1, file2, AddonManager.ERROR_CORRUPT_FILE);
+ file2.remove(true);
+});
+
+add_task(async function test_update_invalid_added() {
+ let file1 = do_get_file(DATA + ADDONS.signed1);
+ let file2 = createBrokenAddonAdd(do_get_file(DATA + ADDONS.signed2));
+ await test_update_broken(file1, file2, AddonManager.ERROR_CORRUPT_FILE);
+ file2.remove(true);
+});
+
+add_task(async function test_update_invalid_removed() {
+ let file1 = do_get_file(DATA + ADDONS.signed1);
+ let file2 = createBrokenAddonRemove(do_get_file(DATA + ADDONS.signed2));
+ await test_update_broken(file1, file2, AddonManager.ERROR_CORRUPT_FILE);
+ file2.remove(true);
+});
+
+// Try to update to an unsigned add-on
+add_task(async function test_update_invalid_unsigned() {
+ let file1 = do_get_file(DATA + ADDONS.signed1);
+ let file2 = do_get_file(DATA + ADDONS.unsigned);
+ await test_update_broken(
+ file1,
+ file2,
+ AddonManager.ERROR_SIGNEDSTATE_REQUIRED
+ );
+});
+
+// Try to update to a signed add-on
+add_task(async function test_update_valid() {
+ let file1 = do_get_file(DATA + ADDONS.signed1);
+ let file2 = do_get_file(DATA + ADDONS.signed2);
+ await test_update_working(file1, file2, AddonManager.SIGNEDSTATE_SIGNED);
+});
+
+add_task(() => promiseShutdownManager());
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_signed_langpack.js b/toolkit/mozapps/extensions/test/xpcshell/test_signed_langpack.js
new file mode 100644
index 0000000000..8ad83b2ecb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_signed_langpack.js
@@ -0,0 +1,67 @@
+const PREF_SIGNATURES_GENERAL = "xpinstall.signatures.required";
+const PREF_SIGNATURES_LANGPACKS = "extensions.langpacks.signatures.required";
+
+// Disable "xpc::IsInAutomation()", since it would override the behavior
+// we're testing for.
+Services.prefs.setBoolPref(
+ "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer",
+ false
+);
+
+// Try to install the given XPI file, and assert that the install
+// succeeds. Uninstalls before returning.
+async function installShouldSucceed(file) {
+ let install = await promiseInstallFile(file);
+ Assert.equal(install.state, AddonManager.STATE_INSTALLED);
+ Assert.notEqual(install.addon, null);
+ await install.addon.uninstall();
+}
+
+// Try to install the given XPI file, assert that the install fails
+// due to lack of signing.
+async function installShouldFail(file) {
+ let install;
+ try {
+ install = await AddonManager.getInstallForFile(file);
+ } catch (err) {}
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install.error, AddonManager.ERROR_SIGNEDSTATE_REQUIRED);
+ Assert.equal(install.addon, null);
+}
+
+// Test that the preference controlling langpack signing works properly
+// (and that the general preference for addon signing does not affect
+// language packs).
+add_task(async function () {
+ AddonTestUtils.useRealCertChecks = true;
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9");
+ await promiseStartupManager();
+
+ Services.prefs.setBoolPref(PREF_SIGNATURES_GENERAL, true);
+ Services.prefs.setBoolPref(PREF_SIGNATURES_LANGPACKS, true);
+
+ // The signed langpack should always install.
+ let signedXPI = do_get_file("data/signing_checks/langpack_signed.xpi");
+ await installShouldSucceed(signedXPI);
+
+ // With signatures required, unsigned langpack should not install.
+ let unsignedXPI = do_get_file("data/signing_checks/langpack_unsigned.xpi");
+ await installShouldFail(unsignedXPI);
+
+ // Even with the general xpi signing pref off, an unsigned langapck
+ // should not install.
+ Services.prefs.setBoolPref(PREF_SIGNATURES_GENERAL, false);
+ await installShouldFail(unsignedXPI);
+
+ // But with the langpack signing pref off, unsigned langpack should
+ // install only on non-release builds.
+ Services.prefs.setBoolPref(PREF_SIGNATURES_LANGPACKS, false);
+ if (AppConstants.MOZ_REQUIRE_SIGNING) {
+ await installShouldFail(unsignedXPI);
+ } else {
+ await installShouldSucceed(unsignedXPI);
+ }
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_signed_long.js b/toolkit/mozapps/extensions/test/xpcshell/test_signed_long.js
new file mode 100644
index 0000000000..2aa76e8ff8
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_signed_long.js
@@ -0,0 +1,23 @@
+gUseRealCertChecks = true;
+
+const ID = "123456789012345678901234567890123456789012345678901@somewhere.com";
+
+// Tests that signature verification works correctly on an extension with
+// an ID that does not fit into a certificate CN field.
+add_task(async function test_long_id() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ await promiseStartupManager();
+
+ Assert.greater(ID.length, 64, "ID is > 64 characters");
+
+ await promiseInstallFile(do_get_file("data/signing_checks/long.xpi"));
+ let addon = await promiseAddonByID(ID);
+
+ Assert.notEqual(addon, null, "Addon install properly");
+ Assert.ok(
+ addon.signedState > AddonManager.SIGNEDSTATE_MISSING,
+ "Signature verification worked properly"
+ );
+
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_signed_updatepref.js b/toolkit/mozapps/extensions/test/xpcshell/test_signed_updatepref.js
new file mode 100644
index 0000000000..cfaa947019
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_signed_updatepref.js
@@ -0,0 +1,130 @@
+// Disable update security
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+gUseRealCertChecks = true;
+
+const DATA = "data/signing_checks/";
+const ID = "test@somewhere.com";
+
+let testserver = createHttpServer({ hosts: ["example.com"] });
+
+AddonTestUtils.registerJSON(testserver, "/update.json", {
+ addons: {
+ [ID]: {
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: {
+ strict_min_version: "4",
+ strict_max_version: "6",
+ },
+ },
+ },
+ },
+});
+
+Services.prefs.setCharPref(
+ "extensions.update.background.url",
+ "http://example.com/update.json"
+);
+
+function verifySignatures() {
+ return new Promise(resolve => {
+ let observer = (subject, topic, data) => {
+ Services.obs.removeObserver(observer, "xpi-signature-changed");
+ resolve(JSON.parse(data));
+ };
+ Services.obs.addObserver(observer, "xpi-signature-changed");
+
+ info("Verifying signatures");
+ const { XPIDatabase } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIDatabase.jsm"
+ );
+ XPIDatabase.verifySignatures();
+ });
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "4", "4");
+});
+
+// Updating the pref without changing the app version won't disable add-ons
+// immediately but will after a signing check
+add_task(async function () {
+ Services.prefs.setBoolPref(PREF_XPI_SIGNATURES_REQUIRED, false);
+ await promiseStartupManager();
+
+ // Install an unsigned add-on
+ await promiseInstallFile(do_get_file(DATA + "unsigned.xpi"));
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+
+ await promiseShutdownManager();
+
+ Services.prefs.setBoolPref(PREF_XPI_SIGNATURES_REQUIRED, true);
+
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+
+ // Update checks shouldn't affect the add-on
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+
+ let changes = await verifySignatures();
+
+ Assert.equal(changes.disabled.length, 1);
+ Assert.equal(changes.disabled[0], ID);
+
+ addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+
+ await addon.uninstall();
+
+ await promiseShutdownManager();
+});
+
+// Updating the pref with changing the app version will disable add-ons
+// immediately
+add_task(async function () {
+ Services.prefs.setBoolPref(PREF_XPI_SIGNATURES_REQUIRED, false);
+ await promiseStartupManager();
+
+ // Install an unsigned add-on
+ await promiseInstallFile(do_get_file(DATA + "unsigned.xpi"));
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+
+ await promiseShutdownManager();
+
+ Services.prefs.setBoolPref(PREF_XPI_SIGNATURES_REQUIRED, true);
+ gAppInfo.version = 5.0;
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+
+ await addon.uninstall();
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_signed_verify.js b/toolkit/mozapps/extensions/test/xpcshell/test_signed_verify.js
new file mode 100644
index 0000000000..85ebeef595
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_signed_verify.js
@@ -0,0 +1,109 @@
+// Enable signature checks for these tests
+gUseRealCertChecks = true;
+
+const DATA = "data/signing_checks";
+const ID = "test@somewhere.com";
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+function verifySignatures() {
+ return new Promise(resolve => {
+ let observer = (subject, topic, data) => {
+ Services.obs.removeObserver(observer, "xpi-signature-changed");
+ resolve(JSON.parse(data));
+ };
+ Services.obs.addObserver(observer, "xpi-signature-changed");
+
+ info("Verifying signatures");
+ const { XPIDatabase } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIDatabase.jsm"
+ );
+ XPIDatabase.verifySignatures();
+ });
+}
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "4", "4");
+
+add_task(async function test_no_change() {
+ await promiseStartupManager();
+
+ // Install the first add-on
+ await promiseInstallFile(do_get_file(`${DATA}/signed1.xpi`));
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.appDisabled, false);
+ Assert.equal(addon.isActive, true);
+ Assert.equal(addon.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_SIGNED);
+
+ // Swap in the files from the next add-on
+ manuallyUninstall(profileDir, ID);
+ await manuallyInstall(do_get_file(`${DATA}/signed2.xpi`), profileDir, ID);
+
+ let listener = {
+ onPropetyChanged(_addon, properties) {
+ Assert.ok(false, `Got unexpected onPropertyChanged for ${_addon.id}`);
+ },
+ };
+
+ AddonManager.addAddonListener(listener);
+
+ // Trigger the check
+ let changes = await verifySignatures();
+ Assert.equal(changes.enabled.length, 0);
+ Assert.equal(changes.disabled.length, 0);
+
+ Assert.equal(addon.appDisabled, false);
+ Assert.equal(addon.isActive, true);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_SIGNED);
+
+ await addon.uninstall();
+ AddonManager.removeAddonListener(listener);
+});
+
+add_task(async function test_diable() {
+ // Install the first add-on
+ await promiseInstallFile(do_get_file(`${DATA}/signed1.xpi`));
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_SIGNED);
+
+ // Swap in the files from the next add-on
+ manuallyUninstall(profileDir, ID);
+ await manuallyInstall(do_get_file(`${DATA}/unsigned.xpi`), profileDir, ID);
+
+ let changedProperties = [];
+ let listener = {
+ onPropertyChanged(_, properties) {
+ changedProperties.push(...properties);
+ },
+ };
+ AddonManager.addAddonListener(listener);
+
+ // Trigger the check
+ let [changes] = await Promise.all([
+ verifySignatures(),
+ promiseAddonEvent("onDisabling"),
+ ]);
+
+ Assert.equal(changes.enabled.length, 0);
+ Assert.equal(changes.disabled.length, 1);
+ Assert.equal(changes.disabled[0], ID);
+
+ Assert.deepEqual(
+ changedProperties,
+ ["signedState", "appDisabled"],
+ "Got onPropertyChanged events for signedState and appDisabled"
+ );
+
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+
+ await addon.uninstall();
+ AddonManager.removeAddonListener(listener);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_sitePermsAddonProvider.js b/toolkit/mozapps/extensions/test/xpcshell/test_sitePermsAddonProvider.js
new file mode 100644
index 0000000000..51272509da
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_sitePermsAddonProvider.js
@@ -0,0 +1,967 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {
+ addGatedPermissionTypesForXpcShellTests,
+ SITEPERMS_ADDON_PROVIDER_PREF,
+ SITEPERMS_ADDON_BLOCKEDLIST_PREF,
+ SITEPERMS_ADDON_TYPE,
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs"
+);
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const addonsBundle = new Localization(["toolkit/about/aboutAddons.ftl"], true);
+
+let ssm = Services.scriptSecurityManager;
+const PRINCIPAL_COM = ssm.createContentPrincipalFromOrigin(
+ "https://example.com"
+);
+const PRINCIPAL_ORG = ssm.createContentPrincipalFromOrigin(
+ "https://example.org"
+);
+const PRINCIPAL_GITHUB =
+ ssm.createContentPrincipalFromOrigin("https://github.io");
+const PRINCIPAL_UNSECURE =
+ ssm.createContentPrincipalFromOrigin("http://example.net");
+const PRINCIPAL_IP = ssm.createContentPrincipalFromOrigin(
+ "https://18.154.122.194"
+);
+const PRINCIPAL_PRIVATEBROWSING = ssm.createContentPrincipal(
+ Services.io.newURI("https://example.withprivatebrowsing.com"),
+ { privateBrowsingId: 1 }
+);
+const PRINCIPAL_USERCONTEXT = ssm.createContentPrincipal(
+ Services.io.newURI("https://example.withusercontext.com"),
+ { userContextId: 2 }
+);
+const PRINCIPAL_NULL = ssm.createNullPrincipal({});
+
+const URI_USED_IN_MULTIPLE_CONTEXTS = Services.io.newURI(
+ "https://multiplecontexts.com"
+);
+const PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR = ssm.createContentPrincipal(
+ URI_USED_IN_MULTIPLE_CONTEXTS,
+ {}
+);
+const PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING = ssm.createContentPrincipal(
+ URI_USED_IN_MULTIPLE_CONTEXTS,
+ { privateBrowsingId: 1 }
+);
+const PRINCIPAL_MULTIPLE_CONTEXTS_USERCONTEXT = ssm.createContentPrincipal(
+ URI_USED_IN_MULTIPLE_CONTEXTS,
+ { userContextId: 3 }
+);
+
+const BLOCKED_DOMAIN = "malicious.com";
+const BLOCKED_DOMAIN2 = "someothermalicious.com";
+const BLOCKED_PRINCIPAL = ssm.createContentPrincipalFromOrigin(
+ `https://${BLOCKED_DOMAIN}`
+);
+const BLOCKED_PRINCIPAL2 = ssm.createContentPrincipalFromOrigin(
+ `https://${BLOCKED_DOMAIN2}`
+);
+
+const GATED_SITE_PERM1 = "test/gatedSitePerm";
+const GATED_SITE_PERM2 = "test/anotherGatedSitePerm";
+addGatedPermissionTypesForXpcShellTests([GATED_SITE_PERM1, GATED_SITE_PERM2]);
+const NON_GATED_SITE_PERM = "test/nonGatedPerm";
+
+// Leave it to throw if the pref doesn't exist anymore, so that a test failure
+// will make us notice and confirm if we have enabled it by default or not.
+const PERMS_ISOLATE_USERCONTEXT_ENABLED = Services.prefs.getBoolPref(
+ "permissions.isolateBy.userContext"
+);
+
+// Observers to bypass panels and continue install.
+const expectAndHandleInstallPrompts = () => {
+ TestUtils.topicObserved("addon-install-blocked").then(([subject]) => {
+ let installInfo = subject.wrappedJSObject;
+ info("==== test got addon-install-blocked, calling `install`");
+ installInfo.install();
+ });
+ TestUtils.topicObserved("webextension-permission-prompt").then(
+ ([subject]) => {
+ info("==== test webextension-permission-prompt, calling `resolve`");
+ subject.wrappedJSObject.info.resolve();
+ }
+ );
+};
+
+add_setup(async () => {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+ AddonTestUtils.init(this);
+ await promiseStartupManager();
+});
+
+add_task(
+ {
+ pref_set: [[SITEPERMS_ADDON_PROVIDER_PREF, false]],
+ },
+ async function test_sitepermsaddon_provider_disabled() {
+ // The SitePermsAddonProvider does not register until the first content process
+ // is launched, so we simulate that by firing this notification.
+ Services.obs.notifyObservers(null, "ipc:first-content-process-created");
+
+ ok(
+ !AddonManager.hasProvider("SitePermsAddonProvider"),
+ "Expect no SitePermsAddonProvider to be registered"
+ );
+ }
+);
+
+add_task(
+ {
+ pref_set: [
+ [SITEPERMS_ADDON_PROVIDER_PREF, true],
+ [
+ SITEPERMS_ADDON_BLOCKEDLIST_PREF,
+ `${BLOCKED_DOMAIN},${BLOCKED_DOMAIN2}`,
+ ],
+ ],
+ },
+ async function test_sitepermsaddon_provider_enabled() {
+ // The SitePermsAddonProvider does not register until the first content process
+ // is launched, so we simulate that by firing this notification.
+ Services.obs.notifyObservers(null, "ipc:first-content-process-created");
+
+ ok(
+ AddonManager.hasProvider("SitePermsAddonProvider"),
+ "Expect SitePermsAddonProvider to be registered"
+ );
+
+ let addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 0, "There's no addons");
+
+ info("Add a gated permission");
+ PermissionTestUtils.add(
+ PRINCIPAL_COM,
+ GATED_SITE_PERM1,
+ Services.perms.ALLOW_ACTION
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 1, "A siteperm addon is now available");
+ const comAddon = await promiseAddonByID(addons[0].id);
+ Assert.equal(
+ addons[0],
+ comAddon,
+ "getAddonByID returns the expected addon"
+ );
+
+ Assert.deepEqual(
+ comAddon.sitePermissions,
+ [GATED_SITE_PERM1],
+ "addon has expected sitePermissions"
+ );
+ Assert.equal(
+ comAddon.type,
+ SITEPERMS_ADDON_TYPE,
+ "addon has expected type"
+ );
+
+ const localizedExtensionName = await addonsBundle.formatValue(
+ "addon-sitepermission-host",
+ {
+ host: PRINCIPAL_COM.host,
+ }
+ );
+ Assert.ok(!!localizedExtensionName, "retrieved addonName is not falsy");
+ Assert.equal(
+ comAddon.name,
+ localizedExtensionName,
+ "addon has expected name"
+ );
+
+ info("Add another gated permission");
+ PermissionTestUtils.add(
+ PRINCIPAL_COM,
+ GATED_SITE_PERM2,
+ Services.perms.ALLOW_ACTION
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 1,
+ "There is no new siteperm addon after adding a permission to the same principal..."
+ );
+ Assert.deepEqual(
+ comAddon.sitePermissions,
+ [GATED_SITE_PERM1, GATED_SITE_PERM2],
+ "...but the new permission is reported by addon.sitePermissions"
+ );
+
+ info("Add a non-gated permission");
+ PermissionTestUtils.add(
+ PRINCIPAL_COM,
+ NON_GATED_SITE_PERM,
+ Services.perms.ALLOW_ACTION
+ );
+ Assert.equal(
+ addons.length,
+ 1,
+ "There is no new siteperm addon after adding a non gated permission to the same principal..."
+ );
+ Assert.deepEqual(
+ comAddon.sitePermissions,
+ [GATED_SITE_PERM1, GATED_SITE_PERM2],
+ "...and the new permission is not reported by addon.sitePermissions"
+ );
+
+ info("Adding a gated permission to another principal");
+ PermissionTestUtils.add(
+ PRINCIPAL_ORG,
+ GATED_SITE_PERM1,
+ Services.perms.ALLOW_ACTION
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 2, "A new siteperm addon is now available");
+ const orgAddon = await promiseAddonByID(addons[1].id);
+ Assert.equal(
+ addons[1],
+ orgAddon,
+ "getAddonByID returns the expected addon"
+ );
+
+ Assert.deepEqual(
+ orgAddon.sitePermissions,
+ [GATED_SITE_PERM1],
+ "new addon only has a single sitePermission"
+ );
+
+ info(
+ "Passing null or undefined to getAddonsByTypes returns all the addons"
+ );
+ addons = await promiseAddonsByTypes(null);
+ // We can't do an exact check on all the returned addons as we get other type
+ // of addons from other providers.
+ Assert.deepEqual(
+ addons.filter(a => a.type == SITEPERMS_ADDON_TYPE).map(a => a.id),
+ [comAddon.id, orgAddon.id],
+ "Got site perms addons when passing null"
+ );
+
+ addons = await promiseAddonsByTypes();
+ Assert.deepEqual(
+ addons.filter(a => a.type == SITEPERMS_ADDON_TYPE).map(a => a.id),
+ [comAddon.id, orgAddon.id],
+ "Got site perms addons when passing undefined"
+ );
+
+ info("Remove a gated permission");
+ PermissionTestUtils.remove(PRINCIPAL_COM, GATED_SITE_PERM2);
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 2,
+ "Removing a permission did not removed the addon has it has another permission"
+ );
+ Assert.deepEqual(
+ comAddon.sitePermissions,
+ [GATED_SITE_PERM1],
+ "addon has expected sitePermissions"
+ );
+
+ info("Remove last gated permission on PRINCIPAL_COM");
+ const promisePrincipalComUninstalling = AddonTestUtils.promiseAddonEvent(
+ "onUninstalling",
+ addon => {
+ return addon.id === comAddon.id;
+ }
+ );
+ const promisePrincipalComUninstalled = AddonTestUtils.promiseAddonEvent(
+ "onUninstalled",
+ addon => {
+ return addon.id === comAddon.id;
+ }
+ );
+ PermissionTestUtils.remove(PRINCIPAL_COM, GATED_SITE_PERM1);
+ info("Wait for onUninstalling addon listener call");
+ await promisePrincipalComUninstalling;
+ info("Wait for onUninstalled addon listener call");
+ await promisePrincipalComUninstalled;
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 1,
+ "Removing the last gated permission removed the addon"
+ );
+ Assert.equal(addons[0], orgAddon);
+
+ info("Uninstall org addon");
+ orgAddon.uninstall();
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 0, "org addon is removed");
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(PRINCIPAL_ORG, GATED_SITE_PERM1),
+ false,
+ "Permission was removed when the addon was uninstalled"
+ );
+
+ info("Add gated permissions");
+ PermissionTestUtils.add(
+ PRINCIPAL_COM,
+ GATED_SITE_PERM1,
+ Services.perms.ALLOW_ACTION
+ );
+ PermissionTestUtils.add(
+ PRINCIPAL_ORG,
+ GATED_SITE_PERM1,
+ Services.perms.ALLOW_ACTION
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 2, "2 addons are now available");
+
+ info("Clear permissions");
+ const onAddon1Uninstall = AddonTestUtils.promiseAddonEvent(
+ "onUninstalled",
+ addon => addon.id === addons[0].id
+ );
+ const onAddon2Uninstall = AddonTestUtils.promiseAddonEvent(
+ "onUninstalled",
+ addon => addon.id === addons[1].id
+ );
+ Services.perms.removeAll();
+
+ await Promise.all([onAddon1Uninstall, onAddon2Uninstall]);
+ ok("Addons were properly uninstalled…");
+ Assert.equal(
+ (await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE])).length,
+ 0,
+ "… and getAddonsByTypes does not return them anymore"
+ );
+
+ info("Adding a permission to a public etld");
+ PermissionTestUtils.add(
+ PRINCIPAL_GITHUB,
+ GATED_SITE_PERM1,
+ Services.perms.ALLOW_ACTION
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 0,
+ "Adding a gated permission to a public etld shouldn't add a new addon"
+ );
+ // Cleanup
+ PermissionTestUtils.remove(PRINCIPAL_GITHUB, GATED_SITE_PERM1);
+
+ info("Adding a permission to a non secure principal");
+ PermissionTestUtils.add(
+ PRINCIPAL_UNSECURE,
+ GATED_SITE_PERM1,
+ Services.perms.ALLOW_ACTION
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 0,
+ "Adding a gated permission to an unsecure principal shouldn't add a new addon"
+ );
+
+ info("Adding a permission to a blocked principal");
+ PermissionTestUtils.add(
+ BLOCKED_PRINCIPAL,
+ GATED_SITE_PERM1,
+ Services.perms.ALLOW_ACTION
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 0,
+ "Adding a gated permission to a blocked principal shouldn't add a new addon"
+ );
+ // Cleanup
+ PermissionTestUtils.remove(BLOCKED_PRINCIPAL, GATED_SITE_PERM1);
+
+ info("Call installSitePermsAddonFromWebpage without proper principal");
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ // principal
+ null,
+ GATED_SITE_PERM1
+ ),
+ /aInstallingPrincipal must be a nsIPrincipal/,
+ "installSitePermsAddonFromWebpage rejected when called without a principal"
+ );
+
+ info(
+ "Call installSitePermsAddonFromWebpage with non-null, non-element browser"
+ );
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ "browser",
+ PRINCIPAL_COM,
+ GATED_SITE_PERM1
+ ),
+ /aBrowser must be an Element, or null/,
+ "installSitePermsAddonFromWebpage rejected when called with a non-null, non-element browser"
+ );
+
+ info("Call installSitePermsAddonFromWebpage with unsecure principal");
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_UNSECURE,
+ GATED_SITE_PERM1
+ ),
+ /SitePermsAddons can only be installed from secure origins/,
+ "installSitePermsAddonFromWebpage rejected when called with unsecure principal"
+ );
+
+ info(
+ "Call installSitePermsAddonFromWebpage for public principal and gated permission"
+ );
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_GITHUB,
+ GATED_SITE_PERM1
+ ),
+ /SitePermsAddon can\'t be installed from public eTLDs/,
+ "installSitePermsAddonFromWebpage rejected when called with public principal"
+ );
+
+ info(
+ "Call installSitePermsAddonFromWebpage for a NullPrincipal as installingPrincipal"
+ );
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_NULL,
+ GATED_SITE_PERM1
+ ),
+ /SitePermsAddons can\'t be installed from sandboxed subframes/,
+ "installSitePermsAddonFromWebpage rejected when called with a NullPrincipal as installing principal"
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 0, "there was no added addon...");
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_GITHUB,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "...and no new permission either"
+ );
+
+ info(
+ "Call installSitePermsAddonFromWebpage for plain-ip principal and gated permission"
+ );
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_IP,
+ GATED_SITE_PERM1
+ ),
+ /SitePermsAddons install disallowed when the host is an IP address/,
+ "installSitePermsAddonFromWebpage rejected when called with plain-ip principal"
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 0, "there was no added addon...");
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(PRINCIPAL_IP, GATED_SITE_PERM1),
+ false,
+ "...and no new permission either"
+ );
+
+ info(
+ "Call installSitePermsAddonFromWebpage for authorized principal and non-gated permission"
+ );
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_COM,
+ NON_GATED_SITE_PERM
+ ),
+ new RegExp(`"${NON_GATED_SITE_PERM}" is not a gated permission`),
+ "installSitePermsAddonFromWebpage rejected for non-gated permission"
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 0,
+ "installSitePermsAddonFromWebpage with non-gated permission should not add the addon"
+ );
+
+ info("Call installSitePermsAddonFromWebpage blocked principals");
+ const blockedDomainsPrincipals = {
+ [BLOCKED_DOMAIN]: BLOCKED_PRINCIPAL,
+ [BLOCKED_DOMAIN2]: BLOCKED_PRINCIPAL2,
+ };
+ for (const blockedDomain of [BLOCKED_DOMAIN, BLOCKED_DOMAIN2]) {
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ blockedDomainsPrincipals[blockedDomain],
+ GATED_SITE_PERM1
+ ),
+ /SitePermsAddons can\'t be installed/,
+ `installSitePermsAddonFromWebpage rejected when called with blocked domain principal: ${blockedDomainsPrincipals[blockedDomain].URI.spec}`
+ );
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ ssm.createContentPrincipalFromOrigin(`https://sub.${blockedDomain}`),
+ GATED_SITE_PERM1
+ ),
+ /SitePermsAddons can\'t be installed/,
+ `installSitePermsAddonFromWebpage rejected when called with blocked subdomain principal: https://sub.${blockedDomain}`
+ );
+ }
+
+ info(
+ "Call installSitePermsAddonFromWebpage for authorized principal and gated permission"
+ );
+ expectAndHandleInstallPrompts();
+ const onAddonInstalled = AddonTestUtils.promiseInstallEvent(
+ "onInstallEnded"
+ ).then(installs => installs?.[0]?.addon);
+
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_COM,
+ GATED_SITE_PERM1
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 1,
+ "installSitePermsAddonFromWebpage should add the addon..."
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(PRINCIPAL_COM, GATED_SITE_PERM1),
+ true,
+ "...and set the permission"
+ );
+
+ // The addon we get here is a SitePermsAddonInstalling instance, and we want to assert
+ // that its permissions are correct as it may impact addon uninstall later on
+ Assert.deepEqual(
+ (await onAddonInstalled).sitePermissions,
+ [GATED_SITE_PERM1],
+ "Addon has expected sitePermissions"
+ );
+
+ info(
+ "Call installSitePermsAddonFromWebpage for private browsing principal and gated permission"
+ );
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "...and set the permission"
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 2,
+ "installSitePermsAddonFromWebpage should add the addon..."
+ );
+ const [addonWithPrivateBrowsing] = addons.filter(
+ addon => addon.siteOrigin === PRINCIPAL_PRIVATEBROWSING.siteOriginNoSuffix
+ );
+ Assert.equal(
+ addonWithPrivateBrowsing?.siteOrigin,
+ PRINCIPAL_PRIVATEBROWSING.siteOriginNoSuffix,
+ "Got an addon for the expected siteOriginNoSuffix value"
+ );
+ await addonWithPrivateBrowsing.uninstall();
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "Uninstalling the addon should clear the permission for the private browsing principal"
+ );
+
+ info(
+ "Call installSitePermsAddonFromWebpage for user context isolated principal and gated permission"
+ );
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_USERCONTEXT,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_USERCONTEXT,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "...and set the permission"
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 2,
+ "installSitePermsAddonFromWebpage should add the addon..."
+ );
+ const [addonWithUserContextId] = addons.filter(
+ addon => addon.siteOrigin === PRINCIPAL_USERCONTEXT.siteOriginNoSuffix
+ );
+ Assert.equal(
+ addonWithUserContextId?.siteOrigin,
+ PRINCIPAL_USERCONTEXT.siteOriginNoSuffix,
+ "Got an addon for the expected siteOriginNoSuffix value"
+ );
+ await addonWithUserContextId.uninstall();
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_USERCONTEXT,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "Uninstalling the addon should clear the permission for the user context isolated principal"
+ );
+
+ info(
+ "Check calling installSitePermsAddonFromWebpage for same gated permission and same origin on different contexts"
+ );
+ info("First call installSitePermsAddonFromWebpage on regular context");
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "...and set the permission"
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_USERCONTEXT,
+ GATED_SITE_PERM1
+ ),
+ // We expect the permission to not be set for user context if
+ // perms.isolateBy.userContext is set to true.
+ PERMS_ISOLATE_USERCONTEXT_ENABLED
+ ? Services.perms.UNKNOWN_ACTION
+ : Services.perms.ALLOW_ACTION,
+ `...and ${
+ PERMS_ISOLATE_USERCONTEXT_ENABLED ? "not allowed" : "allowed"
+ } on the context user specific principal`
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "...but not on the private browsing principal"
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 2,
+ "installSitePermsAddonFromWebpage should add the addon..."
+ );
+ const [addonMultipleContexts] = addons.filter(
+ addon =>
+ addon.siteOrigin ===
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR.siteOriginNoSuffix
+ );
+ Assert.equal(
+ addonMultipleContexts?.siteOrigin,
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR.siteOriginNoSuffix,
+ "Got an addon for the expected siteOriginNoSuffix value"
+ );
+ info("Then call installSitePermsAddonFromWebpage on private context");
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "Permission is set for the private context"
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "...and still set on the regular principal"
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_USERCONTEXT,
+ GATED_SITE_PERM1
+ ),
+ // We expect the permission to not be set for user context if
+ // perms.isolateBy.userContext is set to true.
+ PERMS_ISOLATE_USERCONTEXT_ENABLED
+ ? Services.perms.UNKNOWN_ACTION
+ : Services.perms.ALLOW_ACTION,
+ `...and ${
+ PERMS_ISOLATE_USERCONTEXT_ENABLED ? "not allowed" : "allowed"
+ } on the context user specific principal`
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 2,
+ "installSitePermsAddonFromWebpage did not add a new addon"
+ );
+ info("Then call installSitePermsAddonFromWebpage on specific user context");
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_MULTIPLE_CONTEXTS_USERCONTEXT,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_USERCONTEXT,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "Permission is set for the user context"
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "...and still set on the regular principal"
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "...and on the private principal"
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 2,
+ "installSitePermsAddonFromWebpage did not add a new addon"
+ );
+
+ info(
+ "Uninstalling the addon should remove the permission on the different contexts"
+ );
+ await addonMultipleContexts.uninstall();
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "Uninstalling the addon should clear the permission for regular principal..."
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "... as well as the private browsing one..."
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_USERCONTEXT,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "... and the user context one"
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 1, "addon was properly uninstalled");
+
+ info("Install the addon for the multiple context origin again");
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "...and set the permission"
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 2,
+ "installSitePermsAddonFromWebpage should add the addon..."
+ );
+
+ info("Then call installSitePermsAddonFromWebpage on private context");
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "Permission is set for the private context"
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 2,
+ "installSitePermsAddonFromWebpage did not add a new addon"
+ );
+
+ info("Remove the permission for the private context");
+ PermissionTestUtils.remove(
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "Permission is removed for the private context..."
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 2, "... but it didn't uninstall the addon");
+
+ info("Remove the permission for the regular context");
+ PermissionTestUtils.remove(
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "Permission is removed for the regular context..."
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 1, "...and addon is uninstalled");
+
+ await addons[0]?.uninstall();
+ }
+);
+
+add_task(
+ {
+ pref_set: [[SITEPERMS_ADDON_PROVIDER_PREF, true]],
+ },
+ async function test_salted_hash_addon_id() {
+ // Make sure the test will also be able to run if it is the only one executed.
+ Services.obs.notifyObservers(null, "ipc:first-content-process-created");
+ ok(
+ AddonManager.hasProvider("SitePermsAddonProvider"),
+ "Expect SitePermsAddonProvider to be registered"
+ );
+ // Make sure no sitepermission addon is already installed.
+ let addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 0, "There's no addons");
+
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_COM,
+ GATED_SITE_PERM1
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 1,
+ "installSitePermsAddonFromWebpage should add the addon..."
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(PRINCIPAL_COM, GATED_SITE_PERM1),
+ true,
+ "...and set the permission"
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 1, "There is an addon installed");
+
+ const firstSaltedAddonId = addons[0].id;
+ ok(firstSaltedAddonId, "Got the first addon id");
+
+ info("Verify addon id after mocking new browsing session");
+
+ const { generateSalt } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/SitePermsAddonProvider.sys.mjs"
+ );
+ generateSalt();
+
+ await promiseRestartManager();
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 1, "There is an addon installed");
+
+ const secondSaltedAddonId = addons[0].id;
+ ok(
+ secondSaltedAddonId,
+ "Got the second addon id after mocking new browsing session"
+ );
+
+ Assert.notEqual(
+ firstSaltedAddonId,
+ secondSaltedAddonId,
+ "The two addon ids are different"
+ );
+
+ // Confirm that new installs from the same siteOrigin will still
+ // belong to the existing addon entry while the salt isn't expected
+ // to have changed.
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_COM,
+ GATED_SITE_PERM1
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 1, "There is still a single addon installed");
+
+ await addons[0]?.uninstall();
+ }
+);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_startup.js b/toolkit/mozapps/extensions/test/xpcshell/test_startup.js
new file mode 100644
index 0000000000..ba9e04c7bf
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_startup.js
@@ -0,0 +1,648 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies startup detection of added/removed/changed items and install
+// location priorities
+
+Services.prefs.setIntPref("extensions.autoDisableScopes", 0);
+
+const ID1 = getID(1);
+const ID2 = getID(2);
+const ID3 = getID(3);
+const ID4 = getID(4);
+
+function createWebExtensionXPI(id, version) {
+ return createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+}
+
+// Bug 1554703: Verify that the extensions.lastAppBuildId pref is updated properly
+// to avoid a full scan on second startup in XPIStates.scanForChanges.
+add_task(async function test_scan_app_build_id_updated() {
+ const PREF_EM_LAST_APP_BUILD_ID = "extensions.lastAppBuildId";
+ Assert.equal(
+ Services.prefs.getCharPref(PREF_EM_LAST_APP_BUILD_ID, ""),
+ "",
+ "fresh version with no saved build ID"
+ );
+ Assert.ok(Services.appinfo.appBuildID, "build ID is set before a startup");
+
+ await promiseStartupManager();
+ Assert.equal(
+ Services.prefs.getCharPref(PREF_EM_LAST_APP_BUILD_ID, ""),
+ Services.appinfo.appBuildID,
+ "build ID is correct after a startup"
+ );
+
+ await promiseShutdownManager();
+});
+
+// Try to install all the items into the profile
+add_task(async function test_scan_profile() {
+ await promiseStartupManager();
+
+ let ids = [];
+ for (let n of [1, 2, 3]) {
+ let id = getID(n);
+ ids.push(id);
+ await createWebExtension(id, initialVersion(n), profileDir);
+ }
+
+ await promiseRestartManager();
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 3, "addons installed");
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, ids);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ info("Checking for " + gAddonStartup.path);
+ Assert.ok(gAddonStartup.exists());
+
+ for (let n of [1, 2, 3]) {
+ let id = getID(n);
+ let addon = await promiseAddonByID(id);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.id, id);
+ Assert.notEqual(addon.syncGUID, null);
+ Assert.ok(addon.syncGUID.length >= 9);
+ Assert.equal(addon.version, initialVersion(n));
+ Assert.ok(isExtensionInBootstrappedList(profileDir, id));
+ Assert.ok(hasFlag(addon.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(hasFlag(addon.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(id, initialVersion(n));
+ Assert.equal(addon.scope, AddonManager.SCOPE_PROFILE);
+ Assert.equal(addon.sourceURI, null);
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.userDisabled);
+ Assert.ok(addon.seen);
+ }
+
+ let extensionAddons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(extensionAddons.length, 3);
+
+ await promiseShutdownManager();
+});
+
+// Test that modified items are detected and items in other install locations
+// are ignored
+add_task(async function test_modify() {
+ await createWebExtension(ID1, "1.1", userDir);
+ await createWebExtension(ID2, "2.1", profileDir);
+ await createWebExtension(ID2, "2.2", globalDir);
+ await createWebExtension(ID2, "2.3", userDir);
+
+ await IOUtils.remove(PathUtils.join(profileDir.path, `${ID3}.xpi`));
+
+ await promiseStartupManager();
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 2, "addons installed");
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, [ID2]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, [ID3]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ Assert.ok(gAddonStartup.exists());
+
+ let [a1, a2, a3] = await AddonManager.getAddonsByIDs([ID1, ID2, ID3]);
+
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.id, ID1);
+ Assert.equal(a1.version, "1.0");
+ Assert.ok(isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID1, "1.0");
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+ Assert.ok(a1.foreignInstall);
+
+ // The version in the profile should take precedence.
+ const VERSION2 = "2.1";
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.id, ID2);
+ Assert.equal(a2.version, VERSION2);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(hasFlag(a2.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID2, VERSION2);
+ Assert.equal(a2.scope, AddonManager.SCOPE_PROFILE);
+ Assert.ok(a2.foreignInstall);
+
+ Assert.equal(a3, null);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID3));
+ do_check_not_in_crash_annotation(ID3, "3.0");
+
+ await promiseShutdownManager();
+});
+
+// Check that removing items from the profile reveals their hidden versions.
+add_task(async function test_reveal() {
+ await IOUtils.remove(PathUtils.join(profileDir.path, `${ID1}.xpi`));
+ await IOUtils.remove(PathUtils.join(profileDir.path, `${ID2}.xpi`));
+
+ // XPI with wrong name (basename doesn't match the id)
+ let xpi = await createWebExtensionXPI(ID3, "3.0");
+ xpi.copyTo(profileDir, `${ID4}.xpi`);
+
+ await promiseStartupManager();
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 2, "addons installed");
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, [ID1, ID2]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2, a3, a4] = await AddonManager.getAddonsByIDs([
+ ID1,
+ ID2,
+ ID3,
+ ID4,
+ ]);
+
+ // Copy of addon1 in the per-user directory is now revealed.
+ const VERSION1 = "1.1";
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.id, ID1);
+ Assert.equal(a1.version, VERSION1);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID1, VERSION1);
+ Assert.equal(a1.scope, AddonManager.SCOPE_USER);
+
+ // Likewise with addon2
+ const VERSION2 = "2.3";
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.id, ID2);
+ Assert.equal(a2.version, VERSION2);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID2, VERSION2);
+ Assert.equal(a2.scope, AddonManager.SCOPE_USER);
+
+ Assert.equal(a3, null);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID3));
+
+ Assert.equal(a4, null);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID4));
+
+ let addon4Exists = await IOUtils.exists(
+ PathUtils.join(profileDir.path, `${ID4}.xpi`)
+ );
+ Assert.ok(!addon4Exists, "Misnamed xpi should be removed from profile");
+
+ await promiseShutdownManager();
+});
+
+// Test that disabling an install location works
+add_task(async function test_disable_location() {
+ Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ AddonManager.SCOPE_SYSTEM
+ );
+
+ await promiseStartupManager();
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 1, "addons installed");
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, [ID2]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, [ID1]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+ Assert.equal(a1, null);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID1));
+
+ // System-wide copy of addon2 is now revealed
+ const VERSION2 = "2.2";
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.id, ID2);
+ Assert.equal(a2.version, VERSION2);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID2, VERSION2);
+ Assert.equal(a2.scope, AddonManager.SCOPE_SYSTEM);
+
+ await promiseShutdownManager();
+});
+
+// Switching disabled locations works
+add_task(async function test_disable_location2() {
+ Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ AddonManager.SCOPE_USER
+ );
+
+ await promiseStartupManager();
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, [ID1]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, [ID2]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+
+ const VERSION1 = "1.1";
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.id, ID1);
+ Assert.equal(a1.version, VERSION1);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID1, VERSION1);
+ Assert.equal(a1.scope, AddonManager.SCOPE_USER);
+
+ const VERSION2 = "2.3";
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.id, ID2);
+ Assert.equal(a2.version, VERSION2);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID2, VERSION2);
+ Assert.equal(a2.scope, AddonManager.SCOPE_USER);
+
+ await promiseShutdownManager();
+});
+
+// Resetting the pref makes everything visible again
+add_task(async function test_enable_location() {
+ Services.prefs.clearUserPref("extensions.enabledScopes");
+
+ await promiseStartupManager();
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+
+ const VERSION1 = "1.1";
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.id, ID1);
+ Assert.equal(a1.version, VERSION1);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID1, VERSION1);
+ Assert.equal(a1.scope, AddonManager.SCOPE_USER);
+
+ const VERSION2 = "2.3";
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.id, ID2);
+ Assert.equal(a2.version, VERSION2);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID2, VERSION2);
+ Assert.equal(a2.scope, AddonManager.SCOPE_USER);
+
+ await promiseShutdownManager();
+});
+
+// Check that items in the profile hide the others again.
+add_task(async function test_profile_hiding() {
+ const VERSION1 = "1.2";
+ await createWebExtension(ID1, VERSION1, profileDir);
+
+ await IOUtils.remove(PathUtils.join(userDir.path, `${ID2}.xpi`));
+
+ await promiseStartupManager();
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, [ID1, ID2]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2, a3] = await AddonManager.getAddonsByIDs([ID1, ID2, ID3]);
+
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.id, ID1);
+ Assert.equal(a1.version, VERSION1);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID1, VERSION1);
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+
+ const VERSION2 = "2.2";
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.id, ID2);
+ Assert.equal(a2.version, VERSION2);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID2, VERSION2);
+ Assert.equal(a2.scope, AddonManager.SCOPE_SYSTEM);
+
+ Assert.equal(a3, null);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID3));
+
+ await promiseShutdownManager();
+});
+
+// Disabling all locations still leaves the profile working
+add_task(async function test_disable3() {
+ Services.prefs.setIntPref("extensions.enabledScopes", 0);
+
+ await promiseStartupManager();
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, [
+ "2@tests.mozilla.org",
+ ]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+
+ const VERSION1 = "1.2";
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.id, ID1);
+ Assert.equal(a1.version, VERSION1);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID1, VERSION1);
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+
+ Assert.equal(a2, null);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID2));
+
+ await promiseShutdownManager();
+});
+
+// More hiding and revealing
+add_task(async function test_reval() {
+ Services.prefs.clearUserPref("extensions.enabledScopes");
+
+ await IOUtils.remove(PathUtils.join(userDir.path, `${ID1}.xpi`));
+ await IOUtils.remove(PathUtils.join(globalDir.path, `${ID2}.xpi`));
+
+ const VERSION2 = "2.4";
+ await createWebExtension(ID2, VERSION2, profileDir);
+
+ await promiseStartupManager();
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, [
+ "2@tests.mozilla.org",
+ ]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2, a3] = await AddonManager.getAddonsByIDs([ID1, ID2, ID3]);
+
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.id, ID1);
+ Assert.equal(a1.version, "1.2");
+ Assert.ok(isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_UPGRADE));
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.id, ID2);
+ Assert.equal(a2.version, VERSION2);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(hasFlag(a2.permissions, AddonManager.PERM_CAN_UPGRADE));
+ Assert.equal(a2.scope, AddonManager.SCOPE_PROFILE);
+
+ Assert.equal(a3, null);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID3));
+
+ await promiseShutdownManager();
+});
+
+// Checks that a removal from one location and an addition in another location
+// for the same item is handled
+add_task(async function test_move() {
+ await IOUtils.remove(PathUtils.join(profileDir.path, `${ID1}.xpi`));
+ const VERSION1 = "1.3";
+ await createWebExtension(ID1, VERSION1, userDir);
+
+ await promiseStartupManager();
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, [ID1]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.id, ID1);
+ Assert.equal(a1.version, VERSION1);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UPGRADE));
+ Assert.equal(a1.scope, AddonManager.SCOPE_USER);
+
+ const VERSION2 = "2.4";
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.id, ID2);
+ Assert.equal(a2.version, VERSION2);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(hasFlag(a2.permissions, AddonManager.PERM_CAN_UPGRADE));
+ Assert.equal(a2.scope, AddonManager.SCOPE_PROFILE);
+
+ await promiseShutdownManager();
+});
+
+// This should remove any remaining items
+add_task(async function test_remove() {
+ await IOUtils.remove(PathUtils.join(userDir.path, `${ID1}.xpi`));
+ await IOUtils.remove(PathUtils.join(profileDir.path, `${ID2}.xpi`));
+
+ await promiseStartupManager();
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, [ID1, ID2]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2, a3] = await AddonManager.getAddonsByIDs([ID1, ID2, ID3]);
+ Assert.equal(a1, null);
+ Assert.equal(a2, null);
+ Assert.equal(a3, null);
+
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID3));
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID4));
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID4));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID3));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID4));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID4));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID1));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID3));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID4));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID4));
+
+ await promiseShutdownManager();
+});
+
+// Test that auto-disabling for specific scopes works
+add_task(async function test_autoDisable() {
+ Services.prefs.setIntPref(
+ "extensions.autoDisableScopes",
+ AddonManager.SCOPE_USER
+ );
+
+ async function writeAll() {
+ return Promise.all([
+ createWebExtension(ID1, "1.0", profileDir),
+ createWebExtension(ID2, "2.0", userDir),
+ createWebExtension(ID3, "3.0", globalDir),
+ ]);
+ }
+
+ async function removeAll() {
+ return Promise.all([
+ IOUtils.remove(PathUtils.join(profileDir.path, `${ID1}.xpi`)),
+ IOUtils.remove(PathUtils.join(userDir.path, `${ID2}.xpi`)),
+ IOUtils.remove(PathUtils.join(globalDir.path, `${ID3}.xpi`)),
+ ]);
+ }
+
+ await writeAll();
+
+ await promiseStartupManager();
+
+ let [a1, a2, a3] = await AddonManager.getAddonsByIDs([ID1, ID2, ID3]);
+ Assert.notEqual(a1, null);
+ Assert.ok(!a1.userDisabled);
+ Assert.ok(a1.seen);
+ Assert.ok(a1.isActive);
+
+ Assert.notEqual(a2, null);
+ Assert.ok(a2.userDisabled);
+ Assert.ok(!a2.seen);
+ Assert.ok(!a2.isActive);
+
+ Assert.notEqual(a3, null);
+ Assert.ok(!a3.userDisabled);
+ Assert.ok(a3.seen);
+ Assert.ok(a3.isActive);
+
+ await promiseShutdownManager();
+
+ await removeAll();
+
+ await promiseStartupManager();
+ await promiseShutdownManager();
+
+ Services.prefs.setIntPref(
+ "extensions.autoDisableScopes",
+ AddonManager.SCOPE_SYSTEM
+ );
+
+ await writeAll();
+
+ await promiseStartupManager();
+
+ [a1, a2, a3] = await AddonManager.getAddonsByIDs([ID1, ID2, ID3]);
+ Assert.notEqual(a1, null);
+ Assert.ok(!a1.userDisabled);
+ Assert.ok(a1.seen);
+ Assert.ok(a1.isActive);
+
+ Assert.notEqual(a2, null);
+ Assert.ok(!a2.userDisabled);
+ Assert.ok(a2.seen);
+ Assert.ok(a2.isActive);
+
+ Assert.notEqual(a3, null);
+ Assert.ok(a3.userDisabled);
+ Assert.ok(!a3.seen);
+ Assert.ok(!a3.isActive);
+
+ await promiseShutdownManager();
+
+ await removeAll();
+
+ await promiseStartupManager();
+ await promiseShutdownManager();
+
+ Services.prefs.setIntPref(
+ "extensions.autoDisableScopes",
+ AddonManager.SCOPE_USER + AddonManager.SCOPE_SYSTEM
+ );
+
+ await writeAll();
+
+ await promiseStartupManager();
+
+ [a1, a2, a3] = await AddonManager.getAddonsByIDs([ID1, ID2, ID3]);
+ Assert.notEqual(a1, null);
+ Assert.ok(!a1.userDisabled);
+ Assert.ok(a1.seen);
+ Assert.ok(a1.isActive);
+
+ Assert.notEqual(a2, null);
+ Assert.ok(a2.userDisabled);
+ Assert.ok(!a2.seen);
+ Assert.ok(!a2.isActive);
+
+ Assert.notEqual(a3, null);
+ Assert.ok(a3.userDisabled);
+ Assert.ok(!a3.seen);
+ Assert.ok(!a3.isActive);
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_startup_enable.js b/toolkit/mozapps/extensions/test/xpcshell/test_startup_enable.js
new file mode 100644
index 0000000000..a3259b599f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_startup_enable.js
@@ -0,0 +1,47 @@
+createAppInfo("xpcshell@tessts.mozilla.org", "XPCShell", "1", "1");
+BootstrapMonitor.init();
+
+// Test that enabling an extension during startup generates the
+// proper reason for startup().
+add_task(async function test_startup_enable() {
+ const ID = "compat@tests.mozilla.org";
+
+ await promiseStartupManager();
+
+ await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ },
+ });
+
+ BootstrapMonitor.checkInstalled(ID);
+ BootstrapMonitor.checkStarted(ID);
+ let { reason } = BootstrapMonitor.started.get(ID);
+ equal(
+ reason,
+ BOOTSTRAP_REASONS.ADDON_INSTALL,
+ "Startup reason is ADDON_INSTALL at install"
+ );
+
+ gAppInfo.platformVersion = "2";
+ await promiseRestartManager("2");
+ BootstrapMonitor.checkInstalled(ID);
+ BootstrapMonitor.checkNotStarted(ID);
+
+ gAppInfo.platformVersion = "1";
+ await promiseRestartManager("1");
+ BootstrapMonitor.checkInstalled(ID);
+ BootstrapMonitor.checkStarted(ID);
+ ({ reason } = BootstrapMonitor.started.get(ID));
+ equal(
+ reason,
+ BOOTSTRAP_REASONS.ADDON_ENABLE,
+ "Startup reason is ADDON_ENABLE when re-enabled at startup"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_startup_isPrivileged.js b/toolkit/mozapps/extensions/test/xpcshell/test_startup_isPrivileged.js
new file mode 100644
index 0000000000..eb9cc34938
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_startup_isPrivileged.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const ADDON_ID_PRIVILEGED = "@privileged-addon-id";
+const ADDON_ID_NO_PRIV = "@addon-without-privileges";
+AddonTestUtils.usePrivilegedSignatures = id => id === ADDON_ID_PRIVILEGED;
+
+function isExtensionPrivileged(addonId) {
+ const { extension } = WebExtensionPolicy.getByID(addonId);
+ return extension.isPrivileged;
+}
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function isPrivileged_at_install() {
+ {
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ permissions: ["mozillaAddons"],
+ browser_specific_settings: { gecko: { id: ADDON_ID_PRIVILEGED } },
+ },
+ });
+ ok(addon.isPrivileged, "Add-on is privileged");
+ ok(isExtensionPrivileged(ADDON_ID_PRIVILEGED), "Extension is privileged");
+ }
+
+ {
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ permissions: ["mozillaAddons"],
+ browser_specific_settings: { gecko: { id: ADDON_ID_NO_PRIV } },
+ },
+ });
+ ok(!addon.isPrivileged, "Add-on is not privileged");
+ ok(!isExtensionPrivileged(ADDON_ID_NO_PRIV), "Extension is not privileged");
+ }
+});
+
+// When the Add-on Manager is restarted, the extension is started using data
+// from XPIState. This test verifies that `extension.isPrivileged` is correctly
+// set in that scenario.
+add_task(async function isPrivileged_at_restart() {
+ await promiseRestartManager();
+ {
+ let addon = await AddonManager.getAddonByID(ADDON_ID_PRIVILEGED);
+ ok(addon.isPrivileged, "Add-on is privileged");
+ ok(isExtensionPrivileged(ADDON_ID_PRIVILEGED), "Extension is privileged");
+ }
+ {
+ let addon = await AddonManager.getAddonByID(ADDON_ID_NO_PRIV);
+ ok(!addon.isPrivileged, "Add-on is not privileged");
+ ok(!isExtensionPrivileged(ADDON_ID_NO_PRIV), "Extension is not privileged");
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_startup_scan.js b/toolkit/mozapps/extensions/test/xpcshell/test_startup_scan.js
new file mode 100644
index 0000000000..67c75cbd17
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_startup_scan.js
@@ -0,0 +1,125 @@
+"use strict";
+
+// Turn off startup scanning.
+Services.prefs.setIntPref("extensions.startupScanScopes", 0);
+
+createAppInfo("xpcshell@tessts.mozilla.org", "XPCShell", "42", "42");
+// Prevent XPIStates.scanForChanges from seeing this as an update and forcing a
+// full scan.
+Services.prefs.setCharPref(
+ "extensions.lastAppBuildId",
+ Services.appinfo.appBuildID
+);
+
+// A small bootstrap calls monitor targeting a single extension (created to avoid introducing a workaround
+// in BootstrapMonitor to be able to test Bug 1664144 fix).
+let Monitor = {
+ extensionId: undefined,
+ collected: [],
+ init() {
+ const bootstrapCallListener = (_evtName, data) => {
+ if (data.params.id == this.extensionId) {
+ this.collected.push(data);
+ }
+ };
+ AddonTestUtils.on("bootstrap-method", bootstrapCallListener);
+ registerCleanupFunction(() => {
+ AddonTestUtils.off("bootstrap-method", bootstrapCallListener);
+ });
+ },
+ startCollecting(extensionId) {
+ this.extensionId = extensionId;
+ },
+ stopCollecting() {
+ this.extensionId = undefined;
+ },
+ getCollected() {
+ const collected = this.collected;
+ this.collected = [];
+ return collected;
+ },
+};
+
+Monitor.init();
+
+// Bug 1664144: Test that during startup scans, updating an addon
+// that has already started is restarted.
+add_task(async function test_startup_sideload_updated() {
+ const ID = "sideload@tests.mozilla.org";
+
+ await createWebExtension(ID, initialVersion("1"), profileDir);
+ await promiseStartupManager();
+
+ // Ensure the sideload is enabled and running.
+ let addon = await promiseAddonByID(ID);
+
+ Monitor.startCollecting(ID);
+ await addon.enable();
+ Monitor.stopCollecting();
+
+ let events = Monitor.getCollected();
+ ok(events.length, "bootstrap methods called");
+ equal(
+ events[0].reason,
+ BOOTSTRAP_REASONS.ADDON_ENABLE,
+ "Startup reason is ADDON_ENABLE at install"
+ );
+
+ await promiseShutdownManager();
+ // Touch the addon on disk before startup.
+ await createWebExtension(ID, initialVersion("1.1"), profileDir);
+ Monitor.startCollecting(ID);
+ await promiseStartupManager();
+ await AddonManagerPrivate.getNewSideloads();
+ Monitor.stopCollecting();
+
+ events = Monitor.getCollected().map(({ method, reason, params }) => {
+ const { version } = params;
+ return { method, reason, version };
+ });
+
+ const updatedVersion = "1.1.0";
+ const expectedUpgradeParams = {
+ reason: BOOTSTRAP_REASONS.ADDON_UPGRADE,
+ version: updatedVersion,
+ };
+
+ const expectedCalls = [
+ {
+ method: "startup",
+ reason: BOOTSTRAP_REASONS.APP_STARTUP,
+ version: "1.0",
+ },
+ // Shutdown call has version 1.1 because the file was already
+ // updated on disk and got the new version as part of the startup.
+ { method: "shutdown", ...expectedUpgradeParams },
+ { method: "update", ...expectedUpgradeParams },
+ { method: "startup", ...expectedUpgradeParams },
+ ];
+
+ for (let i = 0; i < expectedCalls.length; i++) {
+ Assert.deepEqual(
+ events[i],
+ expectedCalls[i],
+ "Got the expected sequence of bootstrap method calls"
+ );
+ }
+
+ equal(
+ events.length,
+ expectedCalls.length,
+ "Got the expected number of bootstrap method calls"
+ );
+
+ // flush addonStartup.json
+ await AddonTestUtils.loadAddonsList(true);
+ // verify startupData is correct
+ let startupData = aomStartup.readStartupData();
+ Assert.equal(
+ startupData["app-profile"].addons[ID].version,
+ updatedVersion,
+ "startup data is correct in cache"
+ );
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_strictcompatibility.js b/toolkit/mozapps/extensions/test/xpcshell/test_strictcompatibility.js
new file mode 100644
index 0000000000..98318ceef6
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_strictcompatibility.js
@@ -0,0 +1,156 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests AddonManager.strictCompatibility and it's related preference,
+// extensions.strictCompatibility, and the strictCompatibility option in
+// install.rdf
+
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /IOUtils: Shutting down and refusing additional I\/O tasks/
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /IOUtils\.profileBeforeChange getter: IOUtils: profileBeforeChange phase has already finished/
+);
+
+// The `compatbile` array defines which of the tests below the add-on
+// should be compatible in. It's pretty gross.
+const ADDONS = [
+ // Always compatible
+ {
+ manifest: {
+ id: "addon1@tests.mozilla.org",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "1",
+ },
+ ],
+ },
+ compatible: {
+ nonStrict: true,
+ strict: true,
+ },
+ },
+
+ // Incompatible in strict compatibility mode
+ {
+ manifest: {
+ id: "addon2@tests.mozilla.org",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "0.7",
+ maxVersion: "0.8",
+ },
+ ],
+ },
+ compatible: {
+ nonStrict: true,
+ strict: false,
+ },
+ },
+
+ // Opt-in to strict compatibility - always incompatible
+ {
+ manifest: {
+ id: "addon3@tests.mozilla.org",
+ strictCompatibility: true,
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "0.8",
+ maxVersion: "0.9",
+ },
+ ],
+ },
+ compatible: {
+ nonStrict: false,
+ strict: false,
+ },
+ },
+
+ // Addon from the future - would be marked as compatibile-by-default,
+ // but minVersion is higher than the app version
+ {
+ manifest: {
+ id: "addon4@tests.mozilla.org",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "3",
+ maxVersion: "5",
+ },
+ ],
+ },
+ compatible: {
+ nonStrict: false,
+ strict: false,
+ },
+ },
+
+ // Dictionary - compatible even in strict compatibility mode
+ {
+ manifest: {
+ id: "addon5@tests.mozilla.org",
+ type: "dictionary",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "0.8",
+ maxVersion: "0.9",
+ },
+ ],
+ },
+ compatible: {
+ nonStrict: true,
+ strict: true,
+ },
+ },
+];
+
+async function checkCompatStatus(strict, index) {
+ info(`Checking compat status for test ${index}\n`);
+
+ equal(AddonManager.strictCompatibility, strict);
+
+ for (let test of ADDONS) {
+ let { id } = test.manifest;
+ let addon = await promiseAddonByID(id);
+ checkAddon(id, addon, {
+ isCompatible: test.compatible[index],
+ appDisabled: !test.compatible[index],
+ });
+ }
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ for (let addon of ADDONS) {
+ let xpi = await createAddon(addon.manifest);
+ await manuallyInstall(
+ xpi,
+ AddonTestUtils.profileExtensions,
+ addon.manifest.id
+ );
+ }
+
+ await promiseStartupManager();
+});
+
+add_task(async function test_1() {
+ info("Test 1");
+ Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, false);
+ await checkCompatStatus(false, "nonStrict");
+ await promiseRestartManager();
+ await checkCompatStatus(false, "nonStrict");
+});
+
+add_task(async function test_2() {
+ info("Test 2");
+ Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, true);
+ await checkCompatStatus(true, "strict");
+ await promiseRestartManager();
+ await checkCompatStatus(true, "strict");
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_syncGUID.js b/toolkit/mozapps/extensions/test/xpcshell/test_syncGUID.js
new file mode 100644
index 0000000000..029d9cddc0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_syncGUID.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// restartManager() mucks with XPIProvider.jsm importing, so we hack around.
+this.__defineGetter__("XPIProvider", function () {
+ return ChromeUtils.import(
+ "resource://gre/modules/addons/XPIProvider.jsm"
+ ).XPIProvider;
+});
+
+const UUID_PATTERN =
+ /^\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}$/i;
+
+const ADDONS = [
+ {
+ id: "addon1@tests.mozilla.org",
+ name: "Test 1",
+ },
+ {
+ id: "addon2@tests.mozilla.org",
+ name: "Real Test 2",
+ },
+];
+
+let xpis;
+
+add_task(async function setup() {
+ Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9");
+ await promiseStartupManager();
+
+ xpis = await Promise.all(
+ ADDONS.map(info =>
+ createTempWebExtensionFile({
+ manifest: {
+ name: info.name,
+ browser_specific_settings: { gecko: { id: info.id } },
+ },
+ })
+ )
+ );
+});
+
+add_task(async function test_getter_and_setter() {
+ await promiseInstallFile(xpis[0]);
+
+ let addon = await AddonManager.getAddonByID(ADDONS[0].id);
+ Assert.notEqual(addon, null);
+ Assert.notEqual(addon.syncGUID, null);
+ Assert.ok(UUID_PATTERN.test(addon.syncGUID));
+
+ let newGUID = "foo";
+
+ addon.syncGUID = newGUID;
+ Assert.equal(newGUID, addon.syncGUID);
+
+ // Verify change made it to DB.
+ let newAddon = await AddonManager.getAddonByID(ADDONS[0].id);
+ Assert.notEqual(newAddon, null);
+ Assert.equal(newGUID, newAddon.syncGUID);
+});
+
+add_task(async function test_fetch_by_guid_unknown_guid() {
+ let addon = await XPIProvider.getAddonBySyncGUID("XXXX");
+ Assert.equal(null, addon);
+});
+
+// Ensure setting an extension to an existing syncGUID results in error.
+add_task(async function test_error_on_duplicate_syncguid_insert() {
+ await promiseInstallAllFiles(xpis);
+
+ let addons = await AddonManager.getAddonsByIDs(ADDONS.map(a => a.id));
+ let initialGUID = addons[1].syncGUID;
+
+ Assert.throws(
+ () => {
+ addons[1].syncGUID = addons[0].syncGUID;
+ },
+ /Addon sync GUID conflict/,
+ "Assigning conflicting sync guids throws"
+ );
+
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID(ADDONS[1].id);
+ Assert.equal(initialGUID, addon.syncGUID);
+});
+
+add_task(async function test_fetch_by_guid_known_guid() {
+ let addon = await AddonManager.getAddonByID(ADDONS[0].id);
+ Assert.notEqual(null, addon);
+ Assert.notEqual(null, addon.syncGUID);
+
+ let syncGUID = addon.syncGUID;
+
+ let newAddon = await XPIProvider.getAddonBySyncGUID(syncGUID);
+ Assert.notEqual(null, newAddon);
+ Assert.equal(syncGUID, newAddon.syncGUID);
+});
+
+add_task(async function test_addon_manager_get_by_sync_guid() {
+ let addon = await AddonManager.getAddonByID(ADDONS[0].id);
+ Assert.notEqual(null, addon.syncGUID);
+
+ let syncGUID = addon.syncGUID;
+
+ let newAddon = await AddonManager.getAddonBySyncGUID(syncGUID);
+ Assert.notEqual(null, newAddon);
+ Assert.equal(addon.id, newAddon.id);
+ Assert.equal(syncGUID, newAddon.syncGUID);
+
+ let missing = await AddonManager.getAddonBySyncGUID("DOES_NOT_EXIST");
+ Assert.equal(undefined, missing);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_allowed.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_allowed.js
new file mode 100644
index 0000000000..15e493d86a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_allowed.js
@@ -0,0 +1,54 @@
+// Tests that only allowed built-in system add-ons are loaded on startup.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "0");
+
+// Ensure that only allowed add-ons are loaded.
+add_task(async function test_allowed_addons() {
+ // Build the test set
+ var distroDir = FileUtils.getDir("ProfD", ["sysfeatures"], true);
+ let xpi = await getSystemAddonXPI(1, "1.0");
+ xpi.copyTo(distroDir, "system1@tests.mozilla.org.xpi");
+
+ xpi = await getSystemAddonXPI(2, "1.0");
+ xpi.copyTo(distroDir, "system2@tests.mozilla.org.xpi");
+
+ xpi = await getSystemAddonXPI(3, "1.0");
+ xpi.copyTo(distroDir, "system3@tests.mozilla.org.xpi");
+
+ registerDirectory("XREAppFeat", distroDir);
+
+ // 1 and 2 are allowed, 3 is not.
+ let validAddons = {
+ system: ["system1@tests.mozilla.org", "system2@tests.mozilla.org"],
+ };
+ await overrideBuiltIns(validAddons);
+
+ await promiseStartupManager();
+
+ let addon = await AddonManager.getAddonByID("system1@tests.mozilla.org");
+ notEqual(addon, null);
+
+ addon = await AddonManager.getAddonByID("system2@tests.mozilla.org");
+ notEqual(addon, null);
+
+ addon = await AddonManager.getAddonByID("system3@tests.mozilla.org");
+ Assert.equal(addon, null);
+ equal(addon, null);
+
+ // 3 is now allowed, 1 and 2 are not.
+ validAddons = { system: ["system3@tests.mozilla.org"] };
+ await overrideBuiltIns(validAddons);
+
+ await promiseRestartManager();
+
+ addon = await AddonManager.getAddonByID("system1@tests.mozilla.org");
+ equal(addon, null);
+
+ addon = await AddonManager.getAddonByID("system2@tests.mozilla.org");
+ equal(addon, null);
+
+ addon = await AddonManager.getAddonByID("system3@tests.mozilla.org");
+ notEqual(addon, null);
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_delay_update.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_delay_update.js
new file mode 100644
index 0000000000..2cd479ccf9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_delay_update.js
@@ -0,0 +1,485 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that delaying a system add-on update works.
+
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Message manager disconnected/
+);
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const IGNORE_ID = "system_delay_ignore@tests.mozilla.org";
+const COMPLETE_ID = "system_delay_complete@tests.mozilla.org";
+const DEFER_ID = "system_delay_defer@tests.mozilla.org";
+const DEFER2_ID = "system_delay_defer2@tests.mozilla.org";
+const DEFER_ALSO_ID = "system_delay_defer_also@tests.mozilla.org";
+const NORMAL_ID = "system1@tests.mozilla.org";
+
+const distroDir = FileUtils.getDir("ProfD", ["sysfeatures"], true);
+registerDirectory("XREAppFeat", distroDir);
+
+registerCleanupFunction(() => {
+ distroDir.remove(true);
+});
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+function promiseInstallPostponed(addonID1, addonID2) {
+ return new Promise((resolve, reject) => {
+ let seen = [];
+ let listener = {
+ onInstallFailed: () => {
+ AddonManager.removeInstallListener(listener);
+ reject("extension installation should not have failed");
+ },
+ onInstallEnded: install => {
+ AddonManager.removeInstallListener(listener);
+ reject(
+ `extension installation should not have ended for ${install.addon.id}`
+ );
+ },
+ onInstallPostponed: install => {
+ seen.push(install.addon.id);
+ if (seen.includes(addonID1) && seen.includes(addonID2)) {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ }
+ },
+ };
+
+ AddonManager.addInstallListener(listener);
+ });
+}
+
+function promiseInstallResumed(addonID1, addonID2) {
+ return new Promise((resolve, reject) => {
+ let seenPostponed = [];
+ let seenEnded = [];
+ let listener = {
+ onInstallFailed: () => {
+ AddonManager.removeInstallListener(listener);
+ reject("extension installation should not have failed");
+ },
+ onInstallEnded: install => {
+ seenEnded.push(install.addon.id);
+ if (
+ seenEnded.includes(addonID1) &&
+ seenEnded.includes(addonID2) &&
+ seenPostponed.includes(addonID1) &&
+ seenPostponed.includes(addonID2)
+ ) {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ }
+ },
+ onInstallPostponed: install => {
+ seenPostponed.push(install.addon.id);
+ },
+ };
+
+ AddonManager.addInstallListener(listener);
+ });
+}
+
+function promiseInstallDeferred(addonID1, addonID2) {
+ return new Promise((resolve, reject) => {
+ let seenEnded = [];
+ let listener = {
+ onInstallFailed: () => {
+ AddonManager.removeInstallListener(listener);
+ reject("extension installation should not have failed");
+ },
+ onInstallEnded: install => {
+ seenEnded.push(install.addon.id);
+ if (seenEnded.includes(addonID1) && seenEnded.includes(addonID2)) {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ }
+ },
+ };
+
+ AddonManager.addInstallListener(listener);
+ });
+}
+
+async function checkAddon(addonID, { version }) {
+ let addon = await promiseAddonByID(addonID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, version);
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+}
+
+// Tests below have webextension background scripts inline.
+/* globals browser */
+
+// add-on registers upgrade listener, and ignores update.
+add_task(async function test_addon_upgrade_on_restart() {
+ // discard system addon updates
+ Services.prefs.setCharPref(PREF_SYSTEM_ADDON_SET, "");
+
+ let xpi = await getSystemAddonXPI(1, "1.0");
+ xpi.copyTo(distroDir, `${NORMAL_ID}.xpi`);
+
+ // Version 1.0 of an extension that ignores updates.
+ function background() {
+ browser.runtime.onUpdateAvailable.addListener(() => {
+ browser.test.sendMessage("got-update");
+ });
+ }
+
+ xpi = await createTempWebExtensionFile({
+ background,
+
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: IGNORE_ID } },
+ },
+ });
+ xpi.copyTo(distroDir, `${IGNORE_ID}.xpi`);
+
+ // Version 2.0 of the same extension.
+ let xpi2 = await createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: IGNORE_ID } },
+ },
+ });
+
+ await overrideBuiltIns({ system: [IGNORE_ID, NORMAL_ID] });
+
+ let extension = ExtensionTestUtils.expectExtension(IGNORE_ID);
+
+ await Promise.all([promiseStartupManager(), extension.awaitStartup()]);
+
+ let updateList = [
+ {
+ id: IGNORE_ID,
+ version: "2.0",
+ path: "system_delay_ignore_2.xpi",
+ xpi: xpi2,
+ },
+ {
+ id: NORMAL_ID,
+ version: "2.0",
+ path: "system1_2.xpi",
+ xpi: await getSystemAddonXPI(1, "2.0"),
+ },
+ ];
+
+ await Promise.all([
+ promiseInstallPostponed(IGNORE_ID, NORMAL_ID),
+ installSystemAddons(buildSystemAddonUpdates(updateList)),
+ extension.awaitMessage("got-update"),
+ ]);
+
+ // addon upgrade has been delayed.
+ await checkAddon(IGNORE_ID, { version: "1.0" });
+ // other addons in the set are delayed as well.
+ await checkAddon(NORMAL_ID, { version: "1.0" });
+
+ // restarting allows upgrades to proceed
+ await Promise.all([promiseRestartManager(), extension.awaitStartup()]);
+
+ await checkAddon(IGNORE_ID, { version: "2.0" });
+ await checkAddon(NORMAL_ID, { version: "2.0" });
+
+ await promiseShutdownManager();
+});
+
+// add-on registers upgrade listener, and allows update.
+add_task(async function test_addon_upgrade_on_reload() {
+ // discard system addon updates
+ Services.prefs.setCharPref(PREF_SYSTEM_ADDON_SET, "");
+
+ let xpi = await getSystemAddonXPI(1, "1.0");
+ xpi.copyTo(distroDir, `${NORMAL_ID}.xpi`);
+
+ // Version 1.0 of an extension that listens for and immediately
+ // applies updates.
+ function background() {
+ browser.runtime.onUpdateAvailable.addListener(function listener() {
+ browser.runtime.onUpdateAvailable.removeListener(listener);
+ browser.test.sendMessage("got-update");
+ browser.runtime.reload();
+ });
+ }
+
+ xpi = await createTempWebExtensionFile({
+ background,
+
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: COMPLETE_ID } },
+ },
+ });
+ xpi.copyTo(distroDir, `${COMPLETE_ID}.xpi`);
+
+ // Version 2.0 of the same extension.
+ let xpi2 = await createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: COMPLETE_ID } },
+ },
+ });
+
+ await overrideBuiltIns({ system: [COMPLETE_ID, NORMAL_ID] });
+
+ let extension = ExtensionTestUtils.expectExtension(COMPLETE_ID);
+
+ await Promise.all([promiseStartupManager(), extension.awaitStartup()]);
+
+ let updateList = [
+ {
+ id: COMPLETE_ID,
+ version: "2.0",
+ path: "system_delay_complete_2.xpi",
+ xpi: xpi2,
+ },
+ {
+ id: NORMAL_ID,
+ version: "2.0",
+ path: "system1_2.xpi",
+ xpi: await getSystemAddonXPI(1, "2.0"),
+ },
+ ];
+
+ // initial state
+ await checkAddon(COMPLETE_ID, { version: "1.0" });
+ await checkAddon(NORMAL_ID, { version: "1.0" });
+
+ // We should see that the onUpdateListener executed, then see the
+ // update resume.
+ await Promise.all([
+ extension.awaitMessage("got-update"),
+ promiseInstallResumed(COMPLETE_ID, NORMAL_ID),
+ installSystemAddons(buildSystemAddonUpdates(updateList)),
+ ]);
+ await extension.awaitStartup();
+
+ // addon upgrade has been allowed
+ await checkAddon(COMPLETE_ID, { version: "2.0" });
+ // other upgrades in the set are allowed as well
+ await checkAddon(NORMAL_ID, { version: "2.0" });
+
+ // restarting changes nothing
+ await Promise.all([promiseRestartManager(), extension.awaitStartup()]);
+
+ await checkAddon(COMPLETE_ID, { version: "2.0" });
+ await checkAddon(NORMAL_ID, { version: "2.0" });
+
+ await promiseShutdownManager();
+});
+
+function delayBackground() {
+ browser.test.onMessage.addListener(msg => {
+ if (msg !== "reload") {
+ browser.test.fail(`Got unexpected test message: ${msg}`);
+ }
+ browser.runtime.reload();
+ });
+ browser.runtime.onUpdateAvailable.addListener(async function listener() {
+ browser.runtime.onUpdateAvailable.removeListener(listener);
+ browser.test.sendMessage("got-update");
+ });
+}
+
+// Upgrade listener initially defers then proceeds after a pause.
+add_task(async function test_addon_upgrade_after_pause() {
+ // discard system addon updates
+ Services.prefs.setCharPref(PREF_SYSTEM_ADDON_SET, "");
+
+ let xpi = await getSystemAddonXPI(1, "1.0");
+ xpi.copyTo(distroDir, `${NORMAL_ID}.xpi`);
+
+ // Version 1.0 of an extension that delays upgrades.
+ xpi = await createTempWebExtensionFile({
+ background: delayBackground,
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: DEFER_ID } },
+ },
+ });
+ xpi.copyTo(distroDir, `${DEFER_ID}.xpi`);
+
+ // Version 2.0 of the same xtension.
+ let xpi2 = await createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: DEFER_ID } },
+ },
+ });
+
+ await overrideBuiltIns({ system: [DEFER_ID, NORMAL_ID] });
+
+ let extension = ExtensionTestUtils.expectExtension(DEFER_ID);
+
+ await Promise.all([promiseStartupManager(), extension.awaitStartup()]);
+
+ let updateList = [
+ {
+ id: DEFER_ID,
+ version: "2.0",
+ path: "system_delay_defer_2.xpi",
+ xpi: xpi2,
+ },
+ {
+ id: NORMAL_ID,
+ version: "2.0",
+ path: "system1_2.xpi",
+ xpi: await getSystemAddonXPI(1, "2.0"),
+ },
+ ];
+
+ await Promise.all([
+ promiseInstallPostponed(DEFER_ID, NORMAL_ID),
+ installSystemAddons(buildSystemAddonUpdates(updateList)),
+ extension.awaitMessage("got-update"),
+ ]);
+
+ // upgrade is initially postponed
+ await checkAddon(DEFER_ID, { version: "1.0" });
+ // other addons in the set are postponed as well.
+ await checkAddon(NORMAL_ID, { version: "1.0" });
+
+ let deferred = promiseInstallDeferred(DEFER_ID, NORMAL_ID);
+
+ // Tell the extension to proceed with the update.
+ extension.setRestarting();
+ extension.sendMessage("reload");
+
+ await Promise.all([deferred, extension.awaitStartup()]);
+
+ // addon upgrade has been allowed
+ await checkAddon(DEFER_ID, { version: "2.0" });
+ // other addons in the set are allowed as well.
+ await checkAddon(NORMAL_ID, { version: "2.0" });
+
+ // restarting changes nothing
+ await promiseRestartManager();
+ await extension.awaitStartup();
+
+ await checkAddon(DEFER_ID, { version: "2.0" });
+ await checkAddon(NORMAL_ID, { version: "2.0" });
+
+ await promiseShutdownManager();
+});
+
+// Multiple add-ons register update listeners, initially defers then
+// each unblock in turn.
+add_task(async function test_multiple_addon_upgrade_postpone() {
+ // discard system addon updates.
+ Services.prefs.setCharPref(PREF_SYSTEM_ADDON_SET, "");
+
+ let updateList = [];
+
+ let xpi = await createTempWebExtensionFile({
+ background: delayBackground,
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: DEFER2_ID } },
+ },
+ });
+ xpi.copyTo(distroDir, `${DEFER2_ID}.xpi`);
+
+ xpi = await createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: DEFER2_ID } },
+ },
+ });
+ updateList.push({
+ id: DEFER2_ID,
+ version: "2.0",
+ path: "system_delay_defer_2.xpi",
+ xpi,
+ });
+
+ xpi = await createTempWebExtensionFile({
+ background: delayBackground,
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: DEFER_ALSO_ID } },
+ },
+ });
+ xpi.copyTo(distroDir, `${DEFER_ALSO_ID}.xpi`);
+
+ xpi = await createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: DEFER_ALSO_ID } },
+ },
+ });
+ updateList.push({
+ id: DEFER_ALSO_ID,
+ version: "2.0",
+ path: "system_delay_defer_also_2.xpi",
+ xpi,
+ });
+
+ await overrideBuiltIns({ system: [DEFER2_ID, DEFER_ALSO_ID] });
+
+ let extension1 = ExtensionTestUtils.expectExtension(DEFER2_ID);
+ let extension2 = ExtensionTestUtils.expectExtension(DEFER_ALSO_ID);
+
+ await Promise.all([
+ promiseStartupManager(),
+ extension1.awaitStartup(),
+ extension2.awaitStartup(),
+ ]);
+
+ await Promise.all([
+ promiseInstallPostponed(DEFER2_ID, DEFER_ALSO_ID),
+ installSystemAddons(buildSystemAddonUpdates(updateList)),
+ extension1.awaitMessage("got-update"),
+ extension2.awaitMessage("got-update"),
+ ]);
+
+ // upgrade is initially postponed
+ await checkAddon(DEFER2_ID, { version: "1.0" });
+ // other addons in the set are postponed as well.
+ await checkAddon(DEFER_ALSO_ID, { version: "1.0" });
+
+ let deferred = promiseInstallDeferred(DEFER2_ID, DEFER_ALSO_ID);
+
+ // Let one extension request that the update proceed.
+ extension1.setRestarting();
+ extension1.sendMessage("reload");
+
+ // Upgrade blockers still present.
+ await checkAddon(DEFER2_ID, { version: "1.0" });
+ await checkAddon(DEFER_ALSO_ID, { version: "1.0" });
+
+ // Let the second extension allow the update to proceed.
+ extension2.setRestarting();
+ extension2.sendMessage("reload");
+
+ await Promise.all([
+ deferred,
+ extension1.awaitStartup(),
+ extension2.awaitStartup(),
+ ]);
+
+ // addon upgrade has been allowed
+ await checkAddon(DEFER2_ID, { version: "2.0" });
+ await checkAddon(DEFER_ALSO_ID, { version: "2.0" });
+
+ // restarting changes nothing
+ await Promise.all([
+ promiseRestartManager(),
+ extension1.awaitStartup(),
+ extension2.awaitStartup(),
+ ]);
+
+ await checkAddon(DEFER2_ID, { version: "2.0" });
+ await checkAddon(DEFER_ALSO_ID, { version: "2.0" });
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_profile_location.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_profile_location.js
new file mode 100644
index 0000000000..9dbf8f7070
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_profile_location.js
@@ -0,0 +1,204 @@
+"use strict";
+
+/* globals browser */
+let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION;
+Services.prefs.setIntPref("extensions.enabledScopes", scopes);
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1"
+);
+
+AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("system");
+
+async function promiseInstallSystemProfileExtension(id, hidden) {
+ let xpi = await AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ hidden,
+ },
+ background() {
+ browser.test.sendMessage("started");
+ },
+ });
+ let wrapper = ExtensionTestUtils.expectExtension(id);
+
+ const install = await AddonManager.getInstallForURL(`file://${xpi.path}`, {
+ useSystemLocation: true, // KEY_APP_SYSTEM_PROFILE
+ });
+
+ install.install();
+
+ await wrapper.awaitStartup();
+ await wrapper.awaitMessage("started");
+ return wrapper;
+}
+
+async function promiseUninstall(id) {
+ await AddonManager.uninstallSystemProfileAddon(id);
+
+ let addon = await promiseAddonByID(id);
+ equal(addon, null, "Addon is gone after uninstall");
+}
+
+// Tests installing an extension into the app-system-profile location.
+add_task(async function test_system_profile_location() {
+ let id = "system@tests.mozilla.org";
+ await AddonTestUtils.promiseStartupManager();
+ let wrapper = await promiseInstallSystemProfileExtension(id);
+
+ let addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+ ok(addon.isPrivileged, "Addon is privileged");
+ ok(wrapper.extension.isAppProvided, "Addon is app provided");
+ ok(!addon.hidden, "Addon is not hidden");
+ equal(
+ addon.signedState,
+ AddonManager.SIGNEDSTATE_PRIVILEGED,
+ "Addon is system signed"
+ );
+
+ // After a restart, the extension should start up normally.
+ await promiseRestartManager();
+ await wrapper.awaitStartup();
+ await wrapper.awaitMessage("started");
+ ok(true, "Extension in app-system-profile location ran after restart");
+
+ addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+
+ // After a restart that causes a database rebuild, it should still work
+ await promiseRestartManager("2");
+ await wrapper.awaitStartup();
+ await wrapper.awaitMessage("started");
+ ok(true, "Extension in app-system-profile location ran after restart");
+
+ addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+
+ // After a restart that changes the schema version, it should still work
+ await promiseShutdownManager();
+ Services.prefs.setIntPref("extensions.databaseSchema", 0);
+ await promiseStartupManager();
+
+ await wrapper.awaitStartup();
+ await wrapper.awaitMessage("started");
+ ok(true, "Extension in app-system-profile location ran after restart");
+
+ addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+
+ await promiseUninstall(id);
+
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+// Tests installing a hidden extension in the app-system-profile location.
+add_task(async function test_system_profile_location_hidden() {
+ let id = "system-hidden@tests.mozilla.org";
+ await AddonTestUtils.promiseStartupManager();
+ await promiseInstallSystemProfileExtension(id, true);
+
+ let addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+ ok(addon.isPrivileged, "Addon is privileged");
+ ok(addon.hidden, "Addon is hidden");
+
+ await promiseUninstall(id);
+
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+add_task(async function test_system_profile_location_installFile() {
+ await AddonTestUtils.promiseStartupManager();
+ let id = "system-fileinstall@test";
+ let xpi = await AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ },
+ background() {
+ browser.test.sendMessage("started");
+ },
+ });
+ let wrapper = ExtensionTestUtils.expectExtension(id);
+
+ const install = await AddonManager.getInstallForFile(xpi, null, null, true);
+ install.install();
+
+ await wrapper.awaitStartup();
+ await wrapper.awaitMessage("started");
+
+ await promiseUninstall(id);
+
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+add_task(async function test_system_profile_location_overridden() {
+ await AddonTestUtils.promiseStartupManager();
+ let id = "system-fileinstall@test";
+ let xpi = await AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+
+ let install = await AddonManager.getInstallForFile(xpi, null, null, true);
+ await install.install();
+
+ let addon = await promiseAddonByID(id);
+ equal(addon.version, "1.0", "Addon is installed");
+
+ // Install a user profile version on top of the system profile location.
+ xpi = await AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+
+ install = await AddonManager.getInstallForFile(xpi);
+ await install.install();
+
+ addon = await promiseAddonByID(id);
+ equal(addon.version, "2.0", "Addon is upgraded");
+
+ // Uninstall the system profile addon.
+ await AddonManager.uninstallSystemProfileAddon(id);
+
+ addon = await promiseAddonByID(id);
+ equal(addon.version, "2.0", "Addon is still active");
+
+ await addon.uninstall();
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+add_task(async function test_system_profile_location_require_system_cert() {
+ await AddonTestUtils.promiseStartupManager();
+ let id = "fail@test";
+ let xpi = await AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+ const install = await AddonManager.getInstallForURL(`file://${xpi.path}`, {
+ useSystemLocation: true, // KEY_APP_SYSTEM_PROFILE
+ });
+
+ await install.install();
+
+ let addon = await promiseAddonByID(id);
+ ok(!addon.isPrivileged, "Addon is not privileged");
+ ok(!addon.isActive, "Addon is not active");
+ equal(addon.signedState, AddonManager.SIGNEDSTATE_SIGNED, "Addon is signed");
+
+ await addon.uninstall();
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_repository.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_repository.js
new file mode 100644
index 0000000000..24ddb257cc
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_repository.js
@@ -0,0 +1,68 @@
+// Tests that AddonRepository doesn't download results for system add-ons
+
+const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
+
+var gServer = new HttpServer();
+gServer.start(-1);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "0");
+
+// Test with a missing features directory
+add_task(async function test_app_addons() {
+ // Build the test set
+ var distroDir = FileUtils.getDir("ProfD", ["sysfeatures"], true);
+ let xpi = await getSystemAddonXPI(1, "1.0");
+ xpi.copyTo(distroDir, "system1@tests.mozilla.org.xpi");
+
+ xpi = await getSystemAddonXPI(2, "1.0");
+ xpi.copyTo(distroDir, "system2@tests.mozilla.org.xpi");
+
+ xpi = await getSystemAddonXPI(3, "1.0");
+ xpi.copyTo(distroDir, "system3@tests.mozilla.org.xpi");
+
+ registerDirectory("XREAppFeat", distroDir);
+
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+ Services.prefs.setCharPref(
+ PREF_GETADDONS_BYIDS,
+ `http://localhost:${gServer.identity.primaryPort}/get?%IDS%`
+ );
+
+ gServer.registerPathHandler("/get", (request, response) => {
+ do_throw("Unexpected request to server.");
+ });
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ ],
+ });
+
+ await promiseStartupManager();
+
+ await AddonRepository.cacheAddons([
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ ]);
+
+ let cached = await AddonRepository.getCachedAddonByID(
+ "system1@tests.mozilla.org"
+ );
+ Assert.equal(cached, null);
+
+ cached = await AddonRepository.getCachedAddonByID(
+ "system2@tests.mozilla.org"
+ );
+ Assert.equal(cached, null);
+
+ cached = await AddonRepository.getCachedAddonByID(
+ "system3@tests.mozilla.org"
+ );
+ Assert.equal(cached, null);
+
+ await promiseShutdownManager();
+ await new Promise(resolve => gServer.stop(resolve));
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_reset.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_reset.js
new file mode 100644
index 0000000000..16091167e7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_reset.js
@@ -0,0 +1,533 @@
+// Tests that we reset to the default system add-ons correctly when switching
+// application versions
+
+const updatesDir = FileUtils.getDir("ProfD", ["features"]);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+add_task(async function setup() {
+ // Build the test sets
+ let dir = FileUtils.getDir("ProfD", ["sysfeatures", "app1"], true);
+ let xpi = await getSystemAddonXPI(1, "1.0");
+ xpi.copyTo(dir, "system1@tests.mozilla.org.xpi");
+
+ xpi = await getSystemAddonXPI(2, "1.0");
+ xpi.copyTo(dir, "system2@tests.mozilla.org.xpi");
+
+ dir = FileUtils.getDir("ProfD", ["sysfeatures", "app2"], true);
+ xpi = await getSystemAddonXPI(1, "2.0");
+ xpi.copyTo(dir, "system1@tests.mozilla.org.xpi");
+
+ xpi = await getSystemAddonXPI(3, "1.0");
+ xpi.copyTo(dir, "system3@tests.mozilla.org.xpi");
+
+ dir = FileUtils.getDir("ProfD", ["sysfeatures", "app3"], true);
+ xpi = await getSystemAddonXPI(1, "1.0");
+ xpi.copyTo(dir, "system1@tests.mozilla.org.xpi");
+
+ xpi = await getSystemAddonXPI(3, "1.0");
+ xpi.copyTo(dir, "system3@tests.mozilla.org.xpi");
+});
+
+const distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "app0"], true);
+registerDirectory("XREAppFeat", distroDir);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "0");
+
+function makeUUID() {
+ let uuidGen = Services.uuid;
+ return uuidGen.generateUUID().toString();
+}
+
+async function check_installed(conditions) {
+ for (let i = 0; i < conditions.length; i++) {
+ let condition = conditions[i];
+ let id = "system" + (i + 1) + "@tests.mozilla.org";
+ let addon = await promiseAddonByID(id);
+
+ if (!("isUpgrade" in condition) || !("version" in condition)) {
+ throw Error("condition must contain isUpgrade and version");
+ }
+ let isUpgrade = conditions[i].isUpgrade;
+ let version = conditions[i].version;
+
+ let expectedDir = isUpgrade ? updatesDir : distroDir;
+
+ if (version) {
+ // Add-on should be installed
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, version);
+ Assert.ok(addon.isActive);
+ Assert.ok(!addon.foreignInstall);
+ Assert.ok(addon.hidden);
+ Assert.ok(addon.isSystem);
+ Assert.ok(!hasFlag(addon.permissions, AddonManager.PERM_CAN_UPGRADE));
+ if (isUpgrade) {
+ Assert.ok(
+ hasFlag(addon.permissions, AddonManager.PERM_API_CAN_UNINSTALL)
+ );
+ } else {
+ Assert.ok(
+ !hasFlag(addon.permissions, AddonManager.PERM_API_CAN_UNINSTALL)
+ );
+ }
+
+ // Verify the add-ons file is in the right place
+ let file = expectedDir.clone();
+ file.append(id + ".xpi");
+ Assert.ok(file.exists());
+ Assert.ok(file.isFile());
+
+ Assert.equal(getAddonFile(addon).path, file.path);
+
+ if (isUpgrade) {
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_SYSTEM);
+ }
+ } else if (isUpgrade) {
+ // Add-on should not be installed
+ Assert.equal(addon, null);
+ } else {
+ // Either add-on should not be installed or it shouldn't be active
+ Assert.ok(!addon || !addon.isActive);
+ }
+ }
+}
+
+// Test with a missing features directory
+add_task(async function test_missing_app_dir() {
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ];
+
+ await check_installed(conditions);
+
+ Assert.ok(!updatesDir.exists());
+
+ await promiseShutdownManager();
+});
+
+// Add some features in a new version
+add_task(async function test_new_version() {
+ gAppInfo.version = "1";
+ distroDir.leafName = "app1";
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ];
+
+ await check_installed(conditions);
+
+ Assert.ok(!updatesDir.exists());
+
+ await promiseShutdownManager();
+});
+
+// Another new version swaps one feature and upgrades another
+add_task(async function test_upgrade() {
+ gAppInfo.version = "2";
+ distroDir.leafName = "app2";
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "1.0" },
+ ];
+
+ await check_installed(conditions);
+
+ Assert.ok(!updatesDir.exists());
+
+ await promiseShutdownManager();
+});
+
+// Downgrade
+add_task(async function test_downgrade() {
+ gAppInfo.version = "1";
+ distroDir.leafName = "app1";
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ];
+
+ await check_installed(conditions);
+
+ Assert.ok(!updatesDir.exists());
+
+ await promiseShutdownManager();
+});
+
+// Fake a mid-cycle install
+add_task(async function test_updated() {
+ // Create a random dir to install into
+ let dirname = makeUUID();
+ FileUtils.getDir("ProfD", ["features", dirname], true);
+ updatesDir.append(dirname);
+
+ // Copy in the system add-ons
+ let file = await getSystemAddonXPI(2, "2.0");
+ file.copyTo(updatesDir, "system2@tests.mozilla.org.xpi");
+ file = await getSystemAddonXPI(3, "2.0");
+ file.copyTo(updatesDir, "system3@tests.mozilla.org.xpi");
+
+ // Inject it into the system set
+ let addonSet = {
+ schema: 1,
+ directory: updatesDir.leafName,
+ addons: {
+ "system2@tests.mozilla.org": {
+ version: "2.0",
+ },
+ "system3@tests.mozilla.org": {
+ version: "2.0",
+ },
+ },
+ };
+ Services.prefs.setCharPref(PREF_SYSTEM_ADDON_SET, JSON.stringify(addonSet));
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
+
+// Entering safe mode should disable the updated system add-ons and use the
+// default system add-ons
+add_task(async function safe_mode_disabled() {
+ gAppInfo.inSafeMode = true;
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
+
+// Leaving safe mode should re-enable the updated system add-ons
+add_task(async function normal_mode_enabled() {
+ gAppInfo.inSafeMode = false;
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
+
+// An additional add-on in the directory should be ignored
+add_task(async function test_skips_additional() {
+ // Copy in the system add-ons
+ let file = await getSystemAddonXPI(4, "1.0");
+ file.copyTo(updatesDir, "system4@tests.mozilla.org.xpi");
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
+
+// Missing add-on should revert to the default set
+add_task(async function test_revert() {
+ manuallyUninstall(updatesDir, "system2@tests.mozilla.org");
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ // With system add-on 2 gone the updated set is now invalid so it reverts to
+ // the default set which is system add-ons 1 and 2.
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
+
+// Putting it back will make the set work again
+add_task(async function test_reuse() {
+ let file = await getSystemAddonXPI(2, "2.0");
+ file.copyTo(updatesDir, "system2@tests.mozilla.org.xpi");
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
+
+// Making the pref corrupt should revert to the default set
+add_task(async function test_corrupt_pref() {
+ Services.prefs.setCharPref(PREF_SYSTEM_ADDON_SET, "foo");
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
+
+// An add-on with a bad certificate should cause us to use the default set
+add_task(async function test_bad_profile_cert() {
+ let file = await getSystemAddonXPI(1, "1.0");
+ file.copyTo(updatesDir, "system1@tests.mozilla.org.xpi");
+
+ // Inject it into the system set
+ let addonSet = {
+ schema: 1,
+ directory: updatesDir.leafName,
+ addons: {
+ "system1@tests.mozilla.org": {
+ version: "2.0",
+ },
+ "system2@tests.mozilla.org": {
+ version: "1.0",
+ },
+ "system3@tests.mozilla.org": {
+ version: "1.0",
+ },
+ },
+ };
+ Services.prefs.setCharPref(PREF_SYSTEM_ADDON_SET, JSON.stringify(addonSet));
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
+
+// Switching to app defaults that contain a bad certificate should still work
+add_task(async function test_bad_app_cert() {
+ gAppInfo.version = "3";
+ distroDir.leafName = "app3";
+
+ AddonTestUtils.usePrivilegedSignatures = id => {
+ return id === "system1@tests.mozilla.org" ? false : "system";
+ };
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ // Since we updated the app version, the system addon set should be reset as well.
+ let addonSet = Services.prefs.getCharPref(PREF_SYSTEM_ADDON_SET);
+ Assert.equal(addonSet, `{"schema":1,"addons":{}}`);
+
+ // Add-on will still be present
+ let addon = await promiseAddonByID("system1@tests.mozilla.org");
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_NOT_REQUIRED);
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "1.0" },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+
+ AddonTestUtils.usePrivilegedSignatures = id => "system";
+});
+
+// A failed upgrade should revert to the default set.
+add_task(async function test_updated_bad_update_set() {
+ // Create a random dir to install into
+ let dirname = makeUUID();
+ FileUtils.getDir("ProfD", ["features", dirname], true);
+ updatesDir.append(dirname);
+
+ // Copy in the system add-ons
+ let file = await getSystemAddonXPI(2, "2.0");
+ file.copyTo(updatesDir, "system2@tests.mozilla.org.xpi");
+ file = await getSystemAddonXPI("failed_update", "1.0");
+ file.copyTo(updatesDir, "system_failed_update@tests.mozilla.org.xpi");
+
+ // Inject it into the system set
+ let addonSet = {
+ schema: 1,
+ directory: updatesDir.leafName,
+ addons: {
+ "system2@tests.mozilla.org": {
+ version: "2.0",
+ },
+ "system_failed_update@tests.mozilla.org": {
+ version: "1.0",
+ },
+ },
+ };
+ Services.prefs.setCharPref(PREF_SYSTEM_ADDON_SET, JSON.stringify(addonSet));
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [{ isUpgrade: false, version: "1.0" }];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_blank.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_blank.js
new file mode 100644
index 0000000000..8276dc75c6
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_blank.js
@@ -0,0 +1,117 @@
+// Tests that system add-on upgrades work.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"], true);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+add_task(() => initSystemAddonDirs());
+
+/**
+ * Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ */
+const TEST_CONDITIONS = {
+ // Runs tests with no updated or default system add-ons initially installed
+ blank: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ // Runs tests with default system add-ons installed
+ withAppSet: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "prefilled";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with updated system add-ons installed
+ withProfileSet: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with both default and updated system add-ons installed
+ withBothSets: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "hidden";
+ },
+ initialState: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+};
+
+/**
+ * The tests to run. Each test must define an updateList or test. The following
+ * properties are used:
+ *
+ * updateList: The set of add-ons the server should respond with.
+ * test: A function to run to perform the update check (replaces
+ * updateList)
+ * fails: An optional property, if true the update check is expected to
+ * fail.
+ * finalState: An optional property, the expected final state of system add-ons,
+ * if missing the test condition's initialState is used.
+ */
+const TESTS = {
+ // Test that a blank response does nothing
+ blank: {
+ updateList: null,
+ },
+};
+
+add_task(async function setup() {
+ // Initialise the profile
+ await overrideBuiltIns({ system: [] });
+ await promiseStartupManager();
+ await promiseShutdownManager();
+});
+
+add_task(async function () {
+ for (let setupName of Object.keys(TEST_CONDITIONS)) {
+ for (let testName of Object.keys(TESTS)) {
+ info("Running test " + setupName + " " + testName);
+
+ let setup = TEST_CONDITIONS[setupName];
+ let test = TESTS[testName];
+
+ await execSystemAddonTest(setupName, setup, test, distroDir);
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_checkSizeHash.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_checkSizeHash.js
new file mode 100644
index 0000000000..9d5ea45ce6
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_checkSizeHash.js
@@ -0,0 +1,181 @@
+// Tests that system add-on upgrades work.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"], true);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+/**
+ * Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ */
+const TEST_CONDITIONS = {
+ // Runs tests with no updated or default system add-ons initially installed
+ blank: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ // Runs tests with default system add-ons installed
+ withAppSet: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "prefilled";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with updated system add-ons installed
+ withProfileSet: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with both default and updated system add-ons installed
+ withBothSets: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "hidden";
+ },
+ initialState: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+};
+
+/**
+ * The tests to run. Each test must define an updateList or test. The following
+ * properties are used:
+ *
+ * updateList: The set of add-ons the server should respond with.
+ * test: A function to run to perform the update check (replaces
+ * updateList)
+ * fails: An optional property, if true the update check is expected to
+ * fail.
+ * finalState: An optional property, the expected final state of system add-ons,
+ * if missing the test condition's initialState is used.
+ */
+const TESTS = {
+ // Correct sizes and hashes should work
+ checkSizeHash: {
+ // updateList is populated in setup()
+ updateList: [],
+ finalState: {
+ blank: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "1.0" },
+ ],
+ withAppSet: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "1.0" },
+ ],
+ withProfileSet: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "1.0" },
+ ],
+ withBothSets: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "1.0" },
+ ],
+ },
+ },
+};
+
+add_task(async function setup() {
+ await initSystemAddonDirs();
+
+ // Initialise the profile
+ await overrideBuiltIns({ system: [] });
+ await promiseStartupManager();
+ await promiseShutdownManager();
+
+ let list = TESTS.checkSizeHash.updateList;
+ let xpi = await getSystemAddonXPI(2, "3.0");
+ list.push({
+ id: "system2@tests.mozilla.org",
+ version: "3.0",
+ path: "system2_3.xpi",
+ xpi,
+ size: xpi.fileSize,
+ });
+
+ xpi = await getSystemAddonXPI(3, "3.0");
+ let [hashFunction, hashValue] = do_get_file_hash(xpi, "sha1").split(":");
+ list.push({
+ id: "system3@tests.mozilla.org",
+ version: "3.0",
+ path: "system3_3.xpi",
+ xpi,
+ hashFunction,
+ hashValue,
+ });
+
+ xpi = await getSystemAddonXPI(5, "1.0");
+ [hashFunction, hashValue] = do_get_file_hash(xpi, "sha1").split(":");
+ list.push({
+ id: "system5@tests.mozilla.org",
+ version: "1.0",
+ path: "system5_1.xpi",
+ size: xpi.fileSize,
+ xpi,
+ hashFunction,
+ hashValue,
+ });
+});
+
+add_task(async function () {
+ for (let setupName of Object.keys(TEST_CONDITIONS)) {
+ for (let testName of Object.keys(TESTS)) {
+ info("Running test " + setupName + " " + testName);
+
+ let setup = TEST_CONDITIONS[setupName];
+ let test = TESTS[testName];
+
+ await execSystemAddonTest(setupName, setup, test, distroDir);
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_custom.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_custom.js
new file mode 100644
index 0000000000..3aa61fed51
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_custom.js
@@ -0,0 +1,492 @@
+// Tests that system add-on upgrades work.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"], true);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+add_task(initSystemAddonDirs);
+
+/**
+ * Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ */
+const TEST_CONDITIONS = {
+ // Runs tests with no updated or default system add-ons initially installed
+ blank: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ // Runs tests with default system add-ons installed
+ withAppSet: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "prefilled";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with updated system add-ons installed
+ withProfileSet: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with both default and updated system add-ons installed
+ withBothSets: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "hidden";
+ },
+ initialState: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+};
+
+// Test that the update check is performed as part of the regular add-on update
+// check
+add_task(async function test_addon_update() {
+ Services.prefs.setBoolPref(PREF_SYSTEM_ADDON_UPDATE_ENABLED, true);
+ await setupSystemAddonConditions(TEST_CONDITIONS.blank, distroDir);
+
+ let updateXML = buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi: await getSystemAddonXPI(2, "2.0"),
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "2.0",
+ path: "system3_2.xpi",
+ xpi: await getSystemAddonXPI(3, "2.0"),
+ },
+ ]);
+
+ const promiseInstallsEnded = createInstallsEndedPromise(2);
+
+ await Promise.all([
+ updateAllSystemAddons(updateXML),
+ promiseWebExtensionStartup("system2@tests.mozilla.org"),
+ promiseWebExtensionStartup("system3@tests.mozilla.org"),
+ ]);
+
+ await promiseInstallsEnded;
+
+ await verifySystemAddonState(
+ TEST_CONDITIONS.blank.initialState,
+ [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ false,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+});
+
+// Disabling app updates should block system add-on updates
+add_task(async function test_app_update_disabled() {
+ await setupSystemAddonConditions(TEST_CONDITIONS.blank, distroDir);
+
+ Services.prefs.setBoolPref(PREF_SYSTEM_ADDON_UPDATE_ENABLED, false);
+ let updateXML = buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi: await getSystemAddonXPI(2, "2.0"),
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "2.0",
+ path: "system3_2.xpi",
+ xpi: await getSystemAddonXPI(3, "2.0"),
+ },
+ ]);
+ await updateAllSystemAddons(updateXML);
+ Services.prefs.clearUserPref(PREF_SYSTEM_ADDON_UPDATE_ENABLED);
+
+ await verifySystemAddonState(
+ TEST_CONDITIONS.blank.initialState,
+ undefined,
+ false,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+});
+
+// Safe mode should block system add-on updates
+add_task(async function test_safe_mode() {
+ gAppInfo.inSafeMode = true;
+
+ await setupSystemAddonConditions(TEST_CONDITIONS.blank, distroDir);
+
+ Services.prefs.setBoolPref(PREF_SYSTEM_ADDON_UPDATE_ENABLED, false);
+ let updateXML = buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi: await getSystemAddonXPI(2, "2.0"),
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "2.0",
+ path: "system3_2.xpi",
+ xpi: await getSystemAddonXPI(3, "2.0"),
+ },
+ ]);
+ await updateAllSystemAddons(updateXML);
+ Services.prefs.clearUserPref(PREF_SYSTEM_ADDON_UPDATE_ENABLED);
+
+ await verifySystemAddonState(
+ TEST_CONDITIONS.blank.initialState,
+ undefined,
+ false,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+
+ gAppInfo.inSafeMode = false;
+});
+
+// Tests that a set that matches the default set does nothing
+add_task(async function test_match_default() {
+ await setupSystemAddonConditions(TEST_CONDITIONS.withAppSet, distroDir);
+
+ let installXML = buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi: await getSystemAddonXPI(2, "2.0"),
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "2.0",
+ path: "system3_2.xpi",
+ xpi: await getSystemAddonXPI(3, "2.0"),
+ },
+ ]);
+ await installSystemAddons(installXML);
+
+ // Shouldn't have installed an updated set
+ await verifySystemAddonState(
+ TEST_CONDITIONS.withAppSet.initialState,
+ undefined,
+ false,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+});
+
+// Tests that a set that matches the hidden default set works
+add_task(async function test_match_default_revert() {
+ await setupSystemAddonConditions(TEST_CONDITIONS.withBothSets, distroDir);
+
+ let installXML = buildSystemAddonUpdates([
+ {
+ id: "system1@tests.mozilla.org",
+ version: "1.0",
+ path: "system1_1.xpi",
+ xpi: await getSystemAddonXPI(1, "1.0"),
+ },
+ {
+ id: "system2@tests.mozilla.org",
+ version: "1.0",
+ path: "system2_1.xpi",
+ xpi: await getSystemAddonXPI(2, "1.0"),
+ },
+ ]);
+ await installSystemAddons(installXML);
+
+ // This should revert to the default set instead of installing new versions
+ // into an updated set.
+ await verifySystemAddonState(
+ TEST_CONDITIONS.withBothSets.initialState,
+ [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ false,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+});
+
+// Tests that a set that matches the current set works
+add_task(async function test_match_current() {
+ await setupSystemAddonConditions(TEST_CONDITIONS.withBothSets, distroDir);
+
+ let installXML = buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi: await getSystemAddonXPI(2, "2.0"),
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "2.0",
+ path: "system3_2.xpi",
+ xpi: await getSystemAddonXPI(3, "2.0"),
+ },
+ ]);
+ await installSystemAddons(installXML);
+
+ // This should remain with the current set instead of creating a new copy
+ let set = JSON.parse(Services.prefs.getCharPref(PREF_SYSTEM_ADDON_SET));
+ Assert.equal(set.directory, "prefilled");
+
+ await verifySystemAddonState(
+ TEST_CONDITIONS.withBothSets.initialState,
+ undefined,
+ false,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+});
+
+// Tests that a set with a minor change doesn't re-download existing files
+add_task(async function test_no_download() {
+ await setupSystemAddonConditions(TEST_CONDITIONS.withBothSets, distroDir);
+
+ // The missing file here is unneeded since there is a local version already
+ let installXML = buildSystemAddonUpdates([
+ { id: "system2@tests.mozilla.org", version: "2.0", path: "missing.xpi" },
+ {
+ id: "system4@tests.mozilla.org",
+ version: "1.0",
+ path: "system4_1.xpi",
+ xpi: await getSystemAddonXPI(4, "1.0"),
+ },
+ ]);
+
+ const promiseInstallsEnded = createInstallsEndedPromise(2);
+
+ await Promise.all([
+ installSystemAddons(installXML),
+ promiseWebExtensionStartup("system4@tests.mozilla.org"),
+ ]);
+
+ // NOTE: verifySystemAddonState does call AddonTestUtils.promiseShutdownManager
+ // internally, and so we need to wait for the system addons to be fully
+ // installed and the system addon location's stating dir to have been released.
+ info("Wait for system addon installs to be completed");
+ await promiseInstallsEnded;
+ info("Wait for system addons stating dir to be released");
+ await waitForSystemAddonStagingDirReleased();
+
+ await verifySystemAddonState(
+ TEST_CONDITIONS.withBothSets.initialState,
+ [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ],
+ false,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+});
+
+// Tests that a second update before a restart works
+add_task(async function test_double_update() {
+ await setupSystemAddonConditions(TEST_CONDITIONS.withAppSet, distroDir);
+
+ let installXML = buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi: await getSystemAddonXPI(2, "2.0"),
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "1.0",
+ path: "system3_1.xpi",
+ xpi: await getSystemAddonXPI(3, "1.0"),
+ },
+ ]);
+ await Promise.all([
+ installSystemAddons(installXML),
+ promiseWebExtensionStartup("system2@tests.mozilla.org"),
+ promiseWebExtensionStartup("system3@tests.mozilla.org"),
+ ]);
+
+ installXML = buildSystemAddonUpdates([
+ {
+ id: "system3@tests.mozilla.org",
+ version: "2.0",
+ path: "system3_2.xpi",
+ xpi: await getSystemAddonXPI(3, "2.0"),
+ },
+ {
+ id: "system4@tests.mozilla.org",
+ version: "1.0",
+ path: "system4_1.xpi",
+ xpi: await getSystemAddonXPI(4, "1.0"),
+ },
+ ]);
+ await Promise.all([
+ installSystemAddons(installXML),
+ promiseWebExtensionStartup("system3@tests.mozilla.org"),
+ promiseWebExtensionStartup("system4@tests.mozilla.org"),
+ ]);
+
+ await verifySystemAddonState(
+ TEST_CONDITIONS.withAppSet.initialState,
+ [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ],
+ true,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+});
+
+// A second update after a restart will delete the original unused set
+add_task(async function test_update_purges() {
+ await setupSystemAddonConditions(TEST_CONDITIONS.withBothSets, distroDir);
+
+ let installXML = buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi: await getSystemAddonXPI(2, "2.0"),
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "1.0",
+ path: "system3_1.xpi",
+ xpi: await getSystemAddonXPI(3, "1.0"),
+ },
+ ]);
+ await Promise.all([
+ installSystemAddons(installXML),
+ promiseWebExtensionStartup("system2@tests.mozilla.org"),
+ promiseWebExtensionStartup("system3@tests.mozilla.org"),
+ ]);
+
+ await verifySystemAddonState(
+ TEST_CONDITIONS.withBothSets.initialState,
+ [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ false,
+ distroDir
+ );
+
+ await installSystemAddons(buildSystemAddonUpdates(null));
+
+ let dirs = await getSystemAddonDirectories();
+ Assert.equal(dirs.length, 1);
+
+ await promiseShutdownManager();
+});
+
+function createInstallsEndedPromise(expectedCount) {
+ // Addon installation triggers the execution of an un-awaited promise. We need
+ // to keep track of addon installs so that we can tell when these async
+ // processes have finished.
+
+ return new Promise(resolve => {
+ let installsEnded = 0;
+
+ const listener = {
+ onInstallStarted() {},
+ onInstallEnded() {
+ installsEnded++;
+
+ if (installsEnded === expectedCount) {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ }
+ },
+ onInstallCancelled() {},
+ onInstallFailed() {},
+ };
+
+ AddonManager.addInstallListener(listener);
+ });
+}
+
+async function waitForSystemAddonStagingDirReleased() {
+ // Wait for the staging dir to be released, which prevents unexpected test failure due to
+ // AddonTestUtils.promiseShutdownManager being mocking an AddonManager shutdown by using
+ // Cu.unload to unload all XPIProvider jsm modules, which would hit unexpected failures
+ // if done while system addon updates are still running in the background (due to the
+ // fact that the jsm global to have been already nuked while AddonInstall startInstall
+ // method may still being executed asynchronously).
+
+ const { XPIInternal } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIProvider.jsm"
+ );
+ let systemAddonLocation = XPIInternal.XPIStates.getLocation(
+ XPIInternal.KEY_APP_SYSTEM_ADDONS
+ );
+ await TestUtils.waitForCondition(() => {
+ return systemAddonLocation.installer._stagingDirLock == 0;
+ }, "Wait for staging dir to be released");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_empty.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_empty.js
new file mode 100644
index 0000000000..c22dac7600
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_empty.js
@@ -0,0 +1,141 @@
+// Tests that system add-on upgrades work.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"], true);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+add_task(() => initSystemAddonDirs());
+
+/**
+ * Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ */
+const TEST_CONDITIONS = {
+ // Runs tests with no updated or default system add-ons initially installed
+ blank: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ // Runs tests with default system add-ons installed
+ withAppSet: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "prefilled";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with updated system add-ons installed
+ withProfileSet: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with both default and updated system add-ons installed
+ withBothSets: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "hidden";
+ },
+ initialState: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+};
+
+/**
+ * The tests to run. Each test must define an updateList or test. The following
+ * properties are used:
+ *
+ * updateList: The set of add-ons the server should respond with.
+ * test: A function to run to perform the update check (replaces
+ * updateList)
+ * fails: An optional property, if true the update check is expected to
+ * fail.
+ * finalState: An optional property, the expected final state of system add-ons,
+ * if missing the test condition's initialState is used.
+ */
+const TESTS = {
+ // Test that an empty list removes existing updates, leaving defaults.
+ empty: {
+ updateList: [],
+ finalState: {
+ blank: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ withAppSet: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ withProfileSet: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ withBothSets: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ // Set this to `true` to so `verifySystemAddonState()` expects a blank profile dir
+ { isUpgrade: true, version: null },
+ ],
+ },
+ },
+};
+
+add_task(async function () {
+ for (let setupName of Object.keys(TEST_CONDITIONS)) {
+ for (let testName of Object.keys(TESTS)) {
+ info("Running test " + setupName + " " + testName);
+
+ let setup = TEST_CONDITIONS[setupName];
+ let test = TESTS[testName];
+
+ await execSystemAddonTest(setupName, setup, test, distroDir);
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_enterprisepolicy.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_enterprisepolicy.js
new file mode 100644
index 0000000000..2211a35862
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_enterprisepolicy.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// This test verifies that system addon updates are correctly blocked by the
+// DisableSystemAddonUpdate enterprise policy.
+
+const { EnterprisePolicyTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+);
+
+Services.policies; // Load policy engine
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"], true);
+registerDirectory("XREAppFeat", distroDir);
+add_task(() => initSystemAddonDirs());
+
+/**
+ * Defines the set of initial conditions to run the test against.
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ *
+ * These conditions run tests with no updated or default system add-ons
+ * initially installed
+ */
+const TEST_CONDITIONS = {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+};
+
+add_task(async function test_update_disabled_by_policy() {
+ await setupSystemAddonConditions(TEST_CONDITIONS, distroDir);
+
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {
+ DisableSystemAddonUpdate: true,
+ },
+ });
+
+ await updateAllSystemAddons(
+ buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi: await getSystemAddonXPI(2, "2.0"),
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "2.0",
+ path: "system3_2.xpi",
+ xpi: await getSystemAddonXPI(3, "2.0"),
+ },
+ ])
+ );
+
+ await verifySystemAddonState(
+ TEST_CONDITIONS.initialState,
+ undefined,
+ false,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_fail.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_fail.js
new file mode 100644
index 0000000000..cfcdab401e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_fail.js
@@ -0,0 +1,185 @@
+// Tests that system add-on upgrades fail to upgrade in expected cases.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"], true);
+registerDirectory("XREAppFeat", distroDir);
+add_task(() => initSystemAddonDirs());
+
+/**
+ * Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ */
+const TEST_CONDITIONS = {
+ // Runs tests with no updated or default system add-ons initially installed
+ blank: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ // Runs tests with default system add-ons installed
+ withAppSet: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "prefilled";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with updated system add-ons installed
+ withProfileSet: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with both default and updated system add-ons installed
+ withBothSets: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "hidden";
+ },
+ initialState: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+};
+
+/**
+ * The tests to run. Each test must define an updateList or test. The following
+ * properties are used:
+ *
+ * updateList: The set of add-ons the server should respond with.
+ * test: A function to run to perform the update check (replaces
+ * updateList)
+ * fails: regex to test error in Assert.rejects.
+ * finalState: An optional property, the expected final state of system add-ons,
+ * if missing the test condition's initialState is used.
+ */
+const TESTS = {
+ // Specifying an incorrect version should stop us updating anything
+ badVersion: {
+ fails:
+ /Error: Rejecting updated system add-on set that either could not be downloaded or contained unusable add-ons./,
+ updateList: [
+ {
+ id: "system2@tests.mozilla.org",
+ version: "4.0",
+ path: "system2_3.xpi",
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "3.0",
+ path: "system3_3.xpi",
+ },
+ ],
+ },
+
+ // Specifying an invalid size should stop us updating anything
+ badSize: {
+ fails:
+ /Error: Rejecting updated system add-on set that either could not be downloaded or contained unusable add-ons./,
+ updateList: [
+ {
+ id: "system2@tests.mozilla.org",
+ version: "3.0",
+ path: "system2_3.xpi",
+ size: 2,
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "3.0",
+ path: "system3_3.xpi",
+ },
+ ],
+ },
+
+ // Specifying an incorrect hash should stop us updating anything
+ badHash: {
+ fails:
+ /Error: Rejecting updated system add-on set that either could not be downloaded or contained unusable add-ons./,
+ updateList: [
+ {
+ id: "system2@tests.mozilla.org",
+ version: "3.0",
+ path: "system2_3.xpi",
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "3.0",
+ path: "system3_3.xpi",
+ hashFunction: "sha1",
+ hashValue: "205a4c49bd513ebd30594e380c19e86bba1f83e2",
+ },
+ ],
+ },
+
+ // A bad certificate should stop updates
+ badCert: {
+ fails:
+ /Error: Rejecting updated system add-on set that either could not be downloaded or contained unusable add-ons./,
+ // true is not system addon signed
+ usePrivilegedSignatures: true,
+ updateList: [
+ {
+ id: "system1@tests.mozilla.org",
+ version: "1.0",
+ path: "system1_1_badcert.xpi",
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "1.0",
+ path: "system3_1.xpi",
+ },
+ ],
+ },
+};
+
+add_task(async function setup() {
+ // Initialise the profile
+ await overrideBuiltIns({ system: [] });
+ await promiseStartupManager();
+ await promiseShutdownManager();
+});
+
+add_task(async function () {
+ for (let setupName of Object.keys(TEST_CONDITIONS)) {
+ for (let testName of Object.keys(TESTS)) {
+ info("Running test " + setupName + " " + testName);
+
+ let setup = TEST_CONDITIONS[setupName];
+ let test = TESTS[testName];
+
+ await execSystemAddonTest(setupName, setup, test, distroDir);
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_installTelemetryInfo.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_installTelemetryInfo.js
new file mode 100644
index 0000000000..3d41b81198
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_installTelemetryInfo.js
@@ -0,0 +1,94 @@
+// Test that the expected installTelemetryInfo are being set on the system addon
+// installed/updated through Balrog.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"], true);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+add_task(() => initSystemAddonDirs());
+
+add_task(async function test_addon_update() {
+ Services.prefs.setBoolPref(PREF_SYSTEM_ADDON_UPDATE_ENABLED, true);
+
+ // Define the set of initial conditions to run each test against:
+ // - setup: A task to setup the profile into the initial state.
+ // - initialState: The initial expected system add-on state after setup has run.
+ const initialSetup = {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ ],
+ };
+
+ await setupSystemAddonConditions(initialSetup, distroDir);
+
+ const newlyInstalledSystemAddonId = "system1@tests.mozilla.org";
+ const updatedSystemAddonId = "system2@tests.mozilla.org";
+
+ const updateXML = buildSystemAddonUpdates([
+ // Newly installed system addon entry.
+ {
+ id: newlyInstalledSystemAddonId,
+ version: "1.0",
+ path: "system1_1.xpi",
+ xpi: await getSystemAddonXPI(1, "1.0"),
+ },
+ // Updated system addon entry.
+ {
+ id: updatedSystemAddonId,
+ version: "3.0",
+ path: "system2_3.xpi",
+ xpi: await getSystemAddonXPI(2, "3.0"),
+ },
+ ]);
+
+ await Promise.all([
+ updateAllSystemAddons(updateXML),
+ promiseWebExtensionStartup(newlyInstalledSystemAddonId),
+ promiseWebExtensionStartup(updatedSystemAddonId),
+ ]);
+
+ await verifySystemAddonState(
+ initialSetup.initialState,
+ [
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ false,
+ distroDir
+ );
+
+ const newlyInstalledSystemAddon = await AddonManager.getAddonByID(
+ newlyInstalledSystemAddonId
+ );
+ Assert.deepEqual(
+ newlyInstalledSystemAddon.installTelemetryInfo,
+ // For addons installed for the first time through the product addon checker
+ // we set a `method` in the telemetryInfo.
+ { source: "system-addon", method: "product-updates" },
+ "Got the expected telemetry info on balrog system addon installed addon"
+ );
+
+ const updatedSystemAddon = await AddonManager.getAddonByID(
+ updatedSystemAddonId
+ );
+ Assert.deepEqual(
+ updatedSystemAddon.installTelemetryInfo,
+ // For addons that are distributed in Firefox, then updated through the product
+ // addon checker, `method` will not be set.
+ { source: "system-addon" },
+ "Got the expected telemetry info on balrog system addon updated addon"
+ );
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_newset.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_newset.js
new file mode 100644
index 0000000000..7f1ccf2745
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_newset.js
@@ -0,0 +1,165 @@
+// Tests that system add-on upgrades work.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"], true);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+add_task(() => initSystemAddonDirs());
+
+/**
+ * Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ */
+const TEST_CONDITIONS = {
+ // Runs tests with no updated or default system add-ons initially installed
+ blank: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ // Runs tests with default system add-ons installed
+ withAppSet: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "prefilled";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with updated system add-ons installed
+ withProfileSet: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with both default and updated system add-ons installed
+ withBothSets: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "hidden";
+ },
+ initialState: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+};
+
+/**
+ * The tests to run. Each test must define an updateList or test. The following
+ * properties are used:
+ *
+ * updateList: The set of add-ons the server should respond with.
+ * test: A function to run to perform the update check (replaces
+ * updateList)
+ * fails: An optional property, if true the update check is expected to
+ * fail.
+ * finalState: An optional property, the expected final state of system add-ons,
+ * if missing the test condition's initialState is used.
+ */
+const TESTS = {
+ // Tests that a new set of system add-ons gets installed
+ newset: {
+ // updateList is populated in setup() below
+ updateList: [],
+ finalState: {
+ blank: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: true, version: "1.0" },
+ ],
+ withAppSet: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: true, version: "1.0" },
+ ],
+ withProfileSet: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: true, version: "1.0" },
+ ],
+ withBothSets: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: true, version: "1.0" },
+ ],
+ },
+ },
+};
+
+add_task(async function setup() {
+ // Initialise the profile
+ await overrideBuiltIns({ system: [] });
+ await promiseStartupManager();
+ await promiseShutdownManager();
+
+ let list = TESTS.newset.updateList;
+ let xpi = await getSystemAddonXPI(4, "1.0");
+ list.push({
+ id: "system4@tests.mozilla.org",
+ version: "1.0",
+ path: "system4_1.xpi",
+ xpi,
+ });
+
+ xpi = await getSystemAddonXPI(5, "1.0");
+ list.push({
+ id: "system5@tests.mozilla.org",
+ version: "1.0",
+ path: "system5_1.xpi",
+ xpi,
+ });
+});
+
+add_task(async function () {
+ for (let setupName of Object.keys(TEST_CONDITIONS)) {
+ for (let testName of Object.keys(TESTS)) {
+ info("Running test " + setupName + " " + testName);
+
+ let setup = TEST_CONDITIONS[setupName];
+ let test = TESTS[testName];
+
+ await execSystemAddonTest(setupName, setup, test, distroDir);
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_overlapping.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_overlapping.js
new file mode 100644
index 0000000000..0dd4821462
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_overlapping.js
@@ -0,0 +1,180 @@
+// Tests that system add-on upgrades work.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"], true);
+registerDirectory("XREAppFeat", distroDir);
+add_task(() => initSystemAddonDirs());
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+/**
+ * Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ */
+const TEST_CONDITIONS = {
+ // Runs tests with no updated or default system add-ons initially installed
+ blank: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ // Runs tests with default system add-ons installed
+ withAppSet: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "prefilled";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with updated system add-ons installed
+ withProfileSet: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with both default and updated system add-ons installed
+ withBothSets: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "hidden";
+ },
+ initialState: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+};
+
+/**
+ * The tests to run. Each test must define an updateList or test. The following
+ * properties are used:
+ *
+ * updateList: The set of add-ons the server should respond with.
+ * test: A function to run to perform the update check (replaces
+ * updateList)
+ * fails: An optional property, if true the update check is expected to
+ * fail.
+ * finalState: An optional property, the expected final state of system add-ons,
+ * if missing the test condition's initialState is used.
+ */
+const TESTS = {
+ // Tests that a set of system add-ons, some new, some existing gets installed
+ overlapping: {
+ // updateList is populated in setup() below
+ updateList: [],
+ finalState: {
+ blank: [
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ],
+ withAppSet: [
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ],
+ withProfileSet: [
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ],
+ withBothSets: [
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ },
+};
+
+add_task(async function setup() {
+ // Initialise the profile
+ await overrideBuiltIns({ system: [] });
+ await promiseStartupManager();
+ await promiseShutdownManager();
+
+ let list = TESTS.overlapping.updateList;
+ let xpi = await getSystemAddonXPI(1, "2.0");
+ list.push({
+ id: "system1@tests.mozilla.org",
+ version: "2.0",
+ path: "system1_2.xpi",
+ xpi,
+ });
+
+ xpi = await getSystemAddonXPI(2, "2.0");
+ list.push({
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi,
+ });
+
+ xpi = await getSystemAddonXPI(3, "3.0");
+ list.push({
+ id: "system3@tests.mozilla.org",
+ version: "3.0",
+ path: "system3_3.xpi",
+ xpi,
+ });
+
+ xpi = await getSystemAddonXPI(4, "1.0");
+ list.push({
+ id: "system4@tests.mozilla.org",
+ version: "1.0",
+ path: "system4_1.xpi",
+ xpi,
+ });
+});
+
+add_task(async function () {
+ for (let setupName of Object.keys(TEST_CONDITIONS)) {
+ for (let testName of Object.keys(TESTS)) {
+ info("Running test " + setupName + " " + testName);
+
+ let setup = TEST_CONDITIONS[setupName];
+ let test = TESTS[testName];
+
+ await execSystemAddonTest(setupName, setup, test, distroDir);
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_uninstall_check.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_uninstall_check.js
new file mode 100644
index 0000000000..38ac3cf6b7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_uninstall_check.js
@@ -0,0 +1,56 @@
+// Tests that system add-on doesnt uninstall while update.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"], true);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = "system";
+
+add_task(() => initSystemAddonDirs());
+
+const initialSetup = {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ ],
+};
+
+add_task(async function test_systems_update_uninstall_check() {
+ Services.prefs.setBoolPref(PREF_SYSTEM_ADDON_UPDATE_ENABLED, true);
+
+ await setupSystemAddonConditions(initialSetup, distroDir);
+
+ let updateXML = buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "3.0",
+ path: "system2_3.xpi",
+ xpi: await getSystemAddonXPI(2, "3.0"),
+ },
+ ]);
+
+ const listener = (msg, { method, params, reason }) => {
+ if (params.id === "system2@tests.mozilla.org" && method === "uninstall") {
+ Assert.ok(
+ false,
+ "Should not see uninstall call for system2@tests.mozilla.org"
+ );
+ }
+ };
+
+ AddonTestUtils.on("bootstrap-method", listener);
+
+ await Promise.all([
+ updateAllSystemAddons(updateXML),
+ promiseWebExtensionStartup("system2@tests.mozilla.org"),
+ ]);
+
+ AddonTestUtils.off("bootstrap-method", listener);
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_upgrades.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_upgrades.js
new file mode 100644
index 0000000000..9d514b10c6
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_upgrades.js
@@ -0,0 +1,165 @@
+// Tests that system add-on upgrades work.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"], true);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+add_task(() => initSystemAddonDirs());
+
+/**
+ * Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ */
+const TEST_CONDITIONS = {
+ // Runs tests with no updated or default system add-ons initially installed
+ blank: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ // Runs tests with default system add-ons installed
+ withAppSet: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "prefilled";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with updated system add-ons installed
+ withProfileSet: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with both default and updated system add-ons installed
+ withBothSets: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "hidden";
+ },
+ initialState: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+};
+
+/**
+ * The tests to run. Each test must define an updateList or test. The following
+ * properties are used:
+ *
+ * updateList: The set of add-ons the server should respond with.
+ * test: A function to run to perform the update check (replaces
+ * updateList)
+ * fails: An optional property, if true the update check is expected to
+ * fail.
+ * finalState: An optional property, the expected final state of system add-ons,
+ * if missing the test condition's initialState is used.
+ */
+const TESTS = {
+ // Tests that an upgraded set of system add-ons gets installed
+ upgrades: {
+ // populated in setup() below
+ updateList: [],
+ finalState: {
+ blank: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ withAppSet: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ withProfileSet: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ withBothSets: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ },
+};
+
+add_task(async function setup() {
+ // Initialise the profile
+ await overrideBuiltIns({ system: [] });
+ await promiseStartupManager();
+ await promiseShutdownManager();
+
+ let list = TESTS.upgrades.updateList;
+ let xpi = await getSystemAddonXPI(2, "3.0");
+ list.push({
+ id: "system2@tests.mozilla.org",
+ version: "3.0",
+ path: "system2_3.xpi",
+ xpi,
+ });
+
+ xpi = await getSystemAddonXPI(3, "3.0");
+ list.push({
+ id: "system3@tests.mozilla.org",
+ version: "3.0",
+ path: "system3_3.xpi",
+ xpi,
+ });
+});
+
+add_task(async function () {
+ for (let setupName of Object.keys(TEST_CONDITIONS)) {
+ for (let testName of Object.keys(TESTS)) {
+ info("Running test " + setupName + " " + testName);
+
+ let setup = TEST_CONDITIONS[setupName];
+ let test = TESTS[testName];
+
+ await execSystemAddonTest(setupName, setup, test, distroDir);
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_upgrades.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_upgrades.js
new file mode 100644
index 0000000000..2be1c7b569
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_upgrades.js
@@ -0,0 +1,417 @@
+"use strict";
+
+// Enable SCOPE_APPLICATION for builtin testing. Default in tests is only SCOPE_PROFILE.
+let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION;
+Services.prefs.setIntPref("extensions.enabledScopes", scopes);
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+BootstrapMonitor.init();
+
+// A test directory for default/builtin system addons.
+const systemDefaults = FileUtils.getDir(
+ "ProfD",
+ ["app-system-defaults", "features"],
+ true
+);
+registerDirectory("XREAppFeat", systemDefaults);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+const ADDON_ID = "updates@test";
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+let server = createHttpServer();
+
+server.registerPathHandler("/upgrade.json", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "ok");
+ response.write(
+ JSON.stringify({
+ addons: {
+ [ADDON_ID]: {
+ updates: [
+ {
+ version: "4.0",
+ update_link: `http://localhost:${server.identity.primaryPort}/${ADDON_ID}.xpi`,
+ },
+ ],
+ },
+ },
+ })
+ );
+});
+
+function createWebExtensionFile(id, version, update_url) {
+ return AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: {
+ gecko: { id, update_url },
+ },
+ },
+ });
+}
+
+let xpiUpdate = createWebExtensionFile(ADDON_ID, "4.0");
+
+server.registerFile(`/${ADDON_ID}.xpi`, xpiUpdate);
+
+async function promiseInstallDefaultSystemAddon(id, version) {
+ let xpi = createWebExtensionFile(id, version);
+ await AddonTestUtils.manuallyInstall(xpi, systemDefaults);
+ return xpi;
+}
+
+async function promiseInstallProfileExtension(id, version, update_url) {
+ return promiseInstallWebExtension({
+ manifest: {
+ version,
+ browser_specific_settings: {
+ gecko: { id, update_url },
+ },
+ },
+ });
+}
+
+async function promiseInstallSystemProfileAddon(id, version) {
+ let xpi = createWebExtensionFile(id, version);
+ const install = await AddonManager.getInstallForURL(`file://${xpi.path}`, {
+ useSystemLocation: true, // KEY_APP_SYSTEM_PROFILE
+ });
+
+ return install.install();
+}
+
+async function promiseUpdateSystemAddon(id, version, waitForStartup = true) {
+ let xpi = createWebExtensionFile(id, version);
+ let xml = buildSystemAddonUpdates([
+ {
+ id: ADDON_ID,
+ version,
+ path: xpi.leafName,
+ xpi,
+ },
+ ]);
+ // If we're not expecting a startup we need to wait for install to end.
+ let promises = [];
+ if (!waitForStartup) {
+ promises.push(AddonTestUtils.promiseAddonEvent("onInstalled"));
+ }
+ promises.push(installSystemAddons(xml, waitForStartup ? [ADDON_ID] : []));
+ return Promise.all(promises);
+}
+
+async function promiseClearSystemAddons() {
+ let xml = buildSystemAddonUpdates([]);
+ return installSystemAddons(xml, []);
+}
+
+const builtInOverride = { system: [ADDON_ID] };
+
+async function checkAddon(version, reason, startReason = reason) {
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 1, "one addon expected to be installed");
+ Assert.equal(addons[0].version, version, `addon ${version} is installed`);
+ Assert.ok(addons[0].isActive, `addon ${version} is active`);
+ Assert.ok(!addons[0].disabled, `addon ${version} is enabled`);
+
+ let installInfo = BootstrapMonitor.checkInstalled(ADDON_ID, version);
+ equal(
+ installInfo.reason,
+ reason,
+ `bootstrap monitor verified install reason for ${version}`
+ );
+ let started = BootstrapMonitor.checkStarted(ADDON_ID, version);
+ equal(
+ started.reason,
+ startReason,
+ `bootstrap monitor verified started reason for ${version}`
+ );
+
+ return addons[0];
+}
+
+/**
+ * This test function starts after a 1.0 version of an addon is installed
+ * either as a builtin ("app-builtin") or as a builtin system addon ("app-system-defaults").
+ *
+ * This tests the precedence chain works as expected through upgrades and
+ * downgrades.
+ *
+ * Upgrade to a system addon in the profile location, "app-system-addons"
+ * Upgrade to a temporary addon
+ * Uninstalling the temporary addon downgrades to the system addon in "app-system-addons".
+ * Upgrade to a system addon in the "app-system-profile" location.
+ * Uninstalling the "app-system-profile" addon downgrades to the one in "app-system-addons".
+ * Upgrade to a user-installed addon
+ * Upgrade the addon in "app-system-addons", verify user-install is still active
+ * Uninstall the addon in "app-system-addons", verify user-install is active
+ * Test that the update_url upgrades the user-install and becomes active
+ * Disable the user-install, verify the disabled addon retains precedence
+ * Uninstall the disabled user-install, verify system addon in "app-system-defaults" is active and enabled
+ * Upgrade the system addon again, then user-install a lower version, verify downgrade happens.
+ * Uninstall user-install, verify upgrade happens when the system addon in "app-system-addons" is activated.
+ */
+async function _test_builtin_addon_override() {
+ /////
+ // Upgrade to a system addon in the profile location, "app-system-addons"
+ /////
+ await promiseUpdateSystemAddon(ADDON_ID, "2.0");
+ await checkAddon("2.0", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ /////
+ // Upgrade to a temporary addon
+ /////
+ let tmpAddon = createWebExtensionFile(ADDON_ID, "2.1");
+ await Promise.all([
+ AddonManager.installTemporaryAddon(tmpAddon),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ let addon = await checkAddon("2.1", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ /////
+ // Downgrade back to the system addon
+ /////
+ await addon.uninstall();
+ await checkAddon("2.0", BOOTSTRAP_REASONS.ADDON_DOWNGRADE);
+
+ /////
+ // Install then uninstall an system profile addon
+ /////
+ info("Install an System Profile Addon, then uninstall it.");
+ await Promise.all([
+ promiseInstallSystemProfileAddon(ADDON_ID, "2.2"),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ addon = await checkAddon("2.2", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+ await addon.uninstall();
+ await checkAddon("2.0", BOOTSTRAP_REASONS.ADDON_DOWNGRADE);
+
+ /////
+ // Upgrade to a user-installed addon
+ /////
+ await Promise.all([
+ promiseInstallProfileExtension(
+ ADDON_ID,
+ "3.0",
+ `http://localhost:${server.identity.primaryPort}/upgrade.json`
+ ),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ await checkAddon("3.0", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ /////
+ // Upgrade the system addon, verify user-install is still active
+ /////
+ await promiseUpdateSystemAddon(ADDON_ID, "2.2", false);
+ await checkAddon("3.0", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ /////
+ // Uninstall the system addon, verify user-install is active
+ /////
+ await Promise.all([
+ promiseClearSystemAddons(),
+ AddonTestUtils.promiseAddonEvent("onUninstalled"),
+ ]);
+ addon = await checkAddon("3.0", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ /////
+ // Test that the update_url upgrades the user-install and becomes active
+ /////
+ let update = await promiseFindAddonUpdates(addon);
+ await Promise.all([
+ promiseCompleteAllInstalls([update.updateAvailable]),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ addon = await checkAddon("4.0", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ /////
+ // Disable the user-install, verify the disabled addon retains precedence
+ /////
+ await addon.disable();
+
+ await AddonManager.getAddonByID(ADDON_ID);
+ Assert.ok(!addon.isActive, "4.0 is disabled");
+ Assert.equal(
+ addon.version,
+ "4.0",
+ "version 4.0 is still the visible version"
+ );
+
+ /////
+ // Uninstall the disabled user-install, verify system addon is active and enabled
+ /////
+ await Promise.all([
+ addon.uninstall(),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ // We've downgraded all the way to 1.0 from a user-installed addon
+ addon = await checkAddon("1.0", BOOTSTRAP_REASONS.ADDON_DOWNGRADE);
+
+ /////
+ // Upgrade the system addon again, then user-install a lower version, verify downgrade happens.
+ /////
+ await promiseUpdateSystemAddon(ADDON_ID, "5.1");
+ addon = await checkAddon("5.1", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ // user-install a lower version, downgrade happens
+ await Promise.all([
+ promiseInstallProfileExtension(ADDON_ID, "5.0"),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ addon = await checkAddon("5.0", BOOTSTRAP_REASONS.ADDON_DOWNGRADE);
+
+ /////
+ // Uninstall user-install, verify upgrade happens when system addon is activated.
+ /////
+ await Promise.all([
+ addon.uninstall(),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ // the "system add-on upgrade" is now revealed
+ addon = await checkAddon("5.1", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ await Promise.all([
+ addon.uninstall(),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ await checkAddon("1.0", BOOTSTRAP_REASONS.ADDON_DOWNGRADE);
+
+ // Downgrading from an installed system addon to a default system
+ // addon also requires the removal of the file on disk, and removing
+ // the addon from the pref.
+ Services.prefs.clearUserPref(PREF_SYSTEM_ADDON_SET);
+}
+
+add_task(async function test_system_addon_upgrades() {
+ await AddonTestUtils.overrideBuiltIns(builtInOverride);
+ await promiseInstallDefaultSystemAddon(ADDON_ID, "1.0");
+ await AddonTestUtils.promiseStartupManager();
+ await checkAddon("1.0", BOOTSTRAP_REASONS.ADDON_INSTALL);
+
+ await _test_builtin_addon_override();
+
+ // cleanup the system addon in the default location
+ await AddonTestUtils.manuallyUninstall(systemDefaults, ADDON_ID);
+ // If we don't restart (to clean up the uninstall above) and we
+ // clear BootstrapMonitor, BootstrapMonitor asserts on the next AOM startup.
+ await AddonTestUtils.promiseRestartManager();
+
+ await AddonTestUtils.promiseShutdownManager();
+ BootstrapMonitor.clear(ADDON_ID);
+});
+
+// Run the test again, but starting from a "builtin" addon location
+add_task(async function test_builtin_addon_upgrades() {
+ builtInOverride.system = [];
+
+ await AddonTestUtils.promiseStartupManager();
+ await Promise.all([
+ installBuiltinExtension({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: { id: ADDON_ID },
+ },
+ },
+ }),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ await checkAddon("1.0", BOOTSTRAP_REASONS.ADDON_INSTALL);
+
+ await _test_builtin_addon_override();
+
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ await addon.uninstall();
+ await AddonTestUtils.promiseShutdownManager();
+ BootstrapMonitor.clear(ADDON_ID);
+});
+
+add_task(async function test_system_addon_precedence() {
+ builtInOverride.system = [ADDON_ID];
+ await AddonTestUtils.overrideBuiltIns(builtInOverride);
+ await promiseInstallDefaultSystemAddon(ADDON_ID, "1.0");
+ await AddonTestUtils.promiseStartupManager();
+ await checkAddon("1.0", BOOTSTRAP_REASONS.ADDON_INSTALL);
+
+ /////
+ // Upgrade to a system addon in the profile location, "app-system-addons"
+ /////
+ await promiseUpdateSystemAddon(ADDON_ID, "2.0");
+ await checkAddon("2.0", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ /////
+ // Builtin system addon is changed, it has precedence because when this
+ // happens we remove all prior system addon upgrades.
+ /////
+ await AddonTestUtils.promiseShutdownManager();
+ await AddonTestUtils.overrideBuiltIns(builtInOverride);
+ await promiseInstallDefaultSystemAddon(ADDON_ID, "1.5");
+ await AddonTestUtils.promiseStartupManager("2");
+ await checkAddon(
+ "1.5",
+ BOOTSTRAP_REASONS.ADDON_DOWNGRADE,
+ BOOTSTRAP_REASONS.APP_STARTUP
+ );
+
+ // cleanup the system addon in the default location
+ await AddonTestUtils.manuallyUninstall(systemDefaults, ADDON_ID);
+ await AddonTestUtils.promiseRestartManager();
+
+ await AddonTestUtils.promiseShutdownManager();
+ BootstrapMonitor.clear(ADDON_ID);
+});
+
+add_task(async function test_builtin_addon_version_precedence() {
+ builtInOverride.system = [];
+
+ await AddonTestUtils.promiseStartupManager();
+ await Promise.all([
+ installBuiltinExtension({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: { id: ADDON_ID },
+ },
+ },
+ }),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ await checkAddon("1.0", BOOTSTRAP_REASONS.ADDON_INSTALL);
+
+ /////
+ // Upgrade to a system addon in the profile location, "app-system-addons"
+ /////
+ await promiseUpdateSystemAddon(ADDON_ID, "2.0");
+ await checkAddon("2.0", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ /////
+ // Builtin addon is changed, the system addon should still have precedence.
+ /////
+ await Promise.all([
+ installBuiltinExtension(
+ {
+ manifest: {
+ version: "1.5",
+ browser_specific_settings: {
+ gecko: { id: ADDON_ID },
+ },
+ },
+ },
+ false
+ ),
+ AddonTestUtils.promiseAddonEvent("onInstalled"),
+ ]);
+ await checkAddon("2.0", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ await addon.uninstall();
+ await AddonTestUtils.promiseShutdownManager();
+ BootstrapMonitor.clear(ADDON_ID);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_systemaddomstartupprefs.js b/toolkit/mozapps/extensions/test/xpcshell/test_systemaddomstartupprefs.js
new file mode 100644
index 0000000000..fb45d60ff8
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_systemaddomstartupprefs.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that system addon about:config prefs
+// are honored during startup/restarts/upgrades.
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"], true);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = "system";
+
+add_task(initSystemAddonDirs);
+
+BootstrapMonitor.init();
+
+add_task(async function setup() {
+ let xpi = await getSystemAddonXPI(1, "1.0");
+ await AddonTestUtils.manuallyInstall(xpi, distroDir);
+});
+
+add_task(async function systemAddonPreffedOff() {
+ const id = "system1@tests.mozilla.org";
+ Services.prefs.setBoolPref("extensions.system1.enabled", false);
+
+ await overrideBuiltIns({ system: [id] });
+
+ await promiseStartupManager();
+
+ BootstrapMonitor.checkInstalled(id);
+ BootstrapMonitor.checkNotStarted(id);
+
+ await promiseRestartManager();
+
+ BootstrapMonitor.checkNotStarted(id);
+
+ await promiseShutdownManager({ clearOverrides: false });
+});
+
+add_task(async function systemAddonPreffedOn() {
+ const id = "system1@tests.mozilla.org";
+ Services.prefs.setBoolPref("extensions.system1.enabled", true);
+
+ await promiseStartupManager("2.0");
+
+ BootstrapMonitor.checkInstalled(id);
+ BootstrapMonitor.checkStarted(id);
+
+ await promiseRestartManager();
+
+ BootstrapMonitor.checkStarted(id);
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_temporary.js b/toolkit/mozapps/extensions/test/xpcshell/test_temporary.js
new file mode 100644
index 0000000000..80faa57fc1
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_temporary.js
@@ -0,0 +1,765 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const ID = "addon@tests.mozilla.org";
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+function waitForBootstrapEvent(expectedEvent, addonId) {
+ return new Promise(resolve => {
+ function listener(msg, { method, params, reason }) {
+ if (params.id === addonId && method === expectedEvent) {
+ resolve({ params, method, reason });
+ AddonTestUtils.off("bootstrap-method", listener);
+ } else {
+ info(`Ignoring bootstrap event: ${method} for ${params.id}`);
+ }
+ }
+ AddonTestUtils.on("bootstrap-method", listener);
+ });
+}
+
+async function checkEvent(promise, { reason, params }) {
+ let event = await promise;
+ info(`Checking bootstrap event ${event.method} for ${event.params.id}`);
+
+ equal(
+ event.reason,
+ reason,
+ `Expected bootstrap reason ${getReasonName(reason)} got ${getReasonName(
+ event.reason
+ )}`
+ );
+
+ for (let [param, value] of Object.entries(params)) {
+ equal(event.params[param], value, `Expected value for params.${param}`);
+ }
+}
+
+BootstrapMonitor.init();
+
+const XPIS = {};
+
+add_task(async function setup() {
+ for (let n of [1, 2]) {
+ XPIS[n] = await createTempWebExtensionFile({
+ manifest: {
+ name: "Test",
+ version: `${n}.0`,
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+ }
+});
+
+// Install a temporary add-on with no existing add-on present.
+// Restart and make sure it has gone away.
+add_task(async function test_new_temporary() {
+ await promiseStartupManager();
+
+ let extInstallCalled = false;
+ AddonManager.addInstallListener({
+ onExternalInstall: aInstall => {
+ Assert.equal(aInstall.id, ID);
+ Assert.equal(aInstall.version, "1.0");
+ extInstallCalled = true;
+ },
+ });
+
+ let installingCalled = false;
+ let installedCalled = false;
+ AddonManager.addAddonListener({
+ onInstalling: aInstall => {
+ Assert.equal(aInstall.id, ID);
+ Assert.equal(aInstall.version, "1.0");
+ installingCalled = true;
+ },
+ onInstalled: aInstall => {
+ Assert.equal(aInstall.id, ID);
+ Assert.equal(aInstall.version, "1.0");
+ installedCalled = true;
+ },
+ onInstallStarted: aInstall => {
+ do_throw("onInstallStarted called unexpectedly");
+ },
+ });
+
+ await AddonManager.installTemporaryAddon(XPIS[1]);
+
+ Assert.ok(extInstallCalled);
+ Assert.ok(installingCalled);
+ Assert.ok(installedCalled);
+
+ const install = BootstrapMonitor.checkInstalled(ID, "1.0");
+ equal(install.reason, BOOTSTRAP_REASONS.ADDON_INSTALL);
+
+ BootstrapMonitor.checkStarted(ID, "1.0");
+
+ let info = BootstrapMonitor.started.get(ID);
+ Assert.equal(info.reason, BOOTSTRAP_REASONS.ADDON_INSTALL);
+
+ let addon = await promiseAddonByID(ID);
+
+ checkAddon(ID, addon, {
+ version: "1.0",
+ name: "Test",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ temporarilyInstalled: true,
+ });
+
+ let onShutdown = waitForBootstrapEvent("shutdown", ID);
+ let onUninstall = waitForBootstrapEvent("uninstall", ID);
+
+ await promiseRestartManager();
+
+ let shutdown = await onShutdown;
+ equal(shutdown.reason, BOOTSTRAP_REASONS.ADDON_UNINSTALL);
+
+ let uninstall = await onUninstall;
+ equal(uninstall.reason, BOOTSTRAP_REASONS.ADDON_UNINSTALL);
+
+ BootstrapMonitor.checkNotInstalled(ID);
+ BootstrapMonitor.checkNotStarted(ID);
+
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon, null);
+
+ await promiseRestartManager();
+});
+
+// Install a temporary add-on over the top of an existing add-on.
+// Restart and make sure the existing add-on comes back.
+add_task(async function test_replace_temporary() {
+ await promiseInstallFile(XPIS[2]);
+ let addon = await promiseAddonByID(ID);
+
+ BootstrapMonitor.checkInstalled(ID, "2.0");
+ BootstrapMonitor.checkStarted(ID, "2.0");
+
+ checkAddon(ID, addon, {
+ version: "2.0",
+ name: "Test",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ temporarilyInstalled: false,
+ });
+
+ let tempdir = gTmpD.clone();
+
+ for (let newversion of ["1.0", "3.0"]) {
+ for (let packed of [false, true]) {
+ // ugh, file locking issues with xpis on windows
+ if (packed && AppConstants.platform == "win") {
+ continue;
+ }
+
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ name: "Test",
+ version: newversion,
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ let target = await AddonTestUtils.promiseWriteFilesToExtension(
+ tempdir.path,
+ ID,
+ files,
+ !packed
+ );
+
+ let onShutdown = waitForBootstrapEvent("shutdown", ID);
+ let onUpdate = waitForBootstrapEvent("update", ID);
+ let onStartup = waitForBootstrapEvent("startup", ID);
+
+ await AddonManager.installTemporaryAddon(target);
+
+ let reason =
+ Services.vc.compare(newversion, "2.0") < 0
+ ? BOOTSTRAP_REASONS.ADDON_DOWNGRADE
+ : BOOTSTRAP_REASONS.ADDON_UPGRADE;
+
+ await checkEvent(onShutdown, {
+ reason,
+ params: {
+ version: "2.0",
+ },
+ });
+
+ await checkEvent(onUpdate, {
+ reason,
+ params: {
+ version: newversion,
+ oldVersion: "2.0",
+ },
+ });
+
+ await checkEvent(onStartup, {
+ reason,
+ params: {
+ version: newversion,
+ oldVersion: "2.0",
+ },
+ });
+
+ addon = await promiseAddonByID(ID);
+
+ let signedState = packed
+ ? AddonManager.SIGNEDSTATE_PRIVILEGED
+ : AddonManager.SIGNEDSTATE_UNKNOWN;
+
+ // temporary add-on is installed and started
+ checkAddon(ID, addon, {
+ version: newversion,
+ name: "Test",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState,
+ temporarilyInstalled: true,
+ });
+
+ // Now restart, the temporary addon will go away which should
+ // be the opposite action (ie, if the temporary addon was an
+ // upgrade, then removing it is a downgrade and vice versa)
+ reason =
+ reason == BOOTSTRAP_REASONS.ADDON_UPGRADE
+ ? BOOTSTRAP_REASONS.ADDON_DOWNGRADE
+ : BOOTSTRAP_REASONS.ADDON_UPGRADE;
+
+ onShutdown = waitForBootstrapEvent("shutdown", ID);
+ onUpdate = waitForBootstrapEvent("update", ID);
+ onStartup = waitForBootstrapEvent("startup", ID);
+
+ await promiseRestartManager();
+
+ await checkEvent(onShutdown, {
+ reason,
+ params: {
+ version: newversion,
+ },
+ });
+
+ await checkEvent(onUpdate, {
+ reason,
+ params: {
+ version: "2.0",
+ oldVersion: newversion,
+ },
+ });
+
+ await checkEvent(onStartup, {
+ // We don't actually propagate the upgrade/downgrade reason across
+ // the browser restart when a temporary addon is removed. See
+ // bug 1359558 for detailed reasoning.
+ reason: BOOTSTRAP_REASONS.APP_STARTUP,
+ params: {
+ version: "2.0",
+ },
+ });
+
+ BootstrapMonitor.checkInstalled(ID, "2.0");
+ BootstrapMonitor.checkStarted(ID, "2.0");
+
+ addon = await promiseAddonByID(ID);
+
+ // existing add-on is back
+ checkAddon(ID, addon, {
+ version: "2.0",
+ name: "Test",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ temporarilyInstalled: false,
+ });
+
+ Services.obs.notifyObservers(target, "flush-cache-entry");
+ target.remove(true);
+ }
+ }
+
+ // remove original add-on
+ await addon.uninstall();
+
+ BootstrapMonitor.checkNotInstalled(ID);
+ BootstrapMonitor.checkNotStarted(ID);
+
+ await promiseRestartManager();
+});
+
+// Test that loading from the same path multiple times work
+add_task(async function test_samefile() {
+ // File locking issues on Windows, ugh
+ if (AppConstants.platform == "win") {
+ return;
+ }
+
+ // test that a webextension works
+ let webext = createTempWebExtensionFile({
+ manifest: {
+ version: "1.0",
+ name: "Test WebExtension 1 (temporary)",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ },
+ });
+
+ let addon = await AddonManager.installTemporaryAddon(webext);
+
+ // temporary add-on is installed and started
+ checkAddon(ID, addon, {
+ version: "1.0",
+ name: "Test WebExtension 1 (temporary)",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ temporarilyInstalled: true,
+ });
+
+ Services.obs.notifyObservers(webext, "flush-cache-entry");
+ webext.remove(false);
+ webext = createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ name: "Test WebExtension 1 (temporary)",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ },
+ });
+
+ addon = await AddonManager.installTemporaryAddon(webext);
+
+ // temporary add-on is installed and started
+ checkAddon(ID, addon, {
+ version: "2.0",
+ name: "Test WebExtension 1 (temporary)",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ isWebExtension: true,
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ temporarilyInstalled: true,
+ });
+
+ await addon.uninstall();
+});
+
+// Install a temporary add-on over the top of an existing add-on.
+// Uninstall it and make sure the existing add-on comes back.
+add_task(async function test_replace_permanent() {
+ await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ version: "1.0",
+ name: "Test Bootstrap 1",
+ },
+ });
+
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+
+ let unpacked_addon = gTmpD.clone();
+ unpacked_addon.append(ID);
+
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ version: "2.0",
+ name: "Test Bootstrap 1 (temporary)",
+ },
+ });
+ await AddonTestUtils.promiseWriteFilesToDir(unpacked_addon.path, files);
+
+ let extInstallCalled = false;
+ AddonManager.addInstallListener({
+ onExternalInstall: aInstall => {
+ Assert.equal(aInstall.id, ID);
+ Assert.equal(aInstall.version, "2.0");
+ extInstallCalled = true;
+ },
+ });
+
+ let installingCalled = false;
+ let installedCalled = false;
+ AddonManager.addAddonListener({
+ onInstalling: aInstall => {
+ Assert.equal(aInstall.id, ID);
+ if (!installingCalled) {
+ Assert.equal(aInstall.version, "2.0");
+ }
+ installingCalled = true;
+ },
+ onInstalled: aInstall => {
+ Assert.equal(aInstall.id, ID);
+ if (!installedCalled) {
+ Assert.equal(aInstall.version, "2.0");
+ }
+ installedCalled = true;
+ },
+ onInstallStarted: aInstall => {
+ do_throw("onInstallStarted called unexpectedly");
+ },
+ });
+
+ let addon = await AddonManager.installTemporaryAddon(unpacked_addon);
+
+ Assert.ok(extInstallCalled);
+ Assert.ok(installingCalled);
+ Assert.ok(installedCalled);
+
+ BootstrapMonitor.checkInstalled(ID);
+ BootstrapMonitor.checkStarted(ID);
+
+ // temporary add-on is installed and started
+ checkAddon(ID, addon, {
+ version: "2.0",
+ name: "Test Bootstrap 1 (temporary)",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_UNKNOWN,
+ temporarilyInstalled: true,
+ });
+
+ await addon.uninstall();
+
+ BootstrapMonitor.checkInstalled(ID);
+ BootstrapMonitor.checkStarted(ID);
+
+ addon = await promiseAddonByID(ID);
+
+ // existing add-on is back
+ checkAddon(ID, addon, {
+ version: "1.0",
+ name: "Test Bootstrap 1",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ temporarilyInstalled: false,
+ });
+
+ unpacked_addon.remove(true);
+ await addon.uninstall();
+
+ BootstrapMonitor.checkNotInstalled(ID);
+ BootstrapMonitor.checkNotStarted(ID);
+
+ await promiseRestartManager();
+});
+
+// Install a temporary add-on as a version upgrade over the top of an
+// existing temporary add-on.
+add_task(async function test_replace_temporary() {
+ const unpackedAddon = gTmpD.clone();
+ unpackedAddon.append(ID);
+
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ version: "1.0",
+ },
+ });
+ await AddonTestUtils.promiseWriteFilesToDir(unpackedAddon.path, files);
+
+ await AddonManager.installTemporaryAddon(unpackedAddon);
+
+ // Increment the version number, re-install it, and make sure it
+ // gets marked as an upgrade.
+ files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ version: "2.0",
+ },
+ });
+ await AddonTestUtils.promiseWriteFilesToDir(unpackedAddon.path, files);
+
+ const onShutdown = waitForBootstrapEvent("shutdown", ID);
+ const onUpdate = waitForBootstrapEvent("update", ID);
+ const onStartup = waitForBootstrapEvent("startup", ID);
+ await AddonManager.installTemporaryAddon(unpackedAddon);
+
+ await checkEvent(onShutdown, {
+ reason: BOOTSTRAP_REASONS.ADDON_UPGRADE,
+ params: {
+ version: "1.0",
+ },
+ });
+
+ await checkEvent(onUpdate, {
+ reason: BOOTSTRAP_REASONS.ADDON_UPGRADE,
+ params: {
+ version: "2.0",
+ oldVersion: "1.0",
+ },
+ });
+
+ await checkEvent(onStartup, {
+ reason: BOOTSTRAP_REASONS.ADDON_UPGRADE,
+ params: {
+ version: "2.0",
+ oldVersion: "1.0",
+ },
+ });
+
+ const addon = await promiseAddonByID(ID);
+ await addon.uninstall();
+
+ unpackedAddon.remove(true);
+ await promiseRestartManager();
+});
+
+// Install a temporary add-on as a version downgrade over the top of an
+// existing temporary add-on.
+add_task(async function test_replace_temporary_downgrade() {
+ const unpackedAddon = gTmpD.clone();
+ unpackedAddon.append(ID);
+
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ version: "1.0",
+ },
+ });
+ await AddonTestUtils.promiseWriteFilesToDir(unpackedAddon.path, files);
+
+ await AddonManager.installTemporaryAddon(unpackedAddon);
+
+ // Decrement the version number, re-install, and make sure
+ // it gets marked as a downgrade.
+ files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ version: "0.8",
+ },
+ });
+ await AddonTestUtils.promiseWriteFilesToDir(unpackedAddon.path, files);
+
+ const onShutdown = waitForBootstrapEvent("shutdown", ID);
+ const onUpdate = waitForBootstrapEvent("update", ID);
+ const onStartup = waitForBootstrapEvent("startup", ID);
+ await AddonManager.installTemporaryAddon(unpackedAddon);
+
+ await checkEvent(onShutdown, {
+ reason: BOOTSTRAP_REASONS.ADDON_DOWNGRADE,
+ params: {
+ version: "1.0",
+ },
+ });
+
+ await checkEvent(onUpdate, {
+ reason: BOOTSTRAP_REASONS.ADDON_DOWNGRADE,
+ params: {
+ oldVersion: "1.0",
+ version: "0.8",
+ },
+ });
+
+ await checkEvent(onStartup, {
+ reason: BOOTSTRAP_REASONS.ADDON_DOWNGRADE,
+ params: {
+ version: "0.8",
+ },
+ });
+
+ const addon = await promiseAddonByID(ID);
+ await addon.uninstall();
+
+ unpackedAddon.remove(true);
+ await promiseRestartManager();
+});
+
+// Installing a temporary add-on over an existing add-on with the same
+// version number should be installed as an upgrade.
+add_task(async function test_replace_same_version() {
+ const unpackedAddon = gTmpD.clone();
+ unpackedAddon.append(ID);
+
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ version: "1.0",
+ },
+ });
+ await AddonTestUtils.promiseWriteFilesToDir(unpackedAddon.path, files);
+
+ const onInitialInstall = waitForBootstrapEvent("install", ID);
+ const onInitialStartup = waitForBootstrapEvent("startup", ID);
+ await AddonManager.installTemporaryAddon(unpackedAddon);
+
+ await checkEvent(onInitialInstall, {
+ reason: BOOTSTRAP_REASONS.ADDON_INSTALL,
+ params: {
+ version: "1.0",
+ },
+ });
+
+ await checkEvent(onInitialStartup, {
+ reason: BOOTSTRAP_REASONS.ADDON_INSTALL,
+ params: {
+ version: "1.0",
+ },
+ });
+
+ let info = BootstrapMonitor.started.get(ID);
+ Assert.equal(info.reason, BOOTSTRAP_REASONS.ADDON_INSTALL);
+
+ // Install it again.
+ const onShutdown = waitForBootstrapEvent("shutdown", ID);
+ const onUpdate = waitForBootstrapEvent("update", ID);
+ const onStartup = waitForBootstrapEvent("startup", ID);
+ await AddonManager.installTemporaryAddon(unpackedAddon);
+
+ await checkEvent(onShutdown, {
+ reason: BOOTSTRAP_REASONS.ADDON_UPGRADE,
+ params: {
+ version: "1.0",
+ },
+ });
+
+ await checkEvent(onUpdate, {
+ reason: BOOTSTRAP_REASONS.ADDON_UPGRADE,
+ params: {
+ oldVersion: "1.0",
+ version: "1.0",
+ },
+ });
+
+ await checkEvent(onStartup, {
+ reason: BOOTSTRAP_REASONS.ADDON_UPGRADE,
+ params: {
+ version: "1.0",
+ },
+ });
+
+ const addon = await promiseAddonByID(ID);
+ await addon.uninstall();
+
+ unpackedAddon.remove(true);
+ await promiseRestartManager();
+});
+
+// Install a temporary add-on over the top of an existing disabled add-on.
+// After restart, the existing add-on should continue to be installed and disabled.
+add_task(async function test_replace_permanent_disabled() {
+ await promiseInstallFile(XPIS[1]);
+ let addon = await promiseAddonByID(ID);
+
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+
+ await addon.disable();
+
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkNotStarted(ID);
+
+ let unpacked_addon = gTmpD.clone();
+ unpacked_addon.append(ID);
+
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ name: "Test",
+ version: "2.0",
+ },
+ });
+ await AddonTestUtils.promiseWriteFilesToDir(unpacked_addon.path, files);
+
+ let extInstallCalled = false;
+ AddonManager.addInstallListener({
+ onExternalInstall: aInstall => {
+ Assert.equal(aInstall.id, ID);
+ Assert.equal(aInstall.version, "2.0");
+ extInstallCalled = true;
+ },
+ });
+
+ let tempAddon = await AddonManager.installTemporaryAddon(unpacked_addon);
+
+ Assert.ok(extInstallCalled);
+
+ BootstrapMonitor.checkInstalled(ID, "2.0");
+ BootstrapMonitor.checkStarted(ID);
+
+ // temporary add-on is installed and started
+ checkAddon(ID, tempAddon, {
+ version: "2.0",
+ name: "Test",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_UNKNOWN,
+ temporarilyInstalled: true,
+ });
+
+ await tempAddon.uninstall();
+ unpacked_addon.remove(true);
+
+ await addon.enable();
+ await new Promise(executeSoon);
+ addon = await promiseAddonByID(ID);
+
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID);
+
+ // existing add-on is back
+ checkAddon(ID, addon, {
+ version: "1.0",
+ name: "Test",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ temporarilyInstalled: false,
+ });
+
+ await addon.uninstall();
+
+ BootstrapMonitor.checkNotInstalled(ID);
+ BootstrapMonitor.checkNotStarted(ID);
+
+ await promiseRestartManager();
+});
+
+// Tests that XPIs with a .zip extension work when loaded temporarily.
+add_task(async function test_zip_extension() {
+ let xpi = createTempWebExtensionFile({
+ background() {
+ /* globals browser */
+ browser.test.sendMessage("started", "Hello.");
+ },
+ });
+ xpi.moveTo(null, xpi.leafName.replace(/\.xpi$/, ".zip"));
+
+ let extension = ExtensionTestUtils.loadExtensionXPI(xpi);
+ await extension.startup();
+
+ let msg = await extension.awaitMessage("started");
+ equal(msg, "Hello.", "Got expected background script message");
+
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_trash_directory.js b/toolkit/mozapps/extensions/test/xpcshell/test_trash_directory.js
new file mode 100644
index 0000000000..2c0540e399
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_trash_directory.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const { BasePromiseWorker } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseWorker.sys.mjs"
+);
+
+// Test that an open file inside the trash directory does not cause
+// unrelated installs to break (see bug 1180901 for more background).
+add_task(async function test() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+ await promiseStartupManager();
+
+ let profileDir = PathUtils.profileDir;
+ let trashDir = PathUtils.join(profileDir, "extensions", "trash");
+ let testFile = PathUtils.join(trashDir, "test.txt");
+
+ await IOUtils.makeDirectory(trashDir);
+
+ let trashDirExists = await IOUtils.exists(trashDir);
+ ok(trashDirExists, "trash directory should have been created");
+
+ await IOUtils.writeUTF8(testFile, "");
+
+ // Use a worker to keep the testFile open.
+ const worker = new BasePromiseWorker(
+ "resource://test/data/test_trash_directory.worker.js"
+ );
+ await worker.post("open", [testFile]);
+
+ let fileExists = await IOUtils.exists(testFile);
+ ok(fileExists, "test.txt should have been created in " + trashDir);
+
+ await promiseInstallWebExtension({});
+
+ // The testFile should still exist at this point because we have not
+ // yet closed the file handle and as a result, Windows cannot remove it.
+ fileExists = await IOUtils.exists(testFile);
+ ok(fileExists, "test.txt should still exist");
+
+ // Cleanup
+ await promiseShutdownManager();
+ await worker.post("close", []);
+ await IOUtils.remove(testFile);
+ await IOUtils.remove(trashDir, { recursive: true });
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_types.js b/toolkit/mozapps/extensions/test/xpcshell/test_types.js
new file mode 100644
index 0000000000..b9a0d3987e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_types.js
@@ -0,0 +1,117 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that custom types can be defined and undefined
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+add_task(async function setup() {
+ await promiseStartupManager();
+});
+
+add_task(async function test_new_addonType() {
+ Assert.equal(false, AddonManager.hasAddonType("test"));
+
+ // The dumbest provider possible
+ const provider = {};
+
+ AddonManagerPrivate.registerProvider(provider, ["test"]);
+
+ Assert.equal(true, AddonManager.hasAddonType("test"));
+ Assert.equal(false, AddonManager.hasAddonType("t$e%st"));
+ Assert.equal(false, AddonManager.hasAddonType(null));
+ Assert.equal(false, AddonManager.hasAddonType(undefined));
+
+ AddonManagerPrivate.unregisterProvider(provider);
+
+ Assert.equal(false, AddonManager.hasAddonType("test"));
+});
+
+add_task(async function test_bad_addonType() {
+ const provider = {};
+ Assert.throws(
+ () => AddonManagerPrivate.registerProvider(provider, /* addonTypes =*/ {}),
+ /aTypes must be an array or null/
+ );
+
+ Assert.throws(
+ () => AddonManagerPrivate.registerProvider(provider, new Set()),
+ /aTypes must be an array or null/
+ );
+});
+
+add_task(async function test_addonTypes_should_be_immutable() {
+ const provider = {};
+ const addonTypes = [];
+
+ addonTypes.push("test");
+ AddonManagerPrivate.registerProvider(provider, addonTypes);
+ addonTypes.pop();
+ addonTypes.push("test_added");
+ // Modifications to addonTypes should not affect AddonManager.
+ Assert.equal(true, AddonManager.hasAddonType("test"));
+ Assert.equal(false, AddonManager.hasAddonType("test_added"));
+ AddonManagerPrivate.unregisterProvider(provider);
+
+ AddonManagerPrivate.registerProvider(provider, addonTypes);
+ // After re-registering the provider, the type change should have been processed.
+ Assert.equal(false, AddonManager.hasAddonType("test"));
+ Assert.equal(true, AddonManager.hasAddonType("test_added"));
+ AddonManagerPrivate.unregisterProvider(provider);
+});
+
+add_task(async function test_missing_addonType() {
+ const dummyAddon = {
+ id: "some dummy addon from provider without .addonTypes property",
+ };
+ const provider = {
+ // addonTypes Set is missing. This only happens in unit tests, but let's
+ // verify that the implementation behaves reasonably.
+ // A provider without an explicitly registered type may still return an
+ // entry when getAddonsByTypes is called.
+ async getAddonsByTypes(types) {
+ Assert.equal(null, types);
+ return [dummyAddon];
+ },
+ };
+
+ AddonManagerPrivate.registerProvider(provider); // addonTypes not set.
+ Assert.equal(false, AddonManager.hasAddonType("test"));
+ Assert.equal(false, AddonManager.hasAddonType(null));
+ Assert.equal(false, AddonManager.hasAddonType(undefined));
+
+ const addons = await AddonManager.getAddonsByTypes(null);
+ Assert.equal(1, addons.length);
+ Assert.equal(dummyAddon, addons[0]);
+
+ AddonManagerPrivate.unregisterProvider(provider);
+});
+
+add_task(async function test_getAddonTypesByProvider() {
+ let defaultTypes = AddonManagerPrivate.getAddonTypesByProvider("XPIProvider");
+ Assert.ok(defaultTypes.includes("extension"), `extension in ${defaultTypes}`);
+ Assert.throws(
+ () => AddonManagerPrivate.getAddonTypesByProvider(),
+ /No addonTypes found for provider: undefined/
+ );
+ Assert.throws(
+ () => AddonManagerPrivate.getAddonTypesByProvider("MaybeExistent"),
+ /No addonTypes found for provider: MaybeExistent/
+ );
+
+ const provider = { name: "MaybeExistent" };
+ AddonManagerPrivate.registerProvider(provider, ["test"]);
+ Assert.deepEqual(
+ AddonManagerPrivate.getAddonTypesByProvider("MaybeExistent"),
+ ["test"],
+ "Newly registered type returned by getAddonTypesByProvider"
+ );
+
+ AddonManagerPrivate.unregisterProvider(provider);
+
+ Assert.throws(
+ () => AddonManagerPrivate.getAddonTypesByProvider("MaybeExistent"),
+ /No addonTypes found for provider: MaybeExistent/
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_undouninstall.js b/toolkit/mozapps/extensions/test/xpcshell/test_undouninstall.js
new file mode 100644
index 0000000000..af07fb1e34
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_undouninstall.js
@@ -0,0 +1,584 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that forcing undo for uninstall works
+
+const APP_STARTUP = 1;
+const APP_SHUTDOWN = 2;
+const ADDON_DISABLE = 4;
+const ADDON_INSTALL = 5;
+const ADDON_UNINSTALL = 6;
+const ADDON_UPGRADE = 7;
+
+const ID = "undouninstall1@tests.mozilla.org";
+const INCOMPAT_ID = "incompatible@tests.mozilla.org";
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const ADDONS = {
+ test_undoincompatible: {
+ manifest: {
+ name: "Incompatible Addon",
+ browser_specific_settings: {
+ gecko: {
+ id: "incompatible@tests.mozilla.org",
+ strict_min_version: "2",
+ strict_max_version: "2",
+ },
+ },
+ },
+ },
+ test_undouninstall1: {
+ manifest: {
+ name: "Test Bootstrap 1",
+ browser_specific_settings: {
+ gecko: {
+ id: "undouninstall1@tests.mozilla.org",
+ },
+ },
+ },
+ },
+};
+
+const XPIS = {};
+
+BootstrapMonitor.init();
+
+function getStartupReason(id) {
+ let info = BootstrapMonitor.started.get(id);
+ return info ? info.reason : undefined;
+}
+
+function getShutdownReason(id) {
+ let info = BootstrapMonitor.stopped.get(id);
+ return info ? info.reason : undefined;
+}
+
+function getInstallReason(id) {
+ let info = BootstrapMonitor.installed.get(id);
+ return info ? info.reason : undefined;
+}
+
+function getUninstallReason(id) {
+ let info = BootstrapMonitor.uninstalled.get(id);
+ return info ? info.reason : undefined;
+}
+
+function getShutdownNewVersion(id) {
+ let info = BootstrapMonitor.stopped.get(id);
+ return info ? info.params.newVersion : undefined;
+}
+
+// Sets up the profile by installing an add-on.
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ await promiseStartupManager();
+ registerCleanupFunction(promiseShutdownManager);
+
+ for (let [name, files] of Object.entries(ADDONS)) {
+ XPIS[name] = await AddonTestUtils.createTempWebExtensionFile(files);
+ }
+});
+
+// Tests that an enabled restartless add-on can be uninstalled and goes away
+// when the uninstall is committed
+add_task(async function uninstallRestartless() {
+ await promiseInstallFile(XPIS.test_undouninstall1);
+
+ let a1 = await promiseAddonByID(ID);
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_INSTALL);
+ Assert.equal(getStartupReason(ID), ADDON_INSTALL);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await a1.uninstall(true);
+
+ a1 = await promiseAddonByID(ID);
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID);
+ BootstrapMonitor.checkNotStarted(ID);
+ Assert.equal(getShutdownReason(ID), ADDON_UNINSTALL);
+ Assert.ok(hasFlag(AddonManager.PENDING_UNINSTALL, a1.pendingOperations));
+ Assert.ok(!a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await a1.uninstall();
+
+ a1 = await promiseAddonByID(ID);
+
+ Assert.equal(a1, null);
+ BootstrapMonitor.checkNotStarted(ID);
+});
+
+// Tests that an enabled restartless add-on can be uninstalled and then cancelled
+add_task(async function cancelUninstallOfRestartless() {
+ await promiseInstallFile(XPIS.test_undouninstall1);
+ let a1 = await promiseAddonByID(ID);
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_INSTALL);
+ Assert.equal(getStartupReason(ID), ADDON_INSTALL);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [{ event: "onUninstalling" }],
+ },
+ },
+ () => a1.uninstall(true)
+ );
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID);
+ BootstrapMonitor.checkNotStarted(ID);
+ Assert.equal(getShutdownReason(ID), ADDON_UNINSTALL);
+ Assert.ok(hasFlag(AddonManager.PENDING_UNINSTALL, a1.pendingOperations));
+ Assert.ok(!a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ let promises = [
+ promiseAddonEvent("onOperationCancelled"),
+ promiseWebExtensionStartup(ID),
+ ];
+ a1.cancelUninstall();
+ await Promise.all(promises);
+
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getStartupReason(ID), ADDON_INSTALL);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await promiseShutdownManager();
+
+ Assert.equal(getShutdownReason(ID), APP_SHUTDOWN);
+ Assert.equal(getShutdownNewVersion(ID), undefined);
+
+ await promiseStartupManager();
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getStartupReason(ID), APP_STARTUP);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await a1.uninstall();
+});
+
+// Tests that reinstalling an enabled restartless add-on waiting to be
+// uninstalled aborts the uninstall and leaves the add-on enabled
+add_task(async function reinstallAddonAwaitingUninstall() {
+ await promiseInstallFile(XPIS.test_undouninstall1);
+
+ let a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_INSTALL);
+ Assert.equal(getStartupReason(ID), ADDON_INSTALL);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [{ event: "onUninstalling" }],
+ },
+ },
+ () => a1.uninstall(true)
+ );
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID);
+ BootstrapMonitor.checkNotStarted(ID);
+ Assert.equal(getShutdownReason(ID), ADDON_UNINSTALL);
+ Assert.ok(hasFlag(AddonManager.PENDING_UNINSTALL, a1.pendingOperations));
+ Assert.ok(!a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onNewInstall" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => promiseInstallFile(XPIS.test_undouninstall1)
+ );
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_UPGRADE);
+ Assert.equal(getStartupReason(ID), ADDON_UPGRADE);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await promiseShutdownManager();
+
+ Assert.equal(getShutdownReason(ID), APP_SHUTDOWN);
+
+ await promiseStartupManager();
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getStartupReason(ID), APP_STARTUP);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await a1.uninstall();
+});
+
+// Tests that a disabled restartless add-on can be uninstalled and goes away
+// when the uninstall is committed
+add_task(async function uninstallDisabledRestartless() {
+ await promiseInstallFile(XPIS.test_undouninstall1);
+
+ let a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_INSTALL);
+ Assert.equal(getStartupReason(ID), ADDON_INSTALL);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await a1.disable();
+ BootstrapMonitor.checkNotStarted(ID);
+ Assert.equal(getShutdownReason(ID), ADDON_DISABLE);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [{ event: "onUninstalling" }],
+ },
+ },
+ () => a1.uninstall(true)
+ );
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkNotStarted(ID);
+ Assert.ok(hasFlag(AddonManager.PENDING_UNINSTALL, a1.pendingOperations));
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ // commit the uninstall
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [{ event: "onUninstalled" }],
+ },
+ },
+ () => a1.uninstall()
+ );
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.equal(a1, null);
+ BootstrapMonitor.checkNotStarted(ID);
+ BootstrapMonitor.checkNotInstalled(ID);
+ Assert.equal(getUninstallReason(ID), ADDON_UNINSTALL);
+});
+
+// Tests that a disabled restartless add-on can be uninstalled and then cancelled
+add_task(async function cancelUninstallDisabledRestartless() {
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onNewInstall" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => promiseInstallFile(XPIS.test_undouninstall1)
+ );
+
+ let a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_INSTALL);
+ Assert.equal(getStartupReason(ID), ADDON_INSTALL);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [
+ { event: "onDisabling" },
+ { event: "onDisabled" },
+ ],
+ },
+ },
+ () => a1.disable()
+ );
+
+ BootstrapMonitor.checkNotStarted(ID);
+ Assert.equal(getShutdownReason(ID), ADDON_DISABLE);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [{ event: "onUninstalling" }],
+ },
+ },
+ () => a1.uninstall(true)
+ );
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkNotStarted(ID);
+ BootstrapMonitor.checkInstalled(ID);
+ Assert.ok(hasFlag(AddonManager.PENDING_UNINSTALL, a1.pendingOperations));
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [{ event: "onOperationCancelled" }],
+ },
+ },
+ async () => {
+ a1.cancelUninstall();
+ }
+ );
+
+ BootstrapMonitor.checkNotStarted(ID);
+ BootstrapMonitor.checkInstalled(ID);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await promiseRestartManager();
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkNotStarted(ID);
+ BootstrapMonitor.checkInstalled(ID);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await a1.uninstall();
+});
+
+// Tests that reinstalling a disabled restartless add-on waiting to be
+// uninstalled aborts the uninstall and leaves the add-on disabled
+add_task(async function reinstallDisabledAddonAwaitingUninstall() {
+ await promiseInstallFile(XPIS.test_undouninstall1);
+
+ let a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_INSTALL);
+ Assert.equal(getStartupReason(ID), ADDON_INSTALL);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await a1.disable();
+ BootstrapMonitor.checkNotStarted(ID);
+ Assert.equal(getShutdownReason(ID), ADDON_DISABLE);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [{ event: "onUninstalling" }],
+ },
+ },
+ () => a1.uninstall(true)
+ );
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkNotStarted(ID);
+ Assert.ok(hasFlag(AddonManager.PENDING_UNINSTALL, a1.pendingOperations));
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onNewInstall" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => promiseInstallFile(XPIS.test_undouninstall1)
+ );
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkNotStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_UPGRADE);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await promiseRestartManager();
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkNotStarted(ID, "1.0");
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await a1.uninstall();
+});
+
+// Test that uninstalling a temporary addon can be canceled
+add_task(async function cancelUninstallTemporary() {
+ await AddonManager.installTemporaryAddon(XPIS.test_undouninstall1);
+
+ let a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_INSTALL);
+ Assert.equal(getStartupReason(ID), ADDON_INSTALL);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [{ event: "onUninstalling" }],
+ },
+ },
+ () => a1.uninstall(true)
+ );
+
+ BootstrapMonitor.checkNotStarted(ID, "1.0");
+ Assert.ok(hasFlag(AddonManager.PENDING_UNINSTALL, a1.pendingOperations));
+
+ let promises = [
+ promiseAddonEvent("onOperationCancelled"),
+ promiseWebExtensionStartup("undouninstall1@tests.mozilla.org"),
+ ];
+ a1.cancelUninstall();
+ await Promise.all(promises);
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(a1.pendingOperations, 0);
+
+ await promiseRestartManager();
+});
+
+// Tests that cancelling the uninstall of an incompatible restartless addon
+// does not start the addon
+add_task(async function cancelUninstallIncompatibleRestartless() {
+ await promiseInstallFile(XPIS.test_undoincompatible);
+
+ let a1 = await promiseAddonByID(INCOMPAT_ID);
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkNotStarted(INCOMPAT_ID);
+ Assert.ok(!a1.isActive);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "incompatible@tests.mozilla.org": [{ event: "onUninstalling" }],
+ },
+ },
+ () => a1.uninstall(true)
+ );
+
+ a1 = await promiseAddonByID(INCOMPAT_ID);
+ Assert.notEqual(a1, null);
+ Assert.ok(hasFlag(AddonManager.PENDING_UNINSTALL, a1.pendingOperations));
+ Assert.ok(!a1.isActive);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "incompatible@tests.mozilla.org": [{ event: "onOperationCancelled" }],
+ },
+ },
+ async () => {
+ a1.cancelUninstall();
+ }
+ );
+
+ a1 = await promiseAddonByID(INCOMPAT_ID);
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkNotStarted(INCOMPAT_ID);
+ Assert.equal(a1.pendingOperations, 0);
+ Assert.ok(!a1.isActive);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update.js b/toolkit/mozapps/extensions/test/xpcshell/test_update.js
new file mode 100644
index 0000000000..7f750c6104
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update.js
@@ -0,0 +1,834 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that add-on update checks work
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+// This test uses add-on versions that follow the toolkit version but we
+// started to encourage the use of a simpler format in Bug 1793925. We disable
+// the pref below to avoid install errors.
+Services.prefs.setBoolPref(
+ "extensions.webextensions.warnings-as-errors",
+ false
+);
+
+const updateFile = "test_update.json";
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const ADDONS = {
+ test_update: {
+ id: "addon1@tests.mozilla.org",
+ version: "2.0",
+ name: "Test 1",
+ },
+ test_update8: {
+ id: "addon8@tests.mozilla.org",
+ version: "2.0",
+ name: "Test 8",
+ },
+ test_update12: {
+ id: "addon12@tests.mozilla.org",
+ version: "2.0",
+ name: "Test 12",
+ },
+ test_install2_1: {
+ id: "addon2@tests.mozilla.org",
+ version: "2.0",
+ name: "Real Test 2",
+ },
+ test_install2_2: {
+ id: "addon2@tests.mozilla.org",
+ version: "3.0",
+ name: "Real Test 3",
+ },
+};
+
+var testserver = createHttpServer({ hosts: ["example.com"] });
+testserver.registerDirectory("/data/", do_get_file("data"));
+
+const XPIS = {};
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+ Services.locale.requestedLocales = ["fr-FR"];
+
+ for (let [name, info] of Object.entries(ADDONS)) {
+ XPIS[name] = createTempWebExtensionFile({
+ manifest: {
+ name: info.name,
+ version: info.version,
+ browser_specific_settings: { gecko: { id: info.id } },
+ },
+ });
+ testserver.registerFile(`/addons/${name}.xpi`, XPIS[name]);
+ }
+
+ AddonTestUtils.updateReason = AddonManager.UPDATE_WHEN_USER_REQUESTED;
+
+ await promiseStartupManager();
+});
+
+// Verify that an update is available and can be installed.
+add_task(async function test_apply_update() {
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 1",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon1@tests.mozilla.org",
+ update_url: `http://example.com/data/${updateFile}`,
+ },
+ },
+ },
+ });
+
+ let a1 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ notEqual(a1, null);
+ equal(a1.version, "1.0");
+ equal(a1.applyBackgroundUpdates, AddonManager.AUTOUPDATE_DEFAULT);
+ equal(a1.releaseNotesURI, null);
+ notEqual(a1.syncGUID, null);
+
+ let originalSyncGUID = a1.syncGUID;
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ "addon1@tests.mozilla.org": [
+ {
+ event: "onPropertyChanged",
+ properties: ["applyBackgroundUpdates"],
+ },
+ ],
+ },
+ },
+ async () => {
+ a1.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DISABLE;
+ }
+ );
+
+ a1.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DISABLE;
+
+ let install;
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ installEvents: [{ event: "onNewInstall" }],
+ },
+ async () => {
+ ({ updateAvailable: install } =
+ await AddonTestUtils.promiseFindAddonUpdates(a1));
+ }
+ );
+
+ let installs = await AddonManager.getAllInstalls();
+ equal(installs.length, 1);
+ equal(installs[0], install);
+
+ equal(install.name, a1.name);
+ equal(install.version, "2.0");
+ equal(install.state, AddonManager.STATE_AVAILABLE);
+ equal(install.existingAddon, a1);
+ equal(install.releaseNotesURI.spec, "http://example.com/updateInfo.xhtml");
+
+ // Verify that another update check returns the same AddonInstall
+ let { updateAvailable: install2 } =
+ await AddonTestUtils.promiseFindAddonUpdates(a1);
+
+ installs = await AddonManager.getAllInstalls();
+ equal(installs.length, 1);
+ equal(installs[0], install);
+ equal(install2, install);
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded", returnValue: false },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ equal(install.state, AddonManager.STATE_DOWNLOADED);
+
+ // Continue installing the update.
+ // Verify that another update check returns no new update
+ let { updateAvailable } = await AddonTestUtils.promiseFindAddonUpdates(
+ install.existingAddon
+ );
+
+ ok(
+ !updateAvailable,
+ "Should find no available updates when one is already downloading"
+ );
+
+ installs = await AddonManager.getAllInstalls();
+ equal(installs.length, 1);
+ equal(installs[0], install);
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ "addon1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ await AddonTestUtils.loadAddonsList(true);
+
+ // Grab the current time so we can check the mtime of the add-on below
+ // without worrying too much about how long other tests take.
+ let startupTime = Date.now();
+
+ ok(isExtensionInBootstrappedList(profileDir, "addon1@tests.mozilla.org"));
+
+ a1 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ notEqual(a1, null);
+ equal(a1.version, "2.0");
+ ok(isExtensionInBootstrappedList(profileDir, a1.id));
+ equal(a1.applyBackgroundUpdates, AddonManager.AUTOUPDATE_DISABLE);
+ equal(a1.releaseNotesURI.spec, "http://example.com/updateInfo.xhtml");
+ notEqual(a1.syncGUID, null);
+ equal(originalSyncGUID, a1.syncGUID);
+
+ // Make sure that the extension lastModifiedTime was updated.
+ let testFile = getAddonFile(a1);
+ let difference = testFile.lastModifiedTime - startupTime;
+ ok(Math.abs(difference) < MAX_TIME_DIFFERENCE);
+
+ await a1.uninstall();
+});
+
+// Check that an update check finds compatibility updates and applies them
+add_task(async function test_compat_update() {
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 2",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon2@tests.mozilla.org",
+ update_url: "http://example.com/data/" + updateFile,
+ strict_max_version: "0",
+ },
+ },
+ },
+ });
+
+ let a2 = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ notEqual(a2, null);
+ ok(a2.isActive);
+ ok(a2.isCompatible);
+ ok(!a2.appDisabled);
+ ok(a2.isCompatibleWith("0", "0"));
+
+ let result = await AddonTestUtils.promiseFindAddonUpdates(a2);
+ ok(result.compatibilityUpdate, "Should have seen a compatibility update");
+ ok(!result.updateAvailable, "Should not have seen a version update");
+
+ ok(a2.isCompatible);
+ ok(!a2.appDisabled);
+ ok(a2.isActive);
+
+ await promiseRestartManager();
+
+ a2 = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ notEqual(a2, null);
+ ok(a2.isActive);
+ ok(a2.isCompatible);
+ ok(!a2.appDisabled);
+ await a2.uninstall();
+});
+
+// Checks that we see no compatibility information when there is none.
+add_task(async function test_no_compat() {
+ gAppInfo.platformVersion = "5";
+ await promiseRestartManager("5");
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 3",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon3@tests.mozilla.org",
+ update_url: `http://example.com/data/${updateFile}`,
+ strict_min_version: "5",
+ },
+ },
+ },
+ });
+
+ gAppInfo.platformVersion = "1";
+ await promiseRestartManager("1");
+
+ let a3 = await AddonManager.getAddonByID("addon3@tests.mozilla.org");
+ notEqual(a3, null);
+ ok(!a3.isActive);
+ ok(!a3.isCompatible);
+ ok(a3.appDisabled);
+ ok(a3.isCompatibleWith("5", "5"));
+ ok(!a3.isCompatibleWith("2", "2"));
+
+ let result = await AddonTestUtils.promiseFindAddonUpdates(a3);
+ ok(
+ !result.compatibilityUpdate,
+ "Should not have seen a compatibility update"
+ );
+ ok(!result.updateAvailable, "Should not have seen a version update");
+});
+
+// Checks that compatibility info for future apps are detected but don't make
+// the item compatibile.
+add_task(async function test_future_compat() {
+ let a3 = await AddonManager.getAddonByID("addon3@tests.mozilla.org");
+ notEqual(a3, null);
+ ok(!a3.isActive);
+ ok(!a3.isCompatible);
+ ok(a3.appDisabled);
+ ok(a3.isCompatibleWith("5", "5"));
+ ok(!a3.isCompatibleWith("2", "2"));
+
+ let result = await AddonTestUtils.promiseFindAddonUpdates(
+ a3,
+ undefined,
+ "3.0",
+ "3.0"
+ );
+ ok(result.compatibilityUpdate, "Should have seen a compatibility update");
+ ok(!result.updateAvailable, "Should not have seen a version update");
+
+ ok(!a3.isActive);
+ ok(!a3.isCompatible);
+ ok(a3.appDisabled);
+
+ await promiseRestartManager();
+
+ a3 = await AddonManager.getAddonByID("addon3@tests.mozilla.org");
+ notEqual(a3, null);
+ ok(!a3.isActive);
+ ok(!a3.isCompatible);
+ ok(a3.appDisabled);
+
+ await a3.uninstall();
+});
+
+// Test that background update checks work
+add_task(async function test_background_update() {
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 1",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon1@tests.mozilla.org",
+ update_url: `http://example.com/data/${updateFile}`,
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ },
+ });
+
+ function checkInstall(install) {
+ notEqual(install.existingAddon, null);
+ equal(install.existingAddon.id, "addon1@tests.mozilla.org");
+ }
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ "addon1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onNewInstall" },
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded", callback: checkInstall },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => {
+ AddonManagerPrivate.backgroundUpdateCheck();
+ }
+ );
+
+ let a1 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ notEqual(a1, null);
+ equal(a1.version, "2.0");
+ equal(a1.releaseNotesURI.spec, "http://example.com/updateInfo.xhtml");
+
+ await a1.uninstall();
+});
+
+const STATE_BLOCKED = Ci.nsIBlocklistService.STATE_BLOCKED;
+
+const PARAMS =
+ "?" +
+ [
+ "req_version=%REQ_VERSION%",
+ "item_id=%ITEM_ID%",
+ "item_version=%ITEM_VERSION%",
+ "item_maxappversion=%ITEM_MAXAPPVERSION%",
+ "item_status=%ITEM_STATUS%",
+ "app_id=%APP_ID%",
+ "app_version=%APP_VERSION%",
+ "current_app_version=%CURRENT_APP_VERSION%",
+ "app_os=%APP_OS%",
+ "app_abi=%APP_ABI%",
+ "app_locale=%APP_LOCALE%",
+ "update_type=%UPDATE_TYPE%",
+ ].join("&");
+
+const PARAM_ADDONS = {
+ "addon1@tests.mozilla.org": {
+ manifest: {
+ name: "Test Addon 1",
+ version: "5.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon1@tests.mozilla.org",
+ update_url: `http://example.com/data/param_test.json${PARAMS}`,
+ strict_min_version: "1",
+ strict_max_version: "2",
+ },
+ },
+ },
+ params: {
+ item_version: "5.0",
+ item_maxappversion: "2",
+ item_status: "userEnabled",
+ app_version: "1",
+ update_type: "97",
+ },
+ updateType: [AddonManager.UPDATE_WHEN_USER_REQUESTED],
+ },
+
+ "addon2@tests.mozilla.org": {
+ manifest: {
+ name: "Test Addon 2",
+ version: "67.0.5b1",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon2@tests.mozilla.org",
+ update_url: "http://example.com/data/param_test.json" + PARAMS,
+ strict_min_version: "0",
+ strict_max_version: "3",
+ },
+ },
+ },
+ initialState: {
+ userDisabled: true,
+ },
+ params: {
+ item_version: "67.0.5b1",
+ item_maxappversion: "3",
+ item_status: "userDisabled",
+ app_version: "1",
+ update_type: "49",
+ },
+ updateType: [AddonManager.UPDATE_WHEN_ADDON_INSTALLED],
+ compatOnly: true,
+ },
+
+ "addon3@tests.mozilla.org": {
+ manifest: {
+ name: "Test Addon 3",
+ version: "1.3+",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon3@tests.mozilla.org",
+ update_url: `http://example.com/data/param_test.json${PARAMS}`,
+ },
+ },
+ },
+ params: {
+ item_version: "1.3+",
+ item_status: "userEnabled",
+ app_version: "1",
+ update_type: "112",
+ },
+ updateType: [AddonManager.UPDATE_WHEN_PERIODIC_UPDATE],
+ },
+
+ "addon4@tests.mozilla.org": {
+ manifest: {
+ name: "Test Addon 4",
+ version: "0.5ab6",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon4@tests.mozilla.org",
+ update_url: `http://example.com/data/param_test.json${PARAMS}`,
+ strict_min_version: "1",
+ strict_max_version: "5",
+ },
+ },
+ },
+ params: {
+ item_version: "0.5ab6",
+ item_maxappversion: "5",
+ item_status: "userEnabled",
+ app_version: "2",
+ update_type: "98",
+ },
+ updateType: [AddonManager.UPDATE_WHEN_NEW_APP_DETECTED, "2"],
+ },
+
+ "addon5@tests.mozilla.org": {
+ manifest: {
+ name: "Test Addon 5",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon5@tests.mozilla.org",
+ update_url: `http://example.com/data/param_test.json${PARAMS}`,
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ },
+ params: {
+ item_version: "1.0",
+ item_maxappversion: "1",
+ item_status: "userEnabled",
+ app_version: "1",
+ update_type: "35",
+ },
+ updateType: [AddonManager.UPDATE_WHEN_NEW_APP_INSTALLED],
+ compatOnly: true,
+ },
+
+ "addon6@tests.mozilla.org": {
+ manifest: {
+ name: "Test Addon 6",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon6@tests.mozilla.org",
+ update_url: `http://example.com/data/param_test.json${PARAMS}`,
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ },
+ params: {
+ item_version: "1.0",
+ item_maxappversion: "1",
+ item_status: "userEnabled",
+ app_version: "1",
+ update_type: "99",
+ },
+ updateType: [AddonManager.UPDATE_WHEN_NEW_APP_INSTALLED],
+ },
+
+ "blocklist2@tests.mozilla.org": {
+ manifest: {
+ name: "Test Addon 1",
+ version: "5.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "blocklist2@tests.mozilla.org",
+ update_url: `http://example.com/data/param_test.json${PARAMS}`,
+ strict_min_version: "1",
+ strict_max_version: "2",
+ },
+ },
+ },
+ params: {
+ item_version: "5.0",
+ item_maxappversion: "2",
+ item_status: "userEnabled,blocklisted",
+ app_version: "1",
+ update_type: "97",
+ },
+ updateType: [AddonManager.UPDATE_WHEN_USER_REQUESTED],
+ blocklistState: STATE_BLOCKED,
+ },
+};
+
+const PARAM_IDS = Object.keys(PARAM_ADDONS);
+
+// Verify the parameter escaping in update urls.
+add_task(async function test_params() {
+ let blocked = [];
+ for (let [id, options] of Object.entries(PARAM_ADDONS)) {
+ if (options.blocklistState == STATE_BLOCKED) {
+ blocked.push(`${id}:${options.manifest.version}`);
+ }
+ }
+ let extensionsMLBF = [{ stash: { blocked, unblocked: [] }, stash_time: 0 }];
+ await AddonTestUtils.loadBlocklistRawData({ extensionsMLBF });
+
+ for (let [id, options] of Object.entries(PARAM_ADDONS)) {
+ await promiseInstallWebExtension({ manifest: options.manifest });
+
+ if (options.initialState) {
+ let addon = await AddonManager.getAddonByID(id);
+ await setInitialState(addon, options.initialState);
+ }
+ }
+
+ let resultsPromise = new Promise(resolve => {
+ let results = new Map();
+
+ testserver.registerPathHandler(
+ "/data/param_test.json",
+ function (request, response) {
+ let params = new URLSearchParams(request.queryString);
+ let itemId = params.get("item_id");
+ ok(
+ !results.has(itemId),
+ `Should not see a duplicate request for item ${itemId}`
+ );
+
+ results.set(itemId, params);
+
+ if (results.size === PARAM_IDS.length) {
+ resolve(results);
+ }
+
+ request.setStatusLine(null, 500, "Server Error");
+ }
+ );
+ });
+
+ let addons = await getAddons(PARAM_IDS);
+ for (let [id, options] of Object.entries(PARAM_ADDONS)) {
+ // Having an onUpdateAvailable callback in the listener automagically adds
+ // UPDATE_TYPE_NEWVERSION to the update type flags in the request.
+ let listener = options.compatOnly ? {} : { onUpdateAvailable() {} };
+
+ addons.get(id).findUpdates(listener, ...options.updateType);
+ }
+
+ let baseParams = {
+ req_version: "2",
+ app_id: "xpcshell@tests.mozilla.org",
+ current_app_version: "1",
+ app_os: "XPCShell",
+ app_abi: "noarch-spidermonkey",
+ app_locale: "fr-FR",
+ };
+
+ let results = await resultsPromise;
+ for (let [id, options] of Object.entries(PARAM_ADDONS)) {
+ info(`Checking update params for ${id}`);
+
+ let expected = Object.assign({}, baseParams, options.params);
+ let params = results.get(id);
+
+ for (let [prop, value] of Object.entries(expected)) {
+ equal(params.get(prop), value, `Expected value for ${prop}`);
+ }
+ }
+
+ for (let [, addon] of await getAddons(PARAM_IDS)) {
+ await addon.uninstall();
+ }
+});
+
+// Tests that if a manifest claims compatibility then the add-on will be
+// seen as compatible regardless of what the update payload says.
+add_task(async function test_manifest_compat() {
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 1",
+ version: "5.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon4@tests.mozilla.org",
+ update_url: `http://example.com/data/${updateFile}`,
+ strict_min_version: "0",
+ strict_max_version: "1",
+ },
+ },
+ },
+ });
+
+ let a4 = await AddonManager.getAddonByID("addon4@tests.mozilla.org");
+ ok(a4.isActive, "addon4 is active");
+ ok(a4.isCompatible, "addon4 is compatible");
+
+ // Test that a normal update check won't decrease a targetApplication's
+ // maxVersion but an update check for a new application will.
+ await AddonTestUtils.promiseFindAddonUpdates(
+ a4,
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ ok(a4.isActive, "addon4 is active");
+ ok(a4.isCompatible, "addon4 is compatible");
+
+ await AddonTestUtils.promiseFindAddonUpdates(
+ a4,
+ AddonManager.UPDATE_WHEN_NEW_APP_INSTALLED
+ );
+ ok(!a4.isActive, "addon4 is not active");
+ ok(!a4.isCompatible, "addon4 is not compatible");
+
+ await a4.uninstall();
+});
+
+// Test that the background update check doesn't update an add-on that isn't
+// allowed to update automatically.
+add_task(async function test_no_auto_update() {
+ // Have an add-on there that will be updated so we see some events from it
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 1",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon1@tests.mozilla.org",
+ update_url: `http://example.com/data/${updateFile}`,
+ },
+ },
+ },
+ });
+
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 8",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon8@tests.mozilla.org",
+ update_url: `http://example.com/data/${updateFile}`,
+ },
+ },
+ },
+ });
+
+ let a8 = await AddonManager.getAddonByID("addon8@tests.mozilla.org");
+ a8.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DISABLE;
+
+ // The background update check will find updates for both add-ons but only
+ // proceed to install one of them.
+ let listener;
+ await new Promise(resolve => {
+ listener = {
+ onNewInstall(aInstall) {
+ let id = aInstall.existingAddon.id;
+ ok(
+ id == "addon1@tests.mozilla.org" || id == "addon8@tests.mozilla.org",
+ "Saw unexpected onNewInstall for " + id
+ );
+ },
+
+ onDownloadStarted(aInstall) {
+ equal(aInstall.existingAddon.id, "addon1@tests.mozilla.org");
+ },
+
+ onDownloadEnded(aInstall) {
+ equal(aInstall.existingAddon.id, "addon1@tests.mozilla.org");
+ },
+
+ onDownloadFailed(aInstall) {
+ ok(false, "Should not have seen onDownloadFailed event");
+ },
+
+ onDownloadCancelled(aInstall) {
+ ok(false, "Should not have seen onDownloadCancelled event");
+ },
+
+ onInstallStarted(aInstall) {
+ equal(aInstall.existingAddon.id, "addon1@tests.mozilla.org");
+ },
+
+ onInstallEnded(aInstall) {
+ equal(aInstall.existingAddon.id, "addon1@tests.mozilla.org");
+
+ resolve();
+ },
+
+ onInstallFailed(aInstall) {
+ ok(false, "Should not have seen onInstallFailed event");
+ },
+
+ onInstallCancelled(aInstall) {
+ ok(false, "Should not have seen onInstallCancelled event");
+ },
+ };
+ AddonManager.addInstallListener(listener);
+ AddonManagerPrivate.backgroundUpdateCheck();
+ });
+ AddonManager.removeInstallListener(listener);
+
+ let a1;
+ [a1, a8] = await AddonManager.getAddonsByIDs([
+ "addon1@tests.mozilla.org",
+ "addon8@tests.mozilla.org",
+ ]);
+ notEqual(a1, null);
+ equal(a1.version, "2.0");
+ await a1.uninstall();
+
+ notEqual(a8, null);
+ equal(a8.version, "1.0");
+ await a8.uninstall();
+});
+
+// Test that the update check returns nothing for addons in locked install
+// locations.
+add_task(async function run_test_locked_install() {
+ const lockedDir = gProfD.clone();
+ lockedDir.append("locked_extensions");
+ registerDirectory("XREAppFeat", lockedDir);
+
+ await promiseShutdownManager();
+
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ name: "Test Addon 13",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon13@tests.mozilla.org",
+ update_url: "http://example.com/data/test_update.json",
+ },
+ },
+ },
+ });
+ xpi.copyTo(lockedDir, "addon13@tests.mozilla.org.xpi");
+
+ let validAddons = { system: ["addon13@tests.mozilla.org"] };
+ await overrideBuiltIns(validAddons);
+
+ await promiseStartupManager();
+
+ let a13 = await AddonManager.getAddonByID("addon13@tests.mozilla.org");
+ notEqual(a13, null);
+
+ let result = await AddonTestUtils.promiseFindAddonUpdates(a13);
+ ok(
+ !result.compatibilityUpdate,
+ "Should not have seen a compatibility update"
+ );
+ ok(!result.updateAvailable, "Should not have seen a version update");
+
+ let installs = await AddonManager.getAllInstalls();
+ equal(installs.length, 0);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_updateCancel.js b/toolkit/mozapps/extensions/test/xpcshell/test_updateCancel.js
new file mode 100644
index 0000000000..ac201f434c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_updateCancel.js
@@ -0,0 +1,139 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test cancelling add-on update checks while in progress (bug 925389)
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+// Install one extension
+// Start download of update check (but delay HTTP response)
+// Cancel update check
+// - ensure we get cancel notification
+// complete HTTP response
+// - ensure no callbacks after cancel
+// - ensure update is gone
+
+// Create an addon update listener containing a promise
+// that resolves when the update is cancelled
+function makeCancelListener() {
+ let resolve, reject;
+ let promise = new Promise((_resolve, _reject) => {
+ resolve = _resolve;
+ reject = _reject;
+ });
+
+ return {
+ onUpdateAvailable(addon, install) {
+ reject("Should not have seen onUpdateAvailable notification");
+ },
+
+ onUpdateFinished(aAddon, aError) {
+ info("onUpdateCheckFinished: " + aAddon.id + " " + aError);
+ resolve(aError);
+ },
+ promise,
+ };
+}
+
+let testserver = createHttpServer({ hosts: ["example.com"] });
+
+// Set up the HTTP server so that we can control when it responds
+let _httpResolve;
+function resetUpdateListener() {
+ return new Promise(resolve => {
+ _httpResolve = resolve;
+ });
+}
+
+testserver.registerPathHandler("/data/test_update.json", (req, resp) => {
+ resp.processAsync();
+ _httpResolve([req, resp]);
+});
+
+const UPDATE_RESPONSE = {
+ addons: {
+ "addon1@tests.mozilla.org": {
+ updates: [
+ {
+ version: "2.0",
+ update_link: "http://example.com/addons/test_update.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ },
+ ],
+ },
+ },
+};
+
+add_task(async function cancel_during_check() {
+ await promiseStartupManager();
+
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 1",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon1@tests.mozilla.org",
+ update_url: "http://example.com/data/test_update.json",
+ },
+ },
+ },
+ });
+
+ let a1 = await promiseAddonByID("addon1@tests.mozilla.org");
+ Assert.notEqual(a1, null);
+
+ let requestPromise = resetUpdateListener();
+ let listener = makeCancelListener();
+ a1.findUpdates(listener, AddonManager.UPDATE_WHEN_USER_REQUESTED);
+
+ // Wait for the http request to arrive
+ let [, /* request */ response] = await requestPromise;
+
+ // cancelUpdate returns true if there is an update check in progress
+ Assert.ok(a1.cancelUpdate());
+
+ let updateResult = await listener.promise;
+ Assert.equal(AddonManager.UPDATE_STATUS_CANCELLED, updateResult);
+
+ // Now complete the HTTP request
+ response.write(JSON.stringify(UPDATE_RESPONSE));
+ response.finish();
+
+ // trying to cancel again should return false, i.e. nothing to cancel
+ Assert.ok(!a1.cancelUpdate());
+});
+
+// Test that update check is cancelled if the XPI provider shuts down while
+// the update check is in progress
+add_task(async function shutdown_during_check() {
+ // Reset our HTTP listener
+ let requestPromise = resetUpdateListener();
+
+ let a1 = await promiseAddonByID("addon1@tests.mozilla.org");
+ Assert.notEqual(a1, null);
+
+ let listener = makeCancelListener();
+ a1.findUpdates(listener, AddonManager.UPDATE_WHEN_USER_REQUESTED);
+
+ // Wait for the http request to arrive
+ let [, /* request */ response] = await requestPromise;
+
+ await promiseShutdownManager();
+
+ let updateResult = await listener.promise;
+ Assert.equal(AddonManager.UPDATE_STATUS_CANCELLED, updateResult);
+
+ // Now complete the HTTP request
+ response.write(JSON.stringify(UPDATE_RESPONSE));
+ response.finish();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update_addontype.js b/toolkit/mozapps/extensions/test/xpcshell/test_update_addontype.js
new file mode 100644
index 0000000000..ca324cf4ef
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_addontype.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+let server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_update_theme_to_extension() {
+ const THEME_ID = "theme@tests.mozilla.org";
+ await promiseInstallWebExtension({
+ manifest: {
+ version: "1.0",
+ theme: {},
+ browser_specific_settings: {
+ gecko: {
+ id: THEME_ID,
+ update_url: "http://example.com/update.json",
+ },
+ },
+ },
+ });
+
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: THEME_ID } },
+ },
+ });
+
+ server.registerFile("/addon.xpi", xpi);
+ AddonTestUtils.registerJSON(server, "/update.json", {
+ addons: {
+ [THEME_ID]: {
+ updates: [
+ {
+ version: "2.0",
+ update_link: "http://example.com/addon.xpi",
+ },
+ ],
+ },
+ },
+ });
+
+ let addon = await promiseAddonByID(THEME_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.type, "theme");
+ Assert.equal(addon.version, "1.0");
+
+ let update = await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ let install = update.updateAvailable;
+ Assert.notEqual(install, null, "Found available update");
+ // Although the downloaded xpi is an "extension", install.type is "theme"
+ // because install.type reflects the type of the add-on that is being updated.
+ Assert.equal(install.type, "theme");
+ Assert.equal(install.version, "2.0");
+ Assert.equal(install.state, AddonManager.STATE_AVAILABLE);
+ Assert.equal(install.existingAddon, addon);
+
+ await Assert.rejects(
+ install.install(),
+ err => install.error == AddonManager.ERROR_UNEXPECTED_ADDON_TYPE,
+ "Refusing to change addon type from theme to extension"
+ );
+
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update_compatmode.js b/toolkit/mozapps/extensions/test/xpcshell/test_update_compatmode.js
new file mode 100644
index 0000000000..85ec556f95
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_compatmode.js
@@ -0,0 +1,112 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that add-on update check correctly fills in the
+// %COMPATIBILITY_MODE% token in the update URL.
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+let testserver = createHttpServer({ hosts: ["example.com"] });
+
+let lastMode;
+testserver.registerPathHandler("/update.json", (request, response) => {
+ let params = new URLSearchParams(request.queryString);
+ lastMode = params.get("mode");
+
+ response.setHeader("content-type", "application/json", true);
+ response.write(JSON.stringify({ addons: {} }));
+});
+
+const ID_NORMAL = "compatmode@tests.mozilla.org";
+const ID_STRICT = "compatmode-strict@tests.mozilla.org";
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ let xpi = await createAddon({
+ id: ID_NORMAL,
+ updateURL: "http://example.com/update.json?mode=%COMPATIBILITY_MODE%",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "1",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, AddonTestUtils.profileExtensions, ID_NORMAL);
+
+ xpi = await createAddon({
+ id: ID_STRICT,
+ updateURL: "http://example.com/update.json?mode=%COMPATIBILITY_MODE%",
+ strictCompatibility: true,
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "1",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, AddonTestUtils.profileExtensions, ID_STRICT);
+
+ await promiseStartupManager();
+});
+
+// Strict compatibility checking disabled.
+add_task(async function test_strict_disabled() {
+ Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, false);
+ let addon = await AddonManager.getAddonByID(ID_NORMAL);
+ Assert.notEqual(addon, null);
+
+ await promiseFindAddonUpdates(addon, AddonManager.UPDATE_WHEN_USER_REQUESTED);
+ Assert.equal(
+ lastMode,
+ "normal",
+ "COMPATIBIILITY_MODE normal was set correctly"
+ );
+});
+
+// Strict compatibility checking enabled.
+add_task(async function test_strict_enabled() {
+ Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, true);
+ let addon = await AddonManager.getAddonByID(ID_NORMAL);
+ Assert.notEqual(addon, null);
+
+ await promiseFindAddonUpdates(addon, AddonManager.UPDATE_WHEN_USER_REQUESTED);
+ Assert.equal(
+ lastMode,
+ "strict",
+ "COMPATIBILITY_MODE strict was set correctly"
+ );
+});
+
+// Strict compatibility checking opt-in.
+add_task(async function test_strict_optin() {
+ Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, false);
+ let addon = await AddonManager.getAddonByID(ID_STRICT);
+ Assert.notEqual(addon, null);
+
+ await promiseFindAddonUpdates(addon, AddonManager.UPDATE_WHEN_USER_REQUESTED);
+ Assert.equal(
+ lastMode,
+ "normal",
+ "COMPATIBILITY_MODE is normal even for an addon with strictCompatibility"
+ );
+});
+
+// Compatibility checking disabled.
+add_task(async function test_compat_disabled() {
+ AddonManager.checkCompatibility = false;
+ let addon = await AddonManager.getAddonByID(ID_NORMAL);
+ Assert.notEqual(addon, null);
+
+ await promiseFindAddonUpdates(addon, AddonManager.UPDATE_WHEN_USER_REQUESTED);
+ Assert.equal(
+ lastMode,
+ "ignore",
+ "COMPATIBILITY_MODE ignore was set correctly"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update_ignorecompat.js b/toolkit/mozapps/extensions/test/xpcshell/test_update_ignorecompat.js
new file mode 100644
index 0000000000..7ac01afc53
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_ignorecompat.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This test is disabled but is being kept around so it can eventualy
+// be modernized and re-enabled. But is uses obsolete test helpers that
+// fail lint, so just skip linting it for now.
+/* eslint-disable */
+
+// This verifies that add-on update checks work correctly when compatibility
+// check is disabled.
+
+const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, false);
+
+var testserver = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+testserver.registerDirectory("/data/", do_get_file("data"));
+testserver.registerDirectory("/data/", do_get_file("data"));
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+const updateFile = "test_update.json";
+const appId = "toolkit@mozilla.org";
+
+// Test that the update check correctly observes the
+// extensions.strictCompatibility pref.
+add_test(async function () {
+ await promiseWriteInstallRDFForExtension(
+ {
+ id: "addon9@tests.mozilla.org",
+ version: "1.0",
+ updateURL: "http://example.com/data/" + updateFile,
+ targetApplications: [
+ {
+ id: appId,
+ minVersion: "0.1",
+ maxVersion: "0.2",
+ },
+ ],
+ name: "Test Addon 9",
+ },
+ profileDir
+ );
+
+ await promiseRestartManager();
+
+ AddonManager.addInstallListener({
+ onNewInstall(aInstall) {
+ if (aInstall.existingAddon.id != "addon9@tests.mozilla.org")
+ do_throw(
+ "Saw unexpected onNewInstall for " + aInstall.existingAddon.id
+ );
+ Assert.equal(aInstall.version, "4.0");
+ },
+ onDownloadFailed(aInstall) {
+ run_next_test();
+ },
+ });
+
+ Services.prefs.setCharPref(
+ PREF_GETADDONS_BYIDS,
+ `http://example.com/data/test_update_addons.json`
+ );
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+
+ AddonManagerInternal.backgroundUpdateCheck();
+});
+
+// Test that the update check correctly observes when an addon opts-in to
+// strict compatibility checking.
+add_test(async function () {
+ await promiseWriteInstallRDFForExtension(
+ {
+ id: "addon11@tests.mozilla.org",
+ version: "1.0",
+ updateURL: "http://example.com/data/" + updateFile,
+ targetApplications: [
+ {
+ id: appId,
+ minVersion: "0.1",
+ maxVersion: "0.2",
+ },
+ ],
+ name: "Test Addon 11",
+ },
+ profileDir
+ );
+
+ await promiseRestartManager();
+
+ let a11 = await AddonManager.getAddonByID("addon11@tests.mozilla.org");
+ Assert.notEqual(a11, null);
+
+ a11.findUpdates(
+ {
+ onCompatibilityUpdateAvailable() {
+ do_throw("Should not have seen compatibility information");
+ },
+
+ onUpdateAvailable() {
+ do_throw("Should not have seen an available update");
+ },
+
+ onUpdateFinished() {
+ run_next_test();
+ },
+ },
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update_isPrivileged.js b/toolkit/mozapps/extensions/test/xpcshell/test_update_isPrivileged.js
new file mode 100644
index 0000000000..525dbfa25a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_isPrivileged.js
@@ -0,0 +1,181 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+});
+
+AddonTestUtils.usePrivilegedSignatures = id => id === "privileged@ext";
+
+const EXTENSION_API_IMPL = `
+this.extensionApiImpl = class extends ExtensionAPI {
+ onStartup() {
+ extensions.emit("test-ExtensionAPI-onStartup", {
+ extensionId: this.extension.id,
+ version: this.extension.manifest.version,
+ });
+ }
+ static onUpdate(id, manifest) {
+ extensions.emit("test-ExtensionAPI-onUpdate", {
+ extensionId: id,
+ version: manifest.version,
+ });
+ }
+};`;
+
+function setupTestExtensionAPI() {
+ // The EXTENSION_API_IMPL script is going to be loaded in the main process,
+ // where only safe loads are permitted. So we generate a resource:-URL, to
+ // avoid the use of security.allow_parent_unrestricted_js_loads.
+ let resProto = Services.io
+ .getProtocolHandler("resource")
+ .QueryInterface(Ci.nsIResProtocolHandler);
+ resProto.setSubstitution(
+ "extensionApiImplJs",
+ Services.io.newURI(`data:,${encodeURIComponent(EXTENSION_API_IMPL)}`)
+ );
+ registerCleanupFunction(() => {
+ resProto.setSubstitution("extensionApiImplJs", null);
+ });
+
+ const modules = {
+ extensionApiImpl: {
+ url: "resource://extensionApiImplJs",
+ events: ["startup", "update"],
+ },
+ };
+
+ Services.catMan.addCategoryEntry(
+ "webextension-modules",
+ "test-register-extensionApiImpl",
+ `data:,${JSON.stringify(modules)}`,
+ false,
+ false
+ );
+}
+
+async function runInstallAndUpdate({
+ extensionId,
+ expectPrivileged,
+ installExtensionData,
+}) {
+ let extensionData = {
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: { gecko: { id: extensionId } },
+ version: "1.1",
+ },
+ };
+ let events = [];
+ function onUpdated(type, params) {
+ params = { type, ...params };
+ // resourceURI cannot be serialized for use with deepEqual.
+ delete params.resourceURI;
+ events.push(params);
+ }
+ function onExtensionAPI(type, params) {
+ events.push({ type, ...params });
+ }
+ ExtensionParent.apiManager.on("update", onUpdated);
+ ExtensionParent.apiManager.on("test-ExtensionAPI-onStartup", onExtensionAPI);
+ ExtensionParent.apiManager.on("test-ExtensionAPI-onUpdate", onExtensionAPI);
+
+ let { addon } = await installExtensionData(extensionData);
+ equal(addon.isPrivileged, expectPrivileged, "Expected isPrivileged");
+
+ extensionData.manifest.version = "2.22";
+ extensionData.manifest.permissions = ["mozillaAddons"];
+ // May warn about invalid permissions when the extension is not privileged.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ let extension = await installExtensionData(extensionData);
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ await extension.unload();
+
+ ExtensionParent.apiManager.off("update", onUpdated);
+ ExtensionParent.apiManager.off("test-ExtensionAPI-onStartup", onExtensionAPI);
+ ExtensionParent.apiManager.off("test-ExtensionAPI-onUpdate", onExtensionAPI);
+
+ // Verify that we have (1) installed and (2) updated the extension.
+ Assert.deepEqual(
+ events,
+ [
+ { type: "test-ExtensionAPI-onStartup", extensionId, version: "1.1" },
+ // The next two events show that ExtensionParent has run the onUpdate
+ // handler, during which ExtensionData has supposedly been constructed.
+ { type: "update", id: extensionId, isPrivileged: expectPrivileged },
+ { type: "test-ExtensionAPI-onUpdate", extensionId, version: "2.22" },
+ { type: "test-ExtensionAPI-onStartup", extensionId, version: "2.22" },
+ ],
+ "Expected startup and update events"
+ );
+}
+
+add_task(async function setup() {
+ setupTestExtensionAPI();
+ await ExtensionTestUtils.startAddonManager();
+});
+
+// Tests that privileged extensions (e.g builtins) are always parsed with the
+// correct isPrivileged flag.
+add_task(async function test_install_and_update_builtin() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await runInstallAndUpdate({
+ extensionId: "builtin@ext",
+ expectPrivileged: true,
+ async installExtensionData(extData) {
+ return installBuiltinExtension(extData);
+ },
+ });
+ });
+
+ AddonTestUtils.checkMessages(messages, {
+ expected: [{ message: /Addon with ID builtin@ext already installed,/ }],
+ forbidden: [{ message: /Invalid extension permission: mozillaAddons/ }],
+ });
+});
+
+add_task(async function test_install_and_update_regular_ext() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await runInstallAndUpdate({
+ extensionId: "regular@ext",
+ expectPrivileged: false,
+ async installExtensionData(extData) {
+ let extension = ExtensionTestUtils.loadExtension(extData);
+ await extension.startup();
+ return extension;
+ },
+ });
+ });
+ let errPattern =
+ /Loading extension 'regular@ext': Reading manifest: Invalid extension permission: mozillaAddons/;
+ let permissionWarnings = messages.filter(msg => errPattern.test(msg.message));
+ // Expected number of warnings after triggering the update:
+ // 1. Generated when the loaded by the Addons manager (ExtensionData).
+ // 2. Generated when read again before ExtensionAPI.onUpdate (ExtensionData).
+ // 3. Generated when the extension actually runs (Extension).
+ equal(permissionWarnings.length, 3, "Expected number of permission warnings");
+});
+
+add_task(async function test_install_and_update_privileged_ext() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await runInstallAndUpdate({
+ extensionId: "privileged@ext",
+ expectPrivileged: true,
+ async installExtensionData(extData) {
+ let extension = ExtensionTestUtils.loadExtension(extData);
+ await extension.startup();
+ return extension;
+ },
+ });
+ });
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ // First installation.
+ { message: /Starting install of privileged@ext / },
+ // Second installation (update).
+ { message: /Starting install of privileged@ext / },
+ ],
+ forbidden: [{ message: /Invalid extension permission: mozillaAddons/ }],
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update_noSystemAddonUpdate.js b/toolkit/mozapps/extensions/test/xpcshell/test_update_noSystemAddonUpdate.js
new file mode 100644
index 0000000000..4ee0d95476
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_noSystemAddonUpdate.js
@@ -0,0 +1,42 @@
+// Tests that system add-on doesnt request update while normal backgroundUpdateCheck
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"], true);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = "system";
+
+add_task(() => initSystemAddonDirs());
+
+const initialSetup = {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ ],
+};
+
+add_task(async function test_systems_update_uninstall_check() {
+ await setupSystemAddonConditions(initialSetup, distroDir);
+
+ const testserver = createHttpServer({ hosts: ["example.com"] });
+ testserver.registerPathHandler("/update.json", (request, response) => {
+ Assert.ok(
+ !request._queryString.includes("system2@tests.mozilla.org"),
+ "System addon should not request update from normal update process"
+ );
+ });
+
+ Services.prefs.setCharPref(
+ "extensions.update.background.url",
+ "http://example.com/update.json?id=%ITEM_ID%"
+ );
+
+ await AddonManagerPrivate.backgroundUpdateCheck();
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update_strictcompat.js b/toolkit/mozapps/extensions/test/xpcshell/test_update_strictcompat.js
new file mode 100644
index 0000000000..14192657dd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_strictcompat.js
@@ -0,0 +1,216 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that add-on update checks work in conjunction with
+// strict compatibility settings.
+
+const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, false);
+
+const appId = "toolkit@mozilla.org";
+
+testserver = createHttpServer({ hosts: ["example.com"] });
+testserver.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ AddonTestUtils.updateReason = AddonManager.UPDATE_WHEN_USER_REQUESTED;
+
+ Services.prefs.setCharPref(
+ PREF_GETADDONS_BYIDS,
+ "http://example.com/data/test_update_addons.json"
+ );
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+});
+
+// Test that the update check correctly observes the
+// extensions.strictCompatibility pref.
+add_task(async function test_update_strict() {
+ const ID = "addon9@tests.mozilla.org";
+ let xpi = await createAddon({
+ id: ID,
+ updateURL: "http://example.com/update.json",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "0.1",
+ maxVersion: "0.2",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, AddonTestUtils.profileExtensions, ID);
+
+ await promiseStartupManager();
+
+ await AddonRepository.backgroundUpdateCheck();
+
+ let UPDATE = {
+ addons: {
+ [ID]: {
+ updates: [
+ {
+ version: "2.0",
+ update_link: "http://example.com/addons/test_update9_2.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "1",
+ advisory_max_version: "1",
+ },
+ },
+ },
+
+ // Incompatible when strict compatibility is enabled
+ {
+ version: "3.0",
+ update_link: "http://example.com/addons/test_update9_3.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "0.9",
+ advisory_max_version: "0.9",
+ },
+ },
+ },
+
+ // Addon for future version of app
+ {
+ version: "4.0",
+ update_link: "http://example.com/addons/test_update9_5.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "5",
+ advisory_max_version: "6",
+ },
+ },
+ },
+ ],
+ },
+ },
+ };
+
+ AddonTestUtils.registerJSON(testserver, "/update.json", UPDATE);
+
+ let addon = await AddonManager.getAddonByID(ID);
+ let { updateAvailable } = await promiseFindAddonUpdates(addon);
+
+ Assert.notEqual(updateAvailable, null, "Got update");
+ Assert.equal(
+ updateAvailable.version,
+ "3.0",
+ "The correct update was selected"
+ );
+ await addon.uninstall();
+
+ await promiseShutdownManager();
+});
+
+// Tests that compatibility updates are applied to addons when the updated
+// compatibility data wouldn't match with strict compatibility enabled.
+add_task(async function test_update_strict2() {
+ const ID = "addon10@tests.mozilla.org";
+ let xpi = createAddon({
+ id: ID,
+ updateURL: "http://example.com/update.json",
+ targetApplications: [
+ {
+ id: appId,
+ minVersion: "0.1",
+ maxVersion: "0.2",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, AddonTestUtils.profileExtensions, ID);
+
+ await promiseStartupManager();
+ await AddonRepository.backgroundUpdateCheck();
+
+ const UPDATE = {
+ addons: {
+ [ID]: {
+ updates: [
+ {
+ version: "1.0",
+ update_link: "http://example.com/addons/test_update10.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "0.1",
+ advisory_max_version: "0.4",
+ },
+ },
+ },
+ ],
+ },
+ },
+ };
+
+ AddonTestUtils.registerJSON(testserver, "/update.json", UPDATE);
+
+ let addon = await AddonManager.getAddonByID(ID);
+ notEqual(addon, null);
+
+ let result = await promiseFindAddonUpdates(addon);
+ ok(result.compatibilityUpdate, "Should have seen a compatibility update");
+ ok(!result.updateAvailable, "Should not have seen a version update");
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
+
+// Test that the update check correctly observes when an addon opts-in to
+// strict compatibility checking.
+add_task(async function test_update_strict_optin() {
+ const ID = "addon11@tests.mozilla.org";
+ let xpi = await createAddon({
+ id: ID,
+ updateURL: "http://example.com/update.json",
+ targetApplications: [
+ {
+ id: appId,
+ minVersion: "0.1",
+ maxVersion: "0.2",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, AddonTestUtils.profileExtensions, ID);
+
+ await promiseStartupManager();
+
+ await AddonRepository.backgroundUpdateCheck();
+
+ const UPDATE = {
+ addons: {
+ [ID]: {
+ updates: [
+ {
+ version: "2.0",
+ update_link: "http://example.com/addons/test_update11.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "0.1",
+ strict_max_version: "0.2",
+ },
+ },
+ },
+ ],
+ },
+ },
+ };
+
+ AddonTestUtils.registerJSON(testserver, "/update.json", UPDATE);
+
+ let addon = await AddonManager.getAddonByID(ID);
+ notEqual(addon, null);
+
+ let result = await AddonTestUtils.promiseFindAddonUpdates(addon);
+ ok(
+ !result.compatibilityUpdate,
+ "Should not have seen a compatibility update"
+ );
+ ok(!result.updateAvailable, "Should not have seen a version update");
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update_theme.js b/toolkit/mozapps/extensions/test/xpcshell/test_update_theme.js
new file mode 100644
index 0000000000..3192d47393
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_theme.js
@@ -0,0 +1,121 @@
+"use strict";
+
+XPCOMUtils.defineLazyGetter(this, "Management", () => {
+ const { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+ );
+ return ExtensionParent.apiManager;
+});
+
+add_task(async function setup() {
+ let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION;
+ Services.prefs.setIntPref("extensions.enabledScopes", scopes);
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42.0", "42.0");
+
+ await promiseStartupManager();
+});
+
+// Verify that a theme can be updated without issues.
+add_task(async function test_update_of_disabled_theme() {
+ const id = "theme-only@test";
+ async function installTheme(version) {
+ // Upon installing a theme, it is disabled by default. Because of this,
+ // ExtensionTestUtils.loadExtension cannot be used because it awaits the
+ // startup of a test extension.
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ version,
+ theme: {},
+ },
+ });
+ let install = await AddonManager.getInstallForFile(xpi);
+ let addon = await install.install();
+ ok(addon.userDisabled, "Theme is expected to be disabled by default");
+ equal(addon.userPermissions, null, "theme has no userPermissions");
+ }
+
+ await installTheme("1.0");
+
+ let updatePromise = new Promise(resolve => {
+ Management.on("update", function listener(name, { id: updatedId }) {
+ Management.off("update", listener);
+ equal(updatedId, id, "expected theme update");
+ resolve();
+ });
+ });
+ await installTheme("2.0");
+ await updatePromise;
+ let addon = await promiseAddonByID(id);
+ equal(addon.version, "2.0", "Theme should be updated");
+ ok(addon.userDisabled, "Theme is still disabled after an update");
+ await addon.uninstall();
+});
+
+add_task(async function test_builtin_location_migration() {
+ const ADDON_ID = "mytheme@mozilla.org";
+
+ let themeDef = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: ADDON_ID } },
+ version: "1.0",
+ theme: {},
+ },
+ };
+
+ await setupBuiltinExtension(themeDef, "first-loc", false);
+ await AddonManager.maybeInstallBuiltinAddon(
+ ADDON_ID,
+ "1.0",
+ "resource://first-loc/"
+ );
+
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ await addon.enable();
+ Assert.ok(!addon.userDisabled, "Add-on should be enabled.");
+
+ Assert.equal(
+ Services.prefs.getCharPref("extensions.activeThemeID", ""),
+ ADDON_ID,
+ "Pref should be set."
+ );
+
+ let { addons: activeThemes } = await AddonManager.getActiveAddons(["theme"]);
+ Assert.equal(activeThemes.length, 1, "Should have 1 theme.");
+ Assert.equal(activeThemes[0].id, ADDON_ID, "Should have enabled the theme.");
+
+ // If we restart and update, and install a newer version of the theme,
+ // it should be activated.
+ await promiseShutdownManager();
+
+ // Force schema change and restart
+ Services.prefs.setIntPref("extensions.databaseSchema", 0);
+ await promiseStartupManager();
+
+ // Set up a new version of the builtin add-on.
+ let newDef = { manifest: Object.assign({}, themeDef.manifest) };
+ newDef.manifest.version = "1.1";
+ await setupBuiltinExtension(newDef, "second-loc");
+ await AddonManager.maybeInstallBuiltinAddon(
+ ADDON_ID,
+ "1.1",
+ "resource://second-loc/"
+ );
+
+ let newAddon = await AddonManager.getAddonByID(ADDON_ID);
+ Assert.ok(!newAddon.userDisabled, "Add-on should be enabled.");
+
+ ({ addons: activeThemes } = await AddonManager.getActiveAddons(["theme"]));
+ Assert.equal(activeThemes.length, 1, "Should still have 1 theme.");
+ Assert.equal(
+ activeThemes[0].id,
+ ADDON_ID,
+ "Should still have the theme enabled."
+ );
+ Assert.equal(
+ Services.prefs.getCharPref("extensions.activeThemeID", ""),
+ ADDON_ID,
+ "Pref should still be set."
+ );
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update_webextensions.js b/toolkit/mozapps/extensions/test/xpcshell/test_update_webextensions.js
new file mode 100644
index 0000000000..dbe944013f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_webextensions.js
@@ -0,0 +1,209 @@
+"use strict";
+
+// We don't have an easy way to serve update manifests from a secure URL.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+var testserver = createHttpServer();
+gPort = testserver.identity.primaryPort;
+
+const uuidGenerator = Services.uuid;
+
+const extensionsDir = gProfD.clone();
+extensionsDir.append("extensions");
+
+const addonsDir = gTmpD.clone();
+addonsDir.append("addons");
+addonsDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+
+registerCleanupFunction(() => addonsDir.remove(true));
+
+testserver.registerDirectory("/addons/", addonsDir);
+
+let gUpdateManifests = {};
+
+function mapManifest(aPath, aManifestData) {
+ gUpdateManifests[aPath] = aManifestData;
+ testserver.registerPathHandler(aPath, serveManifest);
+}
+
+function serveManifest(request, response) {
+ let manifest = gUpdateManifests[request.path];
+
+ response.setHeader("Content-Type", manifest.contentType, false);
+ response.write(manifest.data);
+}
+
+async function promiseInstallWebExtension(aData) {
+ let addonFile = createTempWebExtensionFile(aData);
+
+ let { addon } = await promiseInstallFile(addonFile);
+ Services.obs.notifyObservers(addonFile, "flush-cache-entry");
+ return addon;
+}
+
+var checkUpdates = async function (
+ aData,
+ aReason = AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+) {
+ function provide(obj, path, value) {
+ path = path.split(".");
+ let prop = path.pop();
+
+ for (let key of path) {
+ if (!(key in obj)) {
+ obj[key] = {};
+ }
+ obj = obj[key];
+ }
+
+ if (!(prop in obj)) {
+ obj[prop] = value;
+ }
+ }
+
+ let id = uuidGenerator.generateUUID().number;
+ provide(aData, "addon.id", id);
+ provide(aData, "addon.manifest.browser_specific_settings.gecko.id", id);
+
+ let updatePath = `/updates/${id}.json`.replace(/[{}]/g, "");
+ let updateUrl = `http://localhost:${gPort}${updatePath}`;
+
+ let addonData = { updates: [] };
+ let manifestJSON = {
+ addons: { [id]: addonData },
+ };
+
+ provide(
+ aData,
+ "addon.manifest.browser_specific_settings.gecko.update_url",
+ updateUrl
+ );
+ let awaitInstall = promiseInstallWebExtension(aData.addon);
+
+ for (let version of Object.keys(aData.updates)) {
+ let update = aData.updates[version];
+ update.version = version;
+
+ // Create an add-on manifest based on what's in the current `update`.
+ provide(update, "addon.id", id);
+ provide(update, "addon.manifest.browser_specific_settings.gecko.id", id);
+ let addon = update.addon;
+
+ delete update.addon;
+
+ provide(addon, "manifest.version", version);
+ let file = createTempWebExtensionFile(addon);
+ file.moveTo(addonsDir, `${id}-${version}.xpi`.replace(/[{}]/g, ""));
+
+ let path = `/addons/${file.leafName}`;
+ provide(update, "update_link", `http://localhost:${gPort}${path}`);
+
+ addonData.updates.push(update);
+ }
+
+ mapManifest(updatePath, {
+ data: JSON.stringify(manifestJSON),
+ contentType: "application/json",
+ });
+
+ let addon = await awaitInstall;
+
+ let updates = await promiseFindAddonUpdates(addon, aReason);
+ updates.addon = addon;
+
+ return updates;
+};
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42.0", "42.0");
+
+ await promiseStartupManager();
+ registerCleanupFunction(promiseShutdownManager);
+});
+
+// Check that compatibility updates are applied.
+add_task(async function checkUpdateMetadata() {
+ let update = await checkUpdates({
+ addon: {
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { strict_max_version: "45" } },
+ },
+ },
+ updates: {
+ "1.0": {
+ applications: {
+ gecko: { strict_min_version: "40", strict_max_version: "48" },
+ },
+ },
+ },
+ });
+
+ ok(update.compatibilityUpdate, "have compat update");
+ ok(!update.updateAvailable, "have no add-on update");
+
+ ok(update.addon.isCompatibleWith("40", "40"), "compatible min");
+ ok(update.addon.isCompatibleWith("48", "48"), "compatible max");
+ ok(!update.addon.isCompatibleWith("49", "49"), "not compatible max");
+
+ await update.addon.uninstall();
+});
+
+// Check that updates from web extensions to web extensions succeed.
+add_task(async function checkUpdateToWebExt() {
+ let update = await checkUpdates({
+ addon: { manifest: { version: "1.0" } },
+ updates: {
+ 1.1: {},
+ 1.2: {},
+ 1.3: { applications: { gecko: { strict_min_version: "48" } } },
+ },
+ });
+
+ ok(!update.compatibilityUpdate, "have no compat update");
+ ok(update.updateAvailable, "have add-on update");
+
+ equal(update.addon.version, "1.0", "add-on version");
+
+ await update.updateAvailable.install();
+
+ let addon = await promiseAddonByID(update.addon.id);
+ equal(addon.version, "1.2", "new add-on version");
+
+ await addon.uninstall();
+});
+
+// Check that illegal update URLs are rejected.
+add_task(async function checkIllegalUpdateURL() {
+ const URLS = [
+ "chrome://browser/content/",
+ "data:text/json,...",
+ "javascript:;",
+ "/",
+ ];
+
+ for (let url of URLS) {
+ let { messages } = await promiseConsoleOutput(() => {
+ let addonFile = createTempWebExtensionFile({
+ manifest: { browser_specific_settings: { gecko: { update_url: url } } },
+ });
+
+ return AddonManager.getInstallForFile(addonFile).then(install => {
+ Services.obs.notifyObservers(addonFile, "flush-cache-entry");
+
+ if (!install || install.state != AddonManager.STATE_DOWNLOAD_FAILED) {
+ throw new Error("Unexpected state: " + (install && install.state));
+ }
+ });
+ });
+
+ ok(
+ messages.some(msg =>
+ /Access denied for URL|may not load or link to|is not a valid URL/.test(
+ msg
+ )
+ ),
+ "Got checkLoadURI error"
+ );
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck.js b/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck.js
new file mode 100644
index 0000000000..9ddaf82bf3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck.js
@@ -0,0 +1,167 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that AddonUpdateChecker works correctly
+
+const { AddonUpdateChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonUpdateChecker.sys.mjs"
+);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+var testserver = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+
+testserver.registerDirectory("/data/", do_get_file("data"));
+
+function checkUpdates(aId) {
+ return new Promise((resolve, reject) => {
+ AddonUpdateChecker.checkForUpdates(
+ aId,
+ `http://example.com/data/test_updatecheck.json`,
+ {
+ onUpdateCheckComplete: resolve,
+
+ onUpdateCheckError(status) {
+ let error = new Error("Update check failed with status " + status);
+ error.status = status;
+ reject(error);
+ },
+ }
+ );
+ });
+}
+
+// Test that a basic update check returns the expected available updates
+add_task(async function test_basic_update() {
+ let updates = await checkUpdates("updatecheck1@tests.mozilla.org");
+
+ equal(updates.length, 5);
+ let update = await AddonUpdateChecker.getNewestCompatibleUpdate(updates, {});
+ notEqual(update, null);
+ equal(update.version, "3.0");
+ update = AddonUpdateChecker.getCompatibilityUpdate(updates, "2");
+ notEqual(update, null);
+ equal(update.version, "2.0");
+ equal(update.targetApplications[0].minVersion, "1");
+ equal(update.targetApplications[0].maxVersion, "2");
+});
+
+// Test that only newer versions are considered.
+add_task(async function test_update_newer_versions_only() {
+ let updates = await checkUpdates("updatecheck1@tests.mozilla.org");
+
+ // This should be an AddonWrapper instance, but for the purpose of this test,
+ // an object with the version property suffices.
+ let addon = { version: "2.0" };
+ let update = await AddonUpdateChecker.getNewestCompatibleUpdate(
+ updates,
+ addon
+ );
+ notEqual(update, null);
+ equal(update.version, "3.0");
+
+ addon = { version: "3.0" };
+ update = await AddonUpdateChecker.getNewestCompatibleUpdate(updates, addon);
+ equal(update, null);
+});
+
+/*
+ * Tests that the security checks are applied correctly
+ *
+ * Test updateHash updateLink expected
+ *--------------------------------------------
+ * 4 absent http no update
+ * 5 sha1 http update
+ * 6 absent https update
+ * 7 sha1 https update
+ * 8 md2 http no update
+ * 9 md2 https update
+ */
+
+add_task(async function test_4() {
+ let updates = await checkUpdates("test_bug378216_8@tests.mozilla.org");
+ equal(updates.length, 1);
+ ok(!("updateURL" in updates[0]));
+});
+
+add_task(async function test_5() {
+ let updates = await checkUpdates("test_bug378216_9@tests.mozilla.org");
+ equal(updates.length, 1);
+ equal(updates[0].version, "2.0");
+ ok("updateURL" in updates[0]);
+});
+
+add_task(async function test_6() {
+ let updates = await checkUpdates("test_bug378216_10@tests.mozilla.org");
+ equal(updates.length, 1);
+ equal(updates[0].version, "2.0");
+ ok("updateURL" in updates[0]);
+});
+
+add_task(async function test_7() {
+ let updates = await checkUpdates("test_bug378216_11@tests.mozilla.org");
+ equal(updates.length, 1);
+ equal(updates[0].version, "2.0");
+ ok("updateURL" in updates[0]);
+});
+
+add_task(async function test_8() {
+ let updates = await checkUpdates("test_bug378216_12@tests.mozilla.org");
+ equal(updates.length, 1);
+ ok(!("updateURL" in updates[0]));
+});
+
+add_task(async function test_9() {
+ let updates = await checkUpdates("test_bug378216_13@tests.mozilla.org");
+ equal(updates.length, 1);
+ equal(updates[0].version, "2.0");
+ ok("updateURL" in updates[0]);
+});
+
+add_task(async function test_no_update_data() {
+ let updates = await checkUpdates("test_bug378216_14@tests.mozilla.org");
+ equal(updates.length, 0);
+});
+
+add_task(async function test_invalid_json() {
+ await checkUpdates("test_bug378216_15@tests.mozilla.org")
+ .then(() => {
+ ok(false, "Expected the update check to fail");
+ })
+ .catch(e => {
+ equal(
+ e.status,
+ AddonManager.ERROR_PARSE_ERROR,
+ "expected AddonManager.ERROR_PARSE_ERROR"
+ );
+ });
+});
+
+add_task(async function test_ignore_compat() {
+ let updates = await checkUpdates("ignore-compat@tests.mozilla.org");
+ equal(updates.length, 3);
+ let update = await AddonUpdateChecker.getNewestCompatibleUpdate(
+ updates,
+ {}, // dummy value instead of addon.
+ null,
+ null,
+ true
+ );
+ notEqual(update, null);
+ equal(update.version, 2);
+});
+
+add_task(async function test_strict_compat() {
+ let updates = await checkUpdates("compat-strict-optin@tests.mozilla.org");
+ equal(updates.length, 1);
+ let update = await AddonUpdateChecker.getNewestCompatibleUpdate(
+ updates,
+ {}, // dummy value instead of addon.
+ null,
+ null,
+ true,
+ false
+ );
+ equal(update, null);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck_errors.js b/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck_errors.js
new file mode 100644
index 0000000000..5bbd723f42
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck_errors.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that add-on update check failures are propagated correctly
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+var testserver;
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ // Create and configure the HTTP server.
+ testserver = createHttpServer({ hosts: ["example.com"] });
+ testserver.registerDirectory("/data/", do_get_file("data"));
+
+ await promiseStartupManager();
+});
+
+// Verify that an update check returns the correct errors.
+add_task(async function () {
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 1",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon1@tests.mozilla.org",
+ update_url: "http://example.com/data/test_missing.json",
+ },
+ },
+ },
+ });
+
+ let addon = await promiseAddonByID("addon1@tests.mozilla.org");
+ equal(addon.version, "1.0");
+
+ // We're expecting an error, so resolve when the promise is rejected.
+ let update = await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ ).catch(e => e);
+
+ ok(!update.compatibilityUpdate, "not expecting a compatibility update");
+ ok(!update.updateAvailable, "not expecting a compatibility update");
+
+ equal(update.error, AddonManager.UPDATE_STATUS_DOWNLOAD_ERROR);
+
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck_json.js b/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck_json.js
new file mode 100644
index 0000000000..3151c1c296
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck_json.js
@@ -0,0 +1,379 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+// This verifies that AddonUpdateChecker works correctly for JSON
+// update manifests, particularly for behavior which does not
+// cleanly overlap with RDF manifests.
+
+const TOOLKIT_ID = "toolkit@mozilla.org";
+const TOOLKIT_MINVERSION = "42.0a1";
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42.0a2", "42.0a2");
+
+const { AddonUpdateChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonUpdateChecker.sys.mjs"
+);
+
+let testserver = createHttpServer();
+gPort = testserver.identity.primaryPort;
+
+let gUpdateManifests = {};
+
+function mapManifest(aPath, aManifestData) {
+ gUpdateManifests[aPath] = aManifestData;
+ testserver.registerPathHandler(aPath, serveManifest);
+}
+
+function serveManifest(request, response) {
+ let manifest = gUpdateManifests[request.path];
+
+ response.setHeader("Content-Type", manifest.contentType, false);
+ response.write(manifest.data);
+}
+
+const extensionsDir = gProfD.clone();
+extensionsDir.append("extensions");
+
+function checkUpdates(aData) {
+ // Registers JSON update manifest for it with the testing server,
+ // checks for updates, and yields the list of updates on
+ // success.
+
+ let extension = aData.manifestExtension || "json";
+
+ let path = `/updates/${aData.id}.${extension}`;
+ let updateUrl = `http://localhost:${gPort}${path}`;
+
+ let addonData = {};
+ if ("updates" in aData) {
+ addonData.updates = aData.updates;
+ }
+
+ let manifestJSON = {
+ addons: {
+ [aData.id]: addonData,
+ },
+ };
+
+ mapManifest(path.replace(/\?.*/, ""), {
+ data: JSON.stringify(manifestJSON),
+ contentType: aData.contentType || "application/json",
+ });
+
+ return new Promise((resolve, reject) => {
+ AddonUpdateChecker.checkForUpdates(aData.id, updateUrl, {
+ onUpdateCheckComplete: resolve,
+
+ onUpdateCheckError(status) {
+ reject(new Error("Update check failed with status " + status));
+ },
+ });
+ });
+}
+
+add_task(async function test_default_values() {
+ // Checks that the appropriate defaults are used for omitted values.
+
+ await promiseStartupManager();
+
+ let updates = await checkUpdates({
+ id: "updatecheck-defaults@tests.mozilla.org",
+ version: "0.1",
+ updates: [
+ {
+ version: "0.2",
+ },
+ ],
+ });
+
+ equal(updates.length, 1);
+ let update = updates[0];
+
+ equal(update.targetApplications.length, 2);
+ let targetApp = update.targetApplications[0];
+
+ equal(targetApp.id, TOOLKIT_ID);
+ equal(targetApp.minVersion, TOOLKIT_MINVERSION);
+ equal(targetApp.maxVersion, "*");
+
+ equal(update.version, "0.2");
+ equal(update.strictCompatibility, false, "inferred strictConpatibility flag");
+ equal(update.updateURL, null, "updateURL");
+ equal(update.updateHash, null, "updateHash");
+ equal(update.updateInfoURL, null, "updateInfoURL");
+
+ // If there's no applications property, we default to using one
+ // containing "gecko". If there is an applications property, but
+ // it doesn't contain "gecko", the update is skipped.
+ updates = await checkUpdates({
+ id: "updatecheck-defaults@tests.mozilla.org",
+ version: "0.1",
+ updates: [
+ {
+ version: "0.2",
+ applications: { foo: {} },
+ },
+ ],
+ });
+
+ equal(updates.length, 0);
+
+ // Updates property is also optional. No updates, but also no error.
+ updates = await checkUpdates({
+ id: "updatecheck-defaults@tests.mozilla.org",
+ version: "0.1",
+ });
+
+ equal(updates.length, 0);
+});
+
+add_task(async function test_explicit_values() {
+ // Checks that the appropriate explicit values are used when
+ // provided.
+
+ let updates = await checkUpdates({
+ id: "updatecheck-explicit@tests.mozilla.org",
+ version: "0.1",
+ updates: [
+ {
+ version: "0.2",
+ update_link: "https://example.com/foo.xpi",
+ update_hash: "sha256:0",
+ update_info_url: "https://example.com/update_info.html",
+ applications: {
+ gecko: {
+ strict_min_version: "42.0a2.xpcshell",
+ strict_max_version: "43.xpcshell",
+ },
+ },
+ },
+ ],
+ });
+
+ equal(updates.length, 1);
+ let update = updates[0];
+
+ equal(update.targetApplications.length, 2);
+ let targetApp = update.targetApplications[0];
+
+ equal(targetApp.id, TOOLKIT_ID);
+ equal(targetApp.minVersion, "42.0a2.xpcshell");
+ equal(targetApp.maxVersion, "43.xpcshell");
+
+ equal(update.version, "0.2");
+ equal(update.strictCompatibility, true, "inferred strictCompatibility flag");
+ equal(update.updateURL, "https://example.com/foo.xpi", "updateURL");
+ equal(update.updateHash, "sha256:0", "updateHash");
+ equal(
+ update.updateInfoURL,
+ "https://example.com/update_info.html",
+ "updateInfoURL"
+ );
+});
+
+add_task(async function test_secure_hashes() {
+ // Checks that only secure hash functions are accepted for
+ // non-secure update URLs.
+
+ let hashFunctions = ["sha512", "sha256", "sha1", "md5", "md4", "xxx"];
+
+ let updateItems = hashFunctions.map((hash, idx) => ({
+ version: `0.${idx}`,
+ update_link: `http://localhost:${gPort}/updates/${idx}-${hash}.xpi`,
+ update_hash: `${hash}:08ac852190ecd81f40a514ea9299fe9143d9ab5e296b97e73fb2a314de49648a`,
+ }));
+
+ let { messages, result: updates } = await promiseConsoleOutput(() => {
+ return checkUpdates({
+ id: "updatecheck-hashes@tests.mozilla.org",
+ version: "0.1",
+ updates: updateItems,
+ });
+ });
+
+ equal(updates.length, hashFunctions.length);
+
+ updates = updates.filter(update => update.updateHash || update.updateURL);
+ equal(updates.length, 2, "expected number of update hashes were accepted");
+
+ ok(updates[0].updateHash.startsWith("sha512:"), "sha512 hash is present");
+ ok(updates[0].updateURL);
+
+ ok(updates[1].updateHash.startsWith("sha256:"), "sha256 hash is present");
+ ok(updates[1].updateURL);
+
+ messages = messages.filter(msg =>
+ /Update link.*not secure.*strong enough hash \(needs to be sha256 or sha512\)/.test(
+ msg.message
+ )
+ );
+ equal(
+ messages.length,
+ hashFunctions.length - 2,
+ "insecure hashes generated the expected warning"
+ );
+});
+
+add_task(async function test_strict_compat() {
+ // Checks that strict compatibility is enabled for strict max
+ // versions other than "*", but not for advisory max versions.
+ // Also, ensure that strict max versions take precedence over
+ // advisory versions.
+
+ let { messages, result: updates } = await promiseConsoleOutput(() => {
+ return checkUpdates({
+ id: "updatecheck-strict@tests.mozilla.org",
+ version: "0.1",
+ updates: [
+ {
+ version: "0.2",
+ applications: { gecko: { strict_max_version: "*" } },
+ },
+ {
+ version: "0.3",
+ applications: { gecko: { strict_max_version: "43" } },
+ },
+ {
+ version: "0.4",
+ applications: { gecko: { advisory_max_version: "43" } },
+ },
+ {
+ version: "0.5",
+ applications: {
+ gecko: { advisory_max_version: "43", strict_max_version: "44" },
+ },
+ },
+ ],
+ });
+ });
+
+ equal(updates.length, 4, "all update items accepted");
+
+ equal(updates[0].targetApplications[0].maxVersion, "*");
+ equal(updates[0].strictCompatibility, false);
+
+ equal(updates[1].targetApplications[0].maxVersion, "43");
+ equal(updates[1].strictCompatibility, true);
+
+ equal(updates[2].targetApplications[0].maxVersion, "43");
+ equal(updates[2].strictCompatibility, false);
+
+ equal(updates[3].targetApplications[0].maxVersion, "44");
+ equal(updates[3].strictCompatibility, true);
+
+ messages = messages.filter(msg =>
+ /Ignoring 'advisory_max_version'.*'strict_max_version' also present/.test(
+ msg.message
+ )
+ );
+ equal(
+ messages.length,
+ 1,
+ "mix of advisory_max_version and strict_max_version generated the expected warning"
+ );
+});
+
+add_task(async function test_update_url_security() {
+ // Checks that update links to privileged URLs are not accepted.
+
+ let { messages, result: updates } = await promiseConsoleOutput(() => {
+ return checkUpdates({
+ id: "updatecheck-security@tests.mozilla.org",
+ version: "0.1",
+ updates: [
+ {
+ version: "0.2",
+ update_link: "chrome://browser/content/browser.xhtml",
+ update_hash:
+ "sha256:08ac852190ecd81f40a514ea9299fe9143d9ab5e296b97e73fb2a314de49648a",
+ },
+ {
+ version: "0.3",
+ update_link: "http://example.com/update.xpi",
+ update_hash:
+ "sha256:18ac852190ecd81f40a514ea9299fe9143d9ab5e296b97e73fb2a314de49648a",
+ },
+ ],
+ });
+ });
+
+ equal(updates.length, 2, "both updates were processed");
+ equal(updates[0].updateURL, null, "privileged update URL was removed");
+ equal(
+ updates[1].updateURL,
+ "http://example.com/update.xpi",
+ "safe update URL was accepted"
+ );
+
+ messages = messages.filter(msg =>
+ /http:\/\/localhost.*\/updates\/.*may not load or link to chrome:/.test(
+ msg.message
+ )
+ );
+ equal(
+ messages.length,
+ 1,
+ "privileged update URL generated the expected console message"
+ );
+});
+
+add_task(async function test_type_detection() {
+ // Checks that JSON update manifests are detected correctly
+ // regardless of extension or MIME type.
+
+ let tests = [
+ { contentType: "application/json", extension: "json", valid: true },
+ { contentType: "application/json", extension: "php", valid: true },
+ { contentType: "text/plain", extension: "json", valid: true },
+ { contentType: "application/octet-stream", extension: "json", valid: true },
+ { contentType: "text/plain", extension: "json?foo=bar", valid: true },
+ { contentType: "text/plain", extension: "php", valid: true },
+ { contentType: "text/plain", extension: "rdf", valid: true },
+ { contentType: "application/json", extension: "rdf", valid: true },
+ { contentType: "text/xml", extension: "json", valid: true },
+ { contentType: "application/rdf+xml", extension: "json", valid: true },
+ ];
+
+ for (let [i, test] of tests.entries()) {
+ let { messages } = await promiseConsoleOutput(async function () {
+ let id = `updatecheck-typedetection-${i}@tests.mozilla.org`;
+ let updates;
+ try {
+ updates = await checkUpdates({
+ id,
+ version: "0.1",
+ contentType: test.contentType,
+ manifestExtension: test.extension,
+ updates: [{ version: "0.2" }],
+ });
+ } catch (e) {
+ ok(!test.valid, "update manifest correctly detected as RDF");
+ return;
+ }
+
+ ok(test.valid, "update manifest correctly detected as JSON");
+ equal(updates.length, 1, "correct number of updates");
+ equal(updates[0].id, id, "update is for correct extension");
+ });
+
+ if (test.valid) {
+ // Make sure we don't get any XML parsing errors from the
+ // XMLHttpRequest machinery.
+ ok(
+ !messages.some(msg => /not well-formed/.test(msg.message)),
+ "expect XMLHttpRequest not to attempt XML parsing"
+ );
+ }
+
+ messages = messages.filter(msg =>
+ /Update manifest was not valid XML/.test(msg.message)
+ );
+ equal(
+ messages.length,
+ !test.valid,
+ "expected number of XML parsing errors"
+ );
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_updateid.js b/toolkit/mozapps/extensions/test/xpcshell/test_updateid.js
new file mode 100644
index 0000000000..c88c8e637b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_updateid.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that updating an add-on to a new ID does not work.
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+let testserver = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+
+const ID = "updateid@tests.mozilla.org";
+
+// Verify that an update to an add-on with a new ID fails
+add_task(async function test_update_new_id() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ await promiseStartupManager();
+
+ await promiseInstallWebExtension({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ update_url: "http://example.com/update.json",
+ },
+ },
+ },
+ });
+
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: { id: "differentid@tests.mozilla.org" },
+ },
+ },
+ });
+
+ testserver.registerFile("/addon.xpi", xpi);
+ AddonTestUtils.registerJSON(testserver, "/update.json", {
+ addons: {
+ [ID]: {
+ updates: [
+ {
+ version: "2.0",
+ update_link: "http://example.com/addon.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "1",
+ strict_max_version: "10",
+ },
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+
+ let update = await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ let install = update.updateAvailable;
+ Assert.notEqual(install, null, "Found available update");
+ Assert.equal(install.name, addon.name);
+ Assert.equal(install.version, "2.0");
+ Assert.equal(install.state, AddonManager.STATE_AVAILABLE);
+ Assert.equal(install.existingAddon, addon);
+
+ await Assert.rejects(
+ install.install(),
+ err => install.error == AddonManager.ERROR_INCORRECT_ID,
+ "Upgrade to a different ID fails"
+ );
+
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_updateversion.js b/toolkit/mozapps/extensions/test/xpcshell/test_updateversion.js
new file mode 100644
index 0000000000..4d1510c40f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_updateversion.js
@@ -0,0 +1,101 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+let server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+
+async function serverRegisterUpdate({ id, version, actualVersion }) {
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ version: actualVersion,
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+
+ server.registerFile("/addon.xpi", xpi);
+ AddonTestUtils.registerJSON(server, "/update.json", {
+ addons: {
+ [id]: {
+ updates: [{ version, update_link: "http://example.com/addon.xpi" }],
+ },
+ },
+ });
+}
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_update_version_mismatch() {
+ const ID = "updateversion@tests.mozilla.org";
+ await promiseInstallWebExtension({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ update_url: "http://example.com/update.json",
+ },
+ },
+ },
+ });
+
+ await serverRegisterUpdate({
+ id: ID,
+ version: "2.0",
+ actualVersion: "2.0.0",
+ });
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+
+ let update = await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ let install = update.updateAvailable;
+ Assert.notEqual(install, false, "Found available update");
+ Assert.equal(install.version, "2.0");
+ Assert.equal(install.state, AddonManager.STATE_AVAILABLE);
+ Assert.equal(install.existingAddon, addon);
+
+ await Assert.rejects(
+ install.install(),
+ err => install.error == AddonManager.ERROR_UNEXPECTED_ADDON_VERSION,
+ "Should refuse installation when downloaded version does not match"
+ );
+
+ await addon.uninstall();
+});
+
+add_task(async function test_update_version_empty() {
+ const ID = "updateversionempty@tests.mozilla.org";
+ await serverRegisterUpdate({ id: ID, version: "", actualVersion: "1.0" });
+
+ await promiseInstallWebExtension({
+ manifest: {
+ version: "0",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ update_url: "http://example.com/update.json",
+ },
+ },
+ },
+ });
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "0");
+ let update = await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ // The only item in the updates array has version "" (empty). This should not
+ // be offered as an available update because it is certainly not newer.
+ Assert.equal(update.updateAvailable, false, "No update found");
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_upgrade.js b/toolkit/mozapps/extensions/test/xpcshell/test_upgrade.js
new file mode 100644
index 0000000000..6e5f221625
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_upgrade.js
@@ -0,0 +1,199 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that app upgrades produce the expected behaviours,
+// with strict compatibility checking disabled.
+
+Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, false);
+
+// Enable loading extensions from the application scope
+Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION
+);
+Services.prefs.setIntPref("extensions.sideloadScopes", AddonManager.SCOPE_ALL);
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const globalDir = Services.dirsvc.get("XREAddonAppDir", Ci.nsIFile);
+globalDir.append("extensions");
+
+var gGlobalExisted = globalDir.exists();
+var gInstallTime = Date.now();
+
+const ID1 = "addon1@tests.mozilla.org";
+const ID2 = "addon2@tests.mozilla.org";
+const ID3 = "addon3@tests.mozilla.org";
+const ID4 = "addon4@tests.mozilla.org";
+const PATH4 = PathUtils.join(globalDir.path, `${ID4}.xpi`);
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ // Will be compatible in the first version and incompatible in subsequent versions
+ let xpi = await createAddon({
+ id: ID1,
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "1",
+ },
+ ],
+ targetPlatforms: [{ os: "XPCShell" }, { os: "WINNT_x86" }],
+ });
+ await manuallyInstall(xpi, profileDir, ID1);
+
+ // Works in all tested versions
+ xpi = await createAddon({
+ id: ID2,
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "2",
+ },
+ ],
+ targetPlatforms: [
+ {
+ os: "XPCShell",
+ abi: "noarch-spidermonkey",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, profileDir, ID2);
+
+ // Will be disabled in the first version and enabled in the second.
+ xpi = createAddon({
+ id: ID3,
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "2",
+ maxVersion: "2",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, profileDir, ID3);
+
+ // Will be compatible in both versions but will change version in between
+ xpi = await createAddon({
+ id: ID4,
+ version: "1.0",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "1",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, globalDir, ID4);
+ await promiseSetExtensionModifiedTime(PATH4, gInstallTime);
+});
+
+registerCleanupFunction(function end_test() {
+ if (!gGlobalExisted) {
+ globalDir.remove(true);
+ } else {
+ globalDir.append(do_get_expected_addon_name(ID4));
+ globalDir.remove(true);
+ }
+});
+
+// Test that the test extensions are all installed
+add_task(async function test_1() {
+ await promiseStartupManager();
+
+ let [a1, a2, a3, a4] = await promiseAddonsByIDs([ID1, ID2, ID3, ID4]);
+ Assert.notEqual(a1, null, "Found extension 1");
+ Assert.equal(a1.isActive, true, "Extension 1 is active");
+
+ Assert.notEqual(a2, null, "Found extension 2");
+ Assert.equal(a2.isActive, true, "Extension 2 is active");
+
+ Assert.notEqual(a3, null, "Found extension 3");
+ Assert.equal(a3.isActive, false, "Extension 3 is not active");
+
+ Assert.notEqual(a4, null);
+ Assert.equal(a4.isActive, true);
+ Assert.equal(a4.version, "1.0");
+});
+
+// Test that upgrading the application doesn't disable now incompatible add-ons
+add_task(async function test_2() {
+ await promiseShutdownManager();
+
+ // Upgrade the extension
+ let xpi = createAddon({
+ id: ID4,
+ version: "2.0",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "2",
+ maxVersion: "2",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, globalDir, ID4);
+ await promiseSetExtensionModifiedTime(PATH4, gInstallTime);
+
+ await promiseStartupManager("2");
+ let [a1, a2, a3, a4] = await promiseAddonsByIDs([ID1, ID2, ID3, ID4]);
+ Assert.notEqual(a1, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a1.id));
+
+ Assert.notEqual(a2, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a2.id));
+
+ Assert.notEqual(a3, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a3.id));
+
+ Assert.notEqual(a4, null);
+ Assert.ok(isExtensionInBootstrappedList(globalDir, a4.id));
+ Assert.equal(a4.version, "2.0");
+});
+
+// Test that nothing changes when only the build ID changes.
+add_task(async function test_3() {
+ await promiseShutdownManager();
+
+ // Upgrade the extension
+ let xpi = createAddon({
+ id: ID4,
+ version: "3.0",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "3",
+ maxVersion: "3",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, globalDir, ID4);
+ await promiseSetExtensionModifiedTime(PATH4, gInstallTime);
+
+ // Simulates a simple Build ID change
+ gAddonStartup.remove(true);
+ await promiseStartupManager();
+
+ let [a1, a2, a3, a4] = await promiseAddonsByIDs([ID1, ID2, ID3, ID4]);
+
+ Assert.notEqual(a1, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a1.id));
+
+ Assert.notEqual(a2, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a2.id));
+
+ Assert.notEqual(a3, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a3.id));
+
+ Assert.notEqual(a4, null);
+ Assert.ok(isExtensionInBootstrappedList(globalDir, a4.id));
+ Assert.equal(a4.version, "2.0");
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_upgrade_incompatible.js b/toolkit/mozapps/extensions/test/xpcshell/test_upgrade_incompatible.js
new file mode 100644
index 0000000000..b9eb0f31e1
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_upgrade_incompatible.js
@@ -0,0 +1,73 @@
+// Tests that when an extension manifest that was previously valid becomes
+// unparseable after an application update, the extension becomes
+// disabled. (See bug 1439600 for a concrete example of a situation where
+// this happened).
+add_task(async function test_upgrade_incompatible() {
+ const ID = "incompatible-upgrade@tests.mozilla.org";
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ await promiseStartupManager();
+
+ let file = createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ let { addon } = await promiseInstallFile(file);
+
+ notEqual(addon, null);
+ equal(addon.appDisabled, false);
+
+ await promiseShutdownManager();
+
+ // Create a new, incompatible extension
+ let newfile = createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ manifest_version: 1,
+ },
+ });
+
+ // swap the incompatible extension in for the original
+ let path = PathUtils.join(gProfD.path, "extensions", `${ID}.xpi`);
+ let fileInfo = await IOUtils.stat(path);
+ let timestamp = fileInfo.lastModified;
+
+ await IOUtils.move(newfile.path, path);
+ await promiseSetExtensionModifiedTime(path, timestamp);
+ Services.obs.notifyObservers(new FileUtils.File(path), "flush-cache-entry");
+
+ // Restart. With the change to the DB schema we recompute compatibility.
+ // With an unparseable manifest the addon should become disabled.
+ Services.prefs.setIntPref("extensions.databaseSchema", 0);
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ notEqual(addon, null);
+ equal(addon.appDisabled, true);
+
+ await promiseShutdownManager();
+
+ file = createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ // swap the old extension back in and check that we don't persist the disabled state forever.
+ await IOUtils.move(file.path, path);
+ await promiseSetExtensionModifiedTime(path, timestamp);
+ Services.obs.notifyObservers(new FileUtils.File(path), "flush-cache-entry");
+
+ // Restart. With the change to the DB schema we recompute compatibility.
+ Services.prefs.setIntPref("extensions.databaseSchema", 0);
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ notEqual(addon, null);
+ equal(addon.appDisabled, false);
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js b/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js
new file mode 100644
index 0000000000..cd4b376117
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js
@@ -0,0 +1,676 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const ID = "webextension1@tests.mozilla.org";
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const ADDONS = {
+ webextension_1: {
+ "manifest.json": {
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: "webextension1@tests.mozilla.org",
+ },
+ },
+ icons: {
+ 48: "icon48.png",
+ 64: "icon64.png",
+ },
+ },
+ "chrome.manifest": "content webex ./\n",
+ },
+ webextension_3: {
+ "manifest.json": {
+ name: "Web Extensiøn __MSG_name__",
+ description: "Descriptïon __MSG_desc__ of add-on",
+ version: "1.0",
+ manifest_version: 2,
+ default_locale: "en",
+ browser_specific_settings: {
+ gecko: {
+ id: "webextension3@tests.mozilla.org",
+ },
+ },
+ },
+ "_locales/en/messages.json": {
+ name: {
+ message: "foo ☹",
+ description: "foo",
+ },
+ desc: {
+ message: "bar ☹",
+ description: "bar",
+ },
+ },
+ "_locales/fr/messages.json": {
+ name: {
+ message: "le foo ☺",
+ description: "foo",
+ },
+ desc: {
+ message: "le bar ☺",
+ description: "bar",
+ },
+ },
+ },
+};
+
+let chromeReg = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+const {
+ ExtensionParent: { GlobalManager },
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+add_task(async function test_1() {
+ await promiseStartupManager();
+
+ equal(GlobalManager.extensionMap.size, 0);
+
+ let { addon } = await AddonTestUtils.promiseInstallXPI(ADDONS.webextension_1);
+
+ equal(GlobalManager.extensionMap.size, 1);
+ ok(GlobalManager.extensionMap.has(ID));
+
+ Assert.throws(
+ () =>
+ chromeReg.convertChromeURL(
+ Services.io.newURI("chrome://webex/content/webex.xul")
+ ),
+ error => error.result == Cr.NS_ERROR_FILE_NOT_FOUND,
+ "Chrome manifest should not have been registered"
+ );
+
+ let uri = do_get_addon_root_uri(profileDir, ID);
+
+ checkAddon(ID, addon, {
+ version: "1.0",
+ name: "Web Extension Name",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ isSystem: false,
+ type: "extension",
+ isWebExtension: true,
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ iconURL: `${uri}icon48.png`,
+ });
+
+ // Should persist through a restart
+ await promiseShutdownManager();
+
+ equal(GlobalManager.extensionMap.size, 0);
+
+ await promiseStartupManager();
+
+ equal(GlobalManager.extensionMap.size, 1);
+ ok(GlobalManager.extensionMap.has(ID));
+
+ addon = await promiseAddonByID(ID);
+
+ uri = do_get_addon_root_uri(profileDir, ID);
+
+ checkAddon(ID, addon, {
+ version: "1.0",
+ name: "Web Extension Name",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ isSystem: false,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ iconURL: `${uri}icon48.png`,
+ });
+
+ await addon.disable();
+
+ equal(GlobalManager.extensionMap.size, 0);
+
+ await addon.enable();
+
+ equal(GlobalManager.extensionMap.size, 1);
+ ok(GlobalManager.extensionMap.has(ID));
+
+ await addon.uninstall();
+
+ equal(GlobalManager.extensionMap.size, 0);
+ Assert.ok(!GlobalManager.extensionMap.has(ID));
+
+ await promiseShutdownManager();
+});
+
+// Writing the manifest direct to the profile should work
+add_task(async function test_2() {
+ await promiseWriteWebManifestForExtension(
+ {
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ },
+ profileDir
+ );
+
+ await promiseStartupManager();
+
+ let addon = await promiseAddonByID(ID);
+ checkAddon(ID, addon, {
+ version: "1.0",
+ name: "Web Extension Name",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ isSystem: false,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ });
+
+ await addon.uninstall();
+
+ await promiseRestartManager();
+});
+
+add_task(async function test_manifest_localization() {
+ const extensionId = "webextension3@tests.mozilla.org";
+
+ let { addon } = await AddonTestUtils.promiseInstallXPI(ADDONS.webextension_3);
+
+ await addon.disable();
+
+ checkAddon(ID, addon, {
+ name: "Web Extensiøn foo ☹",
+ description: "Descriptïon bar ☹ of add-on",
+ });
+
+ await restartWithLocales(["fr-FR"]);
+
+ addon = await promiseAddonByID(extensionId);
+ checkAddon(ID, addon, {
+ name: "Web Extensiøn le foo ☺",
+ description: "Descriptïon le bar ☺ of add-on",
+ });
+
+ await restartWithLocales(["de"]);
+
+ addon = await promiseAddonByID(extensionId);
+ checkAddon(ID, addon, {
+ name: "Web Extensiøn foo ☹",
+ description: "Descriptïon bar ☹ of add-on",
+ });
+
+ await addon.uninstall();
+});
+
+// Missing version should cause a failure
+add_task(async function test_3() {
+ await promiseWriteWebManifestForExtension(
+ {
+ name: "Web Extension Name",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ },
+ profileDir
+ );
+
+ await promiseRestartManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon, null);
+
+ let file = getFileForAddon(profileDir, ID);
+ Assert.ok(!file.exists());
+
+ await promiseRestartManager();
+});
+
+// Incorrect manifest version should cause a failure
+add_task(async function test_4() {
+ await promiseWriteWebManifestForExtension(
+ {
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 1,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ },
+ profileDir
+ );
+
+ await promiseRestartManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon, null);
+
+ let file = getFileForAddon(profileDir, ID);
+ Assert.ok(!file.exists());
+
+ await promiseRestartManager();
+});
+
+// Test that the "options_ui" manifest section is processed correctly.
+add_task(async function test_options_ui() {
+ let OPTIONS_RE =
+ /^moz-extension:\/\/[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}\/options\.html$/;
+
+ const extensionId = "webextension@tests.mozilla.org";
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: extensionId } },
+ options_ui: {
+ page: "options.html",
+ },
+ },
+ });
+
+ checkAddon(extensionId, addon, {
+ optionsType: AddonManager.OPTIONS_TYPE_INLINE_BROWSER,
+ });
+
+ ok(
+ OPTIONS_RE.test(addon.optionsURL),
+ "Addon should have a moz-extension: options URL for /options.html"
+ );
+
+ await addon.uninstall();
+
+ const ID2 = "webextension2@tests.mozilla.org";
+ addon = await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID2 } },
+ options_ui: {
+ page: "options.html",
+ open_in_tab: true,
+ },
+ },
+ });
+
+ checkAddon(ID2, addon, {
+ optionsType: AddonManager.OPTIONS_TYPE_TAB,
+ });
+
+ ok(
+ OPTIONS_RE.test(addon.optionsURL),
+ "Addon should have a moz-extension: options URL for /options.html"
+ );
+
+ await addon.uninstall();
+});
+
+// Test that experiments permissions add the appropriate dependencies.
+add_task(async function test_experiments_dependencies() {
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "meh@experiment" } },
+ permissions: ["experiments.meh"],
+ },
+ });
+
+ checkAddon(addon.id, addon, {
+ dependencies: ["meh@experiments.addons.mozilla.org"],
+ // Add-on should be app disabled due to missing dependencies
+ appDisabled: true,
+ });
+
+ await addon.uninstall();
+});
+
+add_task(async function developerShouldOverride() {
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ default_locale: "en",
+ developer: {
+ name: "__MSG_name__",
+ url: "__MSG_url__",
+ },
+ author: "Will be overridden by developer",
+ homepage_url: "https://will.be.overridden",
+ },
+ files: {
+ "_locales/en/messages.json": `{
+ "name": {
+ "message": "en name"
+ },
+ "url": {
+ "message": "https://example.net/en"
+ }
+ }`,
+ },
+ });
+
+ checkAddon(ID, addon, {
+ creator: "en name",
+ homepageURL: "https://example.net/en",
+ });
+
+ await addon.uninstall();
+});
+
+add_task(async function test_invalid_developer_does_not_override() {
+ for (const { type, manifestProps, files } of [
+ {
+ type: "dictionary",
+ manifestProps: {
+ dictionaries: {
+ "en-US": "en-US.dic",
+ },
+ },
+ files: {
+ "en-US.dic": "",
+ "en-US.aff": "",
+ },
+ },
+ {
+ type: "theme",
+ manifestProps: {
+ theme: {
+ colors: {
+ frame: "#FFF",
+ tab_background_text: "#000",
+ },
+ },
+ },
+ },
+ {
+ type: "locale",
+ manifestProps: {
+ langpack_id: "und",
+ languages: {
+ und: {
+ chrome_resources: {
+ global: "chrome/und/locale/und/global",
+ },
+ version: "20190326174300",
+ },
+ },
+ },
+ },
+ ]) {
+ const id = `${type}@mozilla.com`;
+ const creator = "Some author";
+ const homepageURL = "https://example.net";
+
+ info(`== loading add-on with id=${id} ==`);
+
+ for (let developer of [{}, null, { name: null, url: null }]) {
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ author: creator,
+ homepage_url: homepageURL,
+ developer,
+ browser_specific_settings: { gecko: { id } },
+ ...manifestProps,
+ },
+ files,
+ });
+
+ checkAddon(id, addon, { type, creator, homepageURL });
+
+ await addon.uninstall();
+ }
+ }
+});
+
+add_task(async function authorNotString() {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ for (let author of [{}, [], 42]) {
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ author,
+ manifest_version: 2,
+ name: "Web Extension Name",
+ version: "1.0",
+ },
+ });
+
+ checkAddon(ID, addon, {
+ creator: null,
+ });
+
+ await addon.uninstall();
+ }
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+});
+
+add_task(async function testThemeExtension() {
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ author: "Some author",
+ manifest_version: 2,
+ name: "Web Extension Name",
+ version: "1.0",
+ theme: { images: { theme_frame: "example.png" } },
+ },
+ });
+
+ checkAddon(ID, addon, {
+ creator: "Some author",
+ version: "1.0",
+ name: "Web Extension Name",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: false,
+ userDisabled: true,
+ isSystem: false,
+ type: "theme",
+ isWebExtension: true,
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ });
+
+ await addon.uninstall();
+
+ // Also test one without a proper 'theme' section.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ addon = await promiseInstallWebExtension({
+ manifest: {
+ author: "Some author",
+ manifest_version: 2,
+ name: "Web Extension Name",
+ version: "1.0",
+ theme: null,
+ },
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ checkAddon(ID, addon, {
+ type: "extension",
+ isWebExtension: true,
+ });
+
+ await addon.uninstall();
+});
+
+// Test that we can update from a webextension to a webextension-theme
+add_task(async function test_theme_upgrade() {
+ // First install a regular webextension
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ version: "1.0",
+ name: "Test WebExtension 1 (temporary)",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ },
+ });
+
+ checkAddon(ID, addon, {
+ version: "1.0",
+ name: "Test WebExtension 1 (temporary)",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ });
+
+ // Create a webextension theme with the same ID
+ addon = await promiseInstallWebExtension({
+ manifest: {
+ version: "2.0",
+ name: "Test WebExtension 1 (temporary)",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ theme: { images: { theme_frame: "example.png" } },
+ },
+ });
+
+ checkAddon(ID, addon, {
+ version: "2.0",
+ name: "Test WebExtension 1 (temporary)",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ // This is what we're really interested in:
+ type: "theme",
+ isWebExtension: true,
+ });
+
+ await addon.uninstall();
+
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon, null);
+});
+
+add_task(async function test_developer_properties() {
+ const name = "developer-name";
+ const url = "https://example.org";
+
+ for (const { type, manifestProps, files } of [
+ {
+ type: "dictionary",
+ manifestProps: {
+ dictionaries: {
+ "en-US": "en-US.dic",
+ },
+ },
+ files: {
+ "en-US.dic": "",
+ "en-US.aff": "",
+ },
+ },
+ {
+ type: "statictheme",
+ manifestProps: {
+ theme: {
+ colors: {
+ frame: "#FFF",
+ tab_background_text: "#000",
+ },
+ },
+ },
+ },
+ {
+ type: "langpack",
+ manifestProps: {
+ langpack_id: "und",
+ languages: {
+ und: {
+ chrome_resources: {
+ global: "chrome/und/locale/und/global",
+ },
+ version: "20190326174300",
+ },
+ },
+ },
+ },
+ ]) {
+ const id = `${type}@mozilla.com`;
+
+ info(`== loading add-on with id=${id} ==`);
+
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ developer: {
+ name,
+ url,
+ },
+ author: "Will be overridden by developer",
+ homepage_url: "https://will.be.overridden",
+ browser_specific_settings: { gecko: { id } },
+ ...manifestProps,
+ },
+ files,
+ });
+
+ checkAddon(id, addon, { creator: name, homepageURL: url });
+
+ await addon.uninstall();
+ }
+});
+
+add_task(async function test_invalid_homepage_and_developer_urls() {
+ const INVALID_URLS = [
+ "chrome://browser/content/",
+ "data:text/json,...",
+ "javascript:;",
+ "/",
+ "not-an-url",
+ ];
+ const EXPECTED_ERROR_RE =
+ /Access denied for URL|may not load or link to|is not a valid URL/;
+
+ for (let url of INVALID_URLS) {
+ // First, we verify `homepage_url`, which has a `url` "format" defined
+ // since it exists.
+ let normalized = await ExtensionTestUtils.normalizeManifest({
+ homepage_url: url,
+ });
+ ok(
+ EXPECTED_ERROR_RE.test(normalized.error),
+ `got expected error for ${url}`
+ );
+
+ // The `developer.url` now has a "format" but it was a late addition so we
+ // are only raising a warning instead of an error.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ normalized = await ExtensionTestUtils.normalizeManifest({
+ developer: { url },
+ });
+ ok(!normalized.error, "expected no error");
+ ok(
+ // Despites this prop being named `errors`, we are checking the warnings
+ // here.
+ EXPECTED_ERROR_RE.test(normalized.errors[0]),
+ `got expected warning for ${url}`
+ );
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ }
+});
+
+add_task(async function test_valid_homepage_and_developer_urls() {
+ let normalized = await ExtensionTestUtils.normalizeManifest({
+ developer: { url: "https://example.com" },
+ });
+ ok(!normalized.error, "expected no error");
+
+ normalized = await ExtensionTestUtils.normalizeManifest({
+ homepage_url: "https://example.com",
+ });
+ ok(!normalized.error, "expected no error");
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_events.js b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_events.js
new file mode 100644
index 0000000000..dd4222ec88
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_events.js
@@ -0,0 +1,94 @@
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+add_task(async function () {
+ let triggered = {};
+ const { Management } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+ );
+ for (let event of ["install", "uninstall", "update"]) {
+ triggered[event] = false;
+ Management.on(event, () => (triggered[event] = true));
+ }
+
+ async function expectEvents(expected, fn) {
+ let events = Object.keys(expected);
+ for (let event of events) {
+ triggered[event] = false;
+ }
+
+ await fn();
+ await new Promise(executeSoon);
+
+ for (let event of events) {
+ equal(
+ triggered[event],
+ expected[event],
+ `Event ${event} was${expected[event] ? "" : " not"} triggered`
+ );
+ }
+ }
+
+ await promiseStartupManager();
+
+ const id = "webextension@tests.mozilla.org";
+
+ // Install version 1.0, shouldn't see any events
+ await expectEvents({ update: false, uninstall: false }, async () => {
+ await promiseInstallWebExtension({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+ });
+
+ // Install version 2.0, we should get an update event but not an uninstall
+ await expectEvents({ update: true, uninstall: false }, async () => {
+ await promiseInstallWebExtension({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+ });
+
+ // Install version 3.0 as a temporary addon, we should again get
+ // update but not uninstall
+ let v3 = createTempWebExtensionFile({
+ manifest: {
+ version: "3.0",
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+
+ await expectEvents({ update: true, uninstall: false }, () =>
+ AddonManager.installTemporaryAddon(v3)
+ );
+
+ // Uninstall the temporary addon, this causes version 2.0 still installed
+ // in the profile to be revealed. Again, this results in an update event.
+ let addon = await promiseAddonByID(id);
+ await expectEvents({ update: true, uninstall: false }, () =>
+ addon.uninstall()
+ );
+
+ // Re-install version 3.0 as a temporary addon
+ await AddonManager.installTemporaryAddon(v3);
+
+ // Now shut down the addons manager, this should cause the temporary
+ // addon to be uninstalled which reveals 2.0 from the profile.
+ await expectEvents({ update: true, uninstall: false }, () =>
+ promiseShutdownManager()
+ );
+
+ // When we start up again we should not see any events
+ await expectEvents({ install: false }, () => promiseStartupManager());
+
+ addon = await promiseAddonByID(id);
+
+ // When we uninstall from the profile, the addon is now gone, we should
+ // get an uninstall events.
+ await expectEvents({ update: false, uninstall: true }, () =>
+ addon.uninstall()
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_icons.js b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_icons.js
new file mode 100644
index 0000000000..112a2c8410
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_icons.js
@@ -0,0 +1,212 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const ID = "webextension1@tests.mozilla.org";
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+profileDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+async function testSimpleIconsetParsing(manifest) {
+ await promiseWriteWebManifestForExtension(manifest, profileDir);
+
+ await promiseRestartManager();
+
+ let uri = do_get_addon_root_uri(profileDir, ID);
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+
+ function check_icons(addon_copy) {
+ deepEqual(addon_copy.icons, {
+ 16: uri + "icon16.png",
+ 32: uri + "icon32.png",
+ 48: uri + "icon48.png",
+ 64: uri + "icon64.png",
+ });
+
+ // iconURL should map to icons[48]
+ equal(addon.iconURL, uri + "icon48.png");
+
+ // AddonManager gets the correct icon sizes from addon.icons
+ equal(AddonManager.getPreferredIconURL(addon, 1), uri + "icon16.png");
+ equal(AddonManager.getPreferredIconURL(addon, 16), uri + "icon16.png");
+ equal(AddonManager.getPreferredIconURL(addon, 30), uri + "icon32.png");
+ equal(AddonManager.getPreferredIconURL(addon, 48), uri + "icon48.png");
+ equal(AddonManager.getPreferredIconURL(addon, 64), uri + "icon64.png");
+ equal(AddonManager.getPreferredIconURL(addon, 128), uri + "icon64.png");
+ }
+
+ check_icons(addon);
+
+ // check if icons are persisted through a restart
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+
+ check_icons(addon);
+
+ await addon.uninstall();
+}
+
+async function testRetinaIconsetParsing(manifest) {
+ await promiseWriteWebManifestForExtension(manifest, profileDir);
+
+ await promiseRestartManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+
+ let uri = do_get_addon_root_uri(profileDir, ID);
+
+ // AddonManager displays larger icons for higher pixel density
+ equal(
+ AddonManager.getPreferredIconURL(addon, 32, {
+ devicePixelRatio: 2,
+ }),
+ uri + "icon64.png"
+ );
+
+ equal(
+ AddonManager.getPreferredIconURL(addon, 48, {
+ devicePixelRatio: 2,
+ }),
+ uri + "icon128.png"
+ );
+
+ equal(
+ AddonManager.getPreferredIconURL(addon, 64, {
+ devicePixelRatio: 2,
+ }),
+ uri + "icon128.png"
+ );
+
+ await addon.uninstall();
+}
+
+async function testNoIconsParsing(manifest) {
+ await promiseWriteWebManifestForExtension(manifest, profileDir);
+
+ await promiseRestartManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+
+ deepEqual(addon.icons, {});
+
+ equal(addon.iconURL, null);
+
+ equal(AddonManager.getPreferredIconURL(addon, 128), null);
+
+ await addon.uninstall();
+}
+
+// Test simple icon set parsing
+add_task(async function () {
+ await promiseStartupManager();
+ await testSimpleIconsetParsing({
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ icons: {
+ 16: "icon16.png",
+ 32: "icon32.png",
+ 48: "icon48.png",
+ 64: "icon64.png",
+ },
+ });
+
+ // Now for theme-type extensions too.
+ await testSimpleIconsetParsing({
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ icons: {
+ 16: "icon16.png",
+ 32: "icon32.png",
+ 48: "icon48.png",
+ 64: "icon64.png",
+ },
+ theme: { images: { theme_frame: "example.png" } },
+ });
+});
+
+// Test AddonManager.getPreferredIconURL for retina screen sizes
+add_task(async function () {
+ await testRetinaIconsetParsing({
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ icons: {
+ 32: "icon32.png",
+ 48: "icon48.png",
+ 64: "icon64.png",
+ 128: "icon128.png",
+ 256: "icon256.png",
+ },
+ });
+
+ await testRetinaIconsetParsing({
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ icons: {
+ 32: "icon32.png",
+ 48: "icon48.png",
+ 64: "icon64.png",
+ 128: "icon128.png",
+ 256: "icon256.png",
+ },
+ theme: { images: { theme_frame: "example.png" } },
+ });
+});
+
+// Handles no icons gracefully
+add_task(async function () {
+ await testNoIconsParsing({
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ });
+
+ await testNoIconsParsing({
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ theme: { images: { theme_frame: "example.png" } },
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_install.js b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_install.js
new file mode 100644
index 0000000000..c01f3ad703
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_install.js
@@ -0,0 +1,694 @@
+let profileDir;
+add_task(async function setup() {
+ profileDir = gProfD.clone();
+ profileDir.append("extensions");
+
+ if (!profileDir.exists()) {
+ profileDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ }
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+ await promiseStartupManager();
+});
+
+const IMPLICIT_ID_XPI = "data/webext-implicit-id.xpi";
+const IMPLICIT_ID_ID = "webext_implicit_id@tests.mozilla.org";
+
+// webext-implicit-id.xpi has a minimal manifest with no
+// applications or browser_specific_settings, so its id comes
+// from its signature, which should be the ID constant defined below.
+add_task(async function test_implicit_id() {
+ let addon = await promiseAddonByID(IMPLICIT_ID_ID);
+ equal(addon, null, "Add-on is not installed");
+
+ await promiseInstallFile(do_get_file(IMPLICIT_ID_XPI));
+
+ addon = await promiseAddonByID(IMPLICIT_ID_ID);
+ notEqual(addon, null, "Add-on is installed");
+
+ await addon.uninstall();
+});
+
+// We should also be able to install webext-implicit-id.xpi temporarily
+// and it should look just like the regular install (ie, the ID should
+// come from the signature)
+add_task(async function test_implicit_id_temp() {
+ let addon = await promiseAddonByID(IMPLICIT_ID_ID);
+ equal(addon, null, "Add-on is not installed");
+
+ let xpifile = do_get_file(IMPLICIT_ID_XPI);
+ await AddonManager.installTemporaryAddon(xpifile);
+
+ addon = await promiseAddonByID(IMPLICIT_ID_ID);
+ notEqual(addon, null, "Add-on is installed");
+
+ // The sourceURI of a temporary installed addon should be equal to the
+ // file url of the installed xpi file.
+ equal(
+ addon.sourceURI && addon.sourceURI.spec,
+ Services.io.newFileURI(xpifile).spec,
+ "SourceURI of the add-on has the expected value"
+ );
+
+ await addon.uninstall();
+});
+
+// Test that extension install error attach the detailed error messages to the
+// Error object.
+add_task(async function test_invalid_extension_install_errors() {
+ const manifest = {
+ name: "invalid",
+ browser_specific_settings: {
+ gecko: {
+ id: "invalid@tests.mozilla.org",
+ },
+ },
+ description: "extension with an invalid 'matches' value",
+ manifest_version: 2,
+ content_scripts: [
+ {
+ matches: "*://*.foo.com/*",
+ js: ["content.js"],
+ },
+ ],
+ version: "1.0",
+ };
+
+ const addonDir = await promiseWriteWebManifestForExtension(
+ manifest,
+ gTmpD,
+ "the-addon-sub-dir"
+ );
+
+ await Assert.rejects(
+ AddonManager.installTemporaryAddon(addonDir),
+ err => {
+ return (
+ err.additionalErrors.length == 1 &&
+ err.additionalErrors[0] ==
+ `Reading manifest: Error processing content_scripts.0.matches: ` +
+ `Expected array instead of "*://*.foo.com/*"`
+ );
+ },
+ "Exception has the proper additionalErrors details"
+ );
+
+ Services.obs.notifyObservers(addonDir, "flush-cache-entry");
+ addonDir.remove(true);
+});
+
+// We should be able to temporarily install an unsigned web extension
+// that does not have an ID in its manifest.
+add_task(async function test_unsigned_no_id_temp_install() {
+ AddonTestUtils.useRealCertChecks = true;
+ const manifest = {
+ name: "no ID",
+ description: "extension without an ID",
+ manifest_version: 2,
+ version: "1.0",
+ };
+
+ const addonDir = await promiseWriteWebManifestForExtension(
+ manifest,
+ gTmpD,
+ "the-addon-sub-dir"
+ );
+ const testDate = new Date();
+ const addon = await AddonManager.installTemporaryAddon(addonDir);
+
+ ok(addon.id, "ID should have been auto-generated");
+ ok(
+ Math.abs(addon.installDate - testDate) < 10000,
+ "addon has an expected installDate"
+ );
+ ok(
+ Math.abs(addon.updateDate - testDate) < 10000,
+ "addon has an expected updateDate"
+ );
+
+ // The sourceURI of a temporary installed addon should be equal to the
+ // file url of the installed source dir.
+ equal(
+ addon.sourceURI && addon.sourceURI.spec,
+ Services.io.newFileURI(addonDir).spec,
+ "SourceURI of the add-on has the expected value"
+ );
+
+ // Install the same directory again, as if re-installing or reloading.
+ const secondAddon = await AddonManager.installTemporaryAddon(addonDir);
+ // The IDs should be the same.
+ equal(secondAddon.id, addon.id, "Reinstalled add-on has the expected ID");
+ equal(
+ secondAddon.installDate.valueOf(),
+ addon.installDate.valueOf(),
+ "Reloaded add-on has the expected installDate."
+ );
+
+ await secondAddon.uninstall();
+ Services.obs.notifyObservers(addonDir, "flush-cache-entry");
+ addonDir.remove(true);
+ AddonTestUtils.useRealCertChecks = false;
+});
+
+// We should be able to install two extensions from manifests without IDs
+// at different locations and get two unique extensions.
+add_task(async function test_multiple_no_id_extensions() {
+ AddonTestUtils.useRealCertChecks = true;
+ const manifest = {
+ name: "no ID",
+ description: "extension without an ID",
+ manifest_version: 2,
+ version: "1.0",
+ };
+
+ let extension1 = ExtensionTestUtils.loadExtension({
+ manifest,
+ useAddonManager: "temporary",
+ });
+
+ let extension2 = ExtensionTestUtils.loadExtension({
+ manifest,
+ useAddonManager: "temporary",
+ });
+
+ await Promise.all([extension1.startup(), extension2.startup()]);
+
+ const allAddons = await AddonManager.getAllAddons();
+
+ info(`Found these add-ons: ${allAddons.map(a => a.name).join(", ")}`);
+ const filtered = allAddons.filter(addon => addon.name === manifest.name);
+ // Make sure we have two add-ons by the same name.
+ equal(filtered.length, 2, "Two add-ons are installed with the same name");
+
+ await extension1.unload();
+ await extension2.unload();
+ AddonTestUtils.useRealCertChecks = false;
+});
+
+// Test that we can get the ID from browser_specific_settings
+add_task(async function test_bss_id() {
+ const ID = "webext_bss_id@tests.mozilla.org";
+
+ let manifest = {
+ name: "bss test",
+ description: "test that ID may be in browser_specific_settings",
+ manifest_version: 2,
+ version: "1.0",
+
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ };
+
+ let addon = await promiseAddonByID(ID);
+ equal(addon, null, "Add-on is not installed");
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ addon = await promiseAddonByID(ID);
+ notEqual(addon, null, "Add-on is installed");
+
+ await extension.unload();
+});
+
+// Test that if we have IDs in both browser_specific_settings and applications,
+// that we prefer the ID in browser_specific_settings.
+add_task(async function test_two_ids() {
+ const GOOD_ID = "two_ids@tests.mozilla.org";
+ const BAD_ID = "i_am_obsolete@tests.mozilla.org";
+
+ let manifest = {
+ name: "two id test",
+ description:
+ "test a web extension with ids in both applications and browser_specific_settings",
+ manifest_version: 2,
+ version: "1.0",
+
+ applications: {
+ gecko: {
+ id: BAD_ID,
+ },
+ },
+
+ browser_specific_settings: {
+ gecko: {
+ id: GOOD_ID,
+ },
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let addon = await promiseAddonByID(BAD_ID);
+ equal(addon, null, "Add-on is not found using bad ID");
+ addon = await promiseAddonByID(GOOD_ID);
+ notEqual(addon, null, "Add-on is found using good ID");
+
+ await extension.unload();
+});
+
+// Test that strict_min_version and strict_max_version are enforced for
+// loading temporary extension.
+add_task(async function test_strict_min_max() {
+ // the app version being compared to is 1.9.2
+ const addonId = "strict_min_max@tests.mozilla.org";
+ const MANIFEST = {
+ name: "strict min max test",
+ description: "test strict min and max with temporary loading",
+ manifest_version: 2,
+ version: "1.0",
+ };
+
+ // bad max good min
+ let apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ };
+ let testManifest = Object.assign(apps, MANIFEST);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ let expectedMsg = new RegExp(
+ "Add-on strict_min_max@tests.mozilla.org is not compatible with application version. " +
+ "add-on minVersion: 1. add-on maxVersion: 1."
+ );
+ await Assert.rejects(
+ extension.startup(),
+ expectedMsg,
+ "Install rejects when specified maxVersion is not valid"
+ );
+
+ let addon = await promiseAddonByID(addonId);
+ equal(addon, null, "Add-on is not installed");
+
+ // bad min good max
+ apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_min_version: "2",
+ strict_max_version: "2",
+ },
+ },
+ };
+ testManifest = Object.assign(apps, MANIFEST);
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ expectedMsg = new RegExp(
+ "Add-on strict_min_max@tests.mozilla.org is not compatible with application version. " +
+ "add-on minVersion: 2. add-on maxVersion: 2."
+ );
+ await Assert.rejects(
+ extension.startup(),
+ expectedMsg,
+ "Install rejects when specified minVersion is not valid"
+ );
+
+ addon = await promiseAddonByID(addonId);
+ equal(addon, null, "Add-on is not installed");
+
+ // bad both
+ apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_min_version: "2",
+ strict_max_version: "1",
+ },
+ },
+ };
+ testManifest = Object.assign(apps, MANIFEST);
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ expectedMsg = new RegExp(
+ "Add-on strict_min_max@tests.mozilla.org is not compatible with application version. " +
+ "add-on minVersion: 2. add-on maxVersion: 1."
+ );
+ await Assert.rejects(
+ extension.startup(),
+ expectedMsg,
+ "Install rejects when specified minVersion and maxVersion are not valid"
+ );
+
+ addon = await promiseAddonByID(addonId);
+ equal(addon, null, "Add-on is not installed");
+
+ // bad only min
+ apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_min_version: "2",
+ },
+ },
+ };
+ testManifest = Object.assign(apps, MANIFEST);
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ expectedMsg = new RegExp(
+ "Add-on strict_min_max@tests.mozilla.org is not compatible with application version. " +
+ "add-on minVersion: 2."
+ );
+ await Assert.rejects(
+ extension.startup(),
+ expectedMsg,
+ "Install rejects when specified minVersion and maxVersion are not valid"
+ );
+
+ addon = await promiseAddonByID(addonId);
+ equal(addon, null, "Add-on is not installed");
+
+ // bad only max
+ apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_max_version: "1",
+ },
+ },
+ };
+ testManifest = Object.assign(apps, MANIFEST);
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ expectedMsg = new RegExp(
+ "Add-on strict_min_max@tests.mozilla.org is not compatible with application version. " +
+ "add-on maxVersion: 1."
+ );
+ await Assert.rejects(
+ extension.startup(),
+ expectedMsg,
+ "Install rejects when specified minVersion and maxVersion are not valid"
+ );
+
+ addon = await promiseAddonByID(addonId);
+ equal(addon, null, "Add-on is not installed");
+
+ // good both
+ apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_min_version: "1",
+ strict_max_version: "2",
+ },
+ },
+ };
+ testManifest = Object.assign(apps, MANIFEST);
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ addon = await promiseAddonByID(addonId);
+
+ notEqual(addon, null, "Add-on is installed");
+ equal(addon.id, addonId, "Installed add-on has the expected ID");
+ await extension.unload();
+
+ // good only min
+ let newId = "strict_min_only@tests.mozilla.org";
+ apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: newId,
+ strict_min_version: "1",
+ },
+ },
+ };
+ testManifest = Object.assign(apps, MANIFEST);
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ addon = await promiseAddonByID(newId);
+
+ notEqual(addon, null, "Add-on is installed");
+ equal(addon.id, newId, "Installed add-on has the expected ID");
+
+ await extension.unload();
+
+ // good only max
+ newId = "strict_max_only@tests.mozilla.org";
+ apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: newId,
+ strict_max_version: "2",
+ },
+ },
+ };
+ testManifest = Object.assign(apps, MANIFEST);
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ addon = await promiseAddonByID(newId);
+
+ notEqual(addon, null, "Add-on is installed");
+ equal(addon.id, newId, "Installed add-on has the expected ID");
+
+ await extension.unload();
+
+ // * in min will throw an error
+ for (let version of ["0.*", "0.*.0"]) {
+ newId = "strict_min_star@tests.mozilla.org";
+ let minStarApps = {
+ browser_specific_settings: {
+ gecko: {
+ id: newId,
+ strict_min_version: version,
+ },
+ },
+ };
+
+ let minStarTestManifest = Object.assign(minStarApps, MANIFEST);
+
+ let minStarExtension = ExtensionTestUtils.loadExtension({
+ manifest: minStarTestManifest,
+ useAddonManager: "temporary",
+ });
+
+ await Assert.rejects(
+ minStarExtension.startup(),
+ /The use of '\*' in strict_min_version is invalid/,
+ "loading an extension with a * in strict_min_version throws an exception"
+ );
+
+ let minStarAddon = await promiseAddonByID(newId);
+ equal(minStarAddon, null, "Add-on is not installed");
+ }
+
+ // incompatible extension but with compatibility checking off
+ newId = "checkCompatibility@tests.mozilla.org";
+ apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: newId,
+ strict_max_version: "1",
+ },
+ },
+ };
+ testManifest = Object.assign(apps, MANIFEST);
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ let savedCheckCompatibilityValue = AddonManager.checkCompatibility;
+ AddonManager.checkCompatibility = false;
+ await extension.startup();
+ addon = await promiseAddonByID(newId);
+
+ notEqual(addon, null, "Add-on is installed");
+ equal(addon.id, newId, "Installed add-on has the expected ID");
+
+ await extension.unload();
+ AddonManager.checkCompatibility = savedCheckCompatibilityValue;
+});
+
+// Check permissions prompt
+add_task(async function test_permissions_prompt() {
+ const manifest = {
+ name: "permissions test",
+ description: "permissions test",
+ manifest_version: 2,
+ version: "1.0",
+
+ permissions: ["tabs", "storage", "https://*.example.com/*", "<all_urls>"],
+ };
+
+ let xpi = ExtensionTestCommon.generateXPI({ manifest });
+
+ let install = await AddonManager.getInstallForFile(xpi);
+
+ let perminfo;
+ install.promptHandler = info => {
+ perminfo = info;
+ return Promise.resolve();
+ };
+
+ await install.install();
+
+ notEqual(perminfo, undefined, "Permission handler was invoked");
+ equal(
+ perminfo.existingAddon,
+ null,
+ "Permission info does not include an existing addon"
+ );
+ notEqual(perminfo.addon, null, "Permission info includes the new addon");
+ let perms = perminfo.addon.userPermissions;
+ deepEqual(
+ perms.permissions,
+ ["tabs", "storage"],
+ "API permissions are correct"
+ );
+ deepEqual(
+ perms.origins,
+ ["https://*.example.com/*", "<all_urls>"],
+ "Host permissions are correct"
+ );
+
+ let addon = await promiseAddonByID(perminfo.addon.id);
+ notEqual(addon, null, "Extension was installed");
+
+ await addon.uninstall();
+ await IOUtils.remove(xpi.path);
+});
+
+// Check permissions prompt cancellation
+add_task(async function test_permissions_prompt_cancel() {
+ const manifest = {
+ name: "permissions test",
+ description: "permissions test",
+ manifest_version: 2,
+ version: "1.0",
+
+ permissions: ["webRequestBlocking"],
+ };
+
+ let xpi = ExtensionTestCommon.generateXPI({ manifest });
+
+ let install = await AddonManager.getInstallForFile(xpi);
+
+ let perminfo;
+ install.promptHandler = info => {
+ perminfo = info;
+ return Promise.reject();
+ };
+
+ await promiseCompleteInstall(install);
+
+ notEqual(perminfo, undefined, "Permission handler was invoked");
+
+ let addon = await promiseAddonByID(perminfo.addon.id);
+ equal(addon, null, "Extension was not installed");
+
+ await IOUtils.remove(xpi.path);
+});
+
+// Test that presence of 'edge' property in 'browser_specific_settings' doesn't prevent installation from completing successfully
+add_task(async function test_non_gecko_bss_install() {
+ const ID = "ms_edge@tests.mozilla.org";
+
+ const manifest = {
+ name: "MS Edge and unknown browser test",
+ description:
+ "extension with bss properties for 'edge', and 'unknown_browser'",
+ manifest_version: 2,
+ version: "1.0",
+ applications: { gecko: { id: ID } },
+ browser_specific_settings: {
+ edge: {
+ browser_action_next_to_addressbar: true,
+ },
+ unknown_browser: {
+ unknown_setting: true,
+ },
+ },
+ };
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ useAddonManager: "temporary",
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ const addon = await promiseAddonByID(ID);
+ notEqual(addon, null, "Add-on is installed");
+
+ await extension.unload();
+});
+
+// Test that bss overrides applications if both are present.
+add_task(async function test_duplicate_bss() {
+ const ID = "expected@tests.mozilla.org";
+
+ const manifest = {
+ manifest_version: 2,
+ version: "1.0",
+ applications: {
+ gecko: { id: "unexpected@tests.mozilla.org" },
+ },
+ browser_specific_settings: {
+ gecko: { id: ID },
+ },
+ };
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ useAddonManager: "temporary",
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ const addon = await promiseAddonByID(ID);
+ notEqual(addon, null, "Add-on is installed");
+
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_install_syntax_error.js b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_install_syntax_error.js
new file mode 100644
index 0000000000..0edc6ec5a4
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_install_syntax_error.js
@@ -0,0 +1,42 @@
+const ADDON_ID = "webext-test@tests.mozilla.org";
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ await promiseStartupManager();
+});
+
+add_task(async function install_xpi() {
+ // WebExtension with a JSON syntax error in manifest.json
+ let xpi1 = AddonTestUtils.createTempWebExtensionFile({
+ files: {
+ "manifest.json": String.raw`{
+ "manifest_version: 2,
+ "browser_specific_settings": {"gecko": {"id": "${ADDON_ID}"}},
+ "name": "Temp WebExt with Error",
+ "version": "0.1"
+ }`,
+ },
+ });
+
+ // Valid WebExtension
+ let xpi2 = AddonTestUtils.createTempWebExtensionFile({
+ files: {
+ "manifest.json": String.raw`{
+ "manifest_version": 2,
+ "browser_specific_settings": {"gecko": {"id": "${ADDON_ID}"}},
+ "name": "Temp WebExt without Error",
+ "version": "0.1"
+ }`,
+ },
+ });
+
+ let install1 = await AddonManager.getInstallForFile(xpi1);
+ Assert.equal(install1.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install1.error, AddonManager.ERROR_CORRUPT_FILE);
+
+ // Replace xpi1 with xpi2 to have the same filename to reproduce install error
+ xpi2.moveTo(xpi1.parent, xpi1.leafName);
+
+ let install2 = await AddonManager.getInstallForFile(xpi2);
+ Assert.equal(install2.error, 0);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_langpack.js b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_langpack.js
new file mode 100644
index 0000000000..40b09d3111
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_langpack.js
@@ -0,0 +1,669 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { ExtensionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyGetter(this, "resourceProtocol", () =>
+ Services.io
+ .getProtocolHandler("resource")
+ .QueryInterface(Ci.nsIResProtocolHandler)
+);
+
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+const ID = "langpack-und@test.mozilla.org";
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+// Langpacks versions follow the following convention:
+// <firefox major>.<firefox minor>.YYYYMMDD.HHmmss
+// with no leading zeros allowed (as enforced per version format, see MDN doc page at https://mzl.la/3M6L15y).
+//
+// See https://searchfox.org/mozilla-central/rev/26790fe/python/mozbuild/mozbuild/action/langpack_manifest.py#388-398
+var server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+AddonTestUtils.registerJSON(server, "/test_update_langpack.json", {
+ addons: {
+ "langpack-und@test.mozilla.org": {
+ updates: [
+ {
+ version: "58.0.20230105.121014",
+ applications: {
+ gecko: {
+ strict_min_version: "58.0",
+ strict_max_version: "58.*",
+ },
+ },
+ },
+ {
+ version: "60.0.20230207.112555",
+ update_link:
+ "http://example.com/addons/langpack-und@test.mozilla.org.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "60.0",
+ strict_max_version: "60.*",
+ },
+ },
+ },
+ {
+ version: "60.1.20230309.91233",
+ update_link:
+ "http://example.com/addons/dotrelease/langpack-und@test.mozilla.org.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "60.0",
+ strict_max_version: "60.*",
+ },
+ },
+ },
+ ],
+ },
+ },
+});
+
+// A second update url, which is included in the last of the langpack
+// version from the previous one (and used to cover the staging of a
+// langpack from one dotrelease to another).
+AddonTestUtils.registerJSON(server, "/test_update_langpack2.json", {
+ addons: {
+ "langpack-und@test.mozilla.org": {
+ updates: [
+ {
+ version: "60.2.20230319.94511",
+ update_link:
+ "http://example.com/addons/dotrelease2/langpack-und@test.mozilla.org.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "60.0",
+ strict_max_version: "60.*",
+ },
+ },
+ },
+ ],
+ },
+ },
+});
+
+function promisePostponeInstall(install) {
+ return new Promise((resolve, reject) => {
+ let listener = {
+ onDownloadEnded: () => {
+ install.postpone();
+ },
+ 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);
+ });
+}
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "58");
+
+const ADDONS = {
+ langpack_1: {
+ "browser/localization/und/browser.ftl":
+ "message-browser = Value from Browser\n",
+ "localization/und/toolkit_test.ftl": "message-id1 = Value 1\n",
+ "chrome/und/locale/und/global/test.properties":
+ "message = Value from .properties\n",
+ "manifest.json": {
+ name: "und Language Pack",
+ version: "58.0.20230105.121014",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ strict_min_version: "58.0",
+ strict_max_version: "58.*",
+ update_url: "http://example.com/test_update_langpack.json",
+ },
+ },
+ sources: {
+ browser: {
+ base_path: "browser/",
+ },
+ },
+ langpack_id: "und",
+ languages: {
+ und: {
+ chrome_resources: {
+ global: "chrome/und/locale/und/global/",
+ },
+ version: "20171001190118",
+ },
+ },
+ author: "Mozilla Localization Task Force",
+ description: "Language pack for Testy for und",
+ },
+ },
+};
+
+// clone the extension so we can create an update.
+const langpack_update = JSON.parse(JSON.stringify(ADDONS.langpack_1));
+langpack_update["manifest.json"].version = "60.0.20230207.112555";
+langpack_update["manifest.json"].browser_specific_settings.gecko = {
+ id: ID,
+ strict_min_version: "60.0",
+ strict_max_version: "60.*",
+ update_url: "http://example.com/test_update_langpack.json",
+};
+
+const langpack_update_dotrelease = JSON.parse(
+ JSON.stringify(ADDONS.langpack_1)
+);
+langpack_update_dotrelease["manifest.json"].version = "60.1.20230309.91233";
+langpack_update_dotrelease["manifest.json"].browser_specific_settings.gecko = {
+ id: ID,
+ strict_min_version: "60.0",
+ strict_max_version: "60.*",
+ update_url: "http://example.com/test_update_langpack2.json",
+};
+
+// Another langpack for another dot release part of the same major version as the previous one.
+const langpack_update_dotrelease2 = JSON.parse(
+ JSON.stringify(ADDONS.langpack_1)
+);
+langpack_update_dotrelease2["manifest.json"].version = "60.2.20230319.94511";
+langpack_update_dotrelease2["manifest.json"].browser_specific_settings.gecko = {
+ id: ID,
+ strict_min_version: "60.0",
+ strict_max_version: "60.*",
+ update_url: "http://example.com/test_update_langpack2.json",
+};
+
+let xpi = AddonTestUtils.createTempXPIFile(langpack_update);
+server.registerFile(`/addons/${ID}.xpi`, xpi);
+
+let xpiDotRelease = AddonTestUtils.createTempXPIFile(
+ langpack_update_dotrelease
+);
+server.registerFile(`/addons/dotrelease/${ID}.xpi`, xpiDotRelease);
+
+let xpiDotRelease2 = AddonTestUtils.createTempXPIFile(
+ langpack_update_dotrelease2
+);
+server.registerFile(`/addons/dotrelease2/${ID}.xpi`, xpiDotRelease2);
+
+function promiseLangpackStartup() {
+ return new Promise(resolve => {
+ const EVENT = "webextension-langpack-startup";
+ Services.obs.addObserver(function observer() {
+ Services.obs.removeObserver(observer, EVENT);
+ resolve();
+ }, EVENT);
+ });
+}
+
+add_task(async function setup() {
+ Services.prefs.clearUserPref("extensions.startupScanScopes");
+});
+
+/**
+ * This is a basic life-cycle test which verifies that
+ * the language pack registers and unregisters correct
+ * languages at various stages.
+ */
+add_task(async function test_basic_lifecycle() {
+ await promiseStartupManager();
+
+ // Make sure that `und` locale is not installed.
+ equal(
+ L10nRegistry.getInstance().getAvailableLocales().includes("und"),
+ false,
+ "und not installed"
+ );
+ equal(
+ Services.locale.availableLocales.includes("und"),
+ false,
+ "und not available"
+ );
+
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+
+ // Now make sure that `und` locale is available.
+ equal(
+ L10nRegistry.getInstance().getAvailableLocales().includes("und"),
+ true,
+ "und is installed"
+ );
+ equal(
+ Services.locale.availableLocales.includes("und"),
+ true,
+ "und is available"
+ );
+
+ await addon.disable();
+
+ // It is not available after the langpack has been disabled.
+ equal(
+ L10nRegistry.getInstance().getAvailableLocales().includes("und"),
+ false,
+ "und not installed"
+ );
+ equal(
+ Services.locale.availableLocales.includes("und"),
+ false,
+ "und not available"
+ );
+
+ // This quirky code here allows us to handle a scenario where enabling the
+ // addon is synchronous or asynchronous.
+ await Promise.all([promiseLangpackStartup(), addon.enable()]);
+
+ // After re-enabling it, the `und` locale is available again.
+ equal(
+ L10nRegistry.getInstance().getAvailableLocales().includes("und"),
+ true,
+ "und is installed"
+ );
+ equal(
+ Services.locale.availableLocales.includes("und"),
+ true,
+ "und is available"
+ );
+
+ await addon.uninstall();
+
+ // After the langpack has been uninstalled, no more `und` in locales.
+ equal(
+ L10nRegistry.getInstance().getAvailableLocales().includes("und"),
+ false,
+ "und not installed"
+ );
+ equal(
+ Services.locale.availableLocales.includes("und"),
+ false,
+ "und not available"
+ );
+});
+
+/**
+ * This test verifies that registries are able to load and synchronously return
+ * correct strings available in the language pack.
+ */
+add_task(async function test_locale_registries() {
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+
+ {
+ // Toolkit string
+ let bundles = L10nRegistry.getInstance().generateBundlesSync(
+ ["und"],
+ ["toolkit_test.ftl"]
+ );
+ let bundle0 = bundles.next().value;
+ ok(bundle0);
+ equal(bundle0.hasMessage("message-id1"), true);
+ }
+
+ {
+ // Browser string
+ let bundles = L10nRegistry.getInstance().generateBundlesSync(
+ ["und"],
+ ["browser.ftl"]
+ );
+ let bundle0 = bundles.next().value;
+ ok(bundle0);
+ equal(bundle0.hasMessage("message-browser"), true);
+ }
+
+ {
+ // Test chrome package
+ let reqLocs = Services.locale.requestedLocales;
+ Services.locale.requestedLocales = ["und"];
+
+ let bundle = Services.strings.createBundle(
+ "chrome://global/locale/test.properties"
+ );
+ let entry = bundle.GetStringFromName("message");
+ equal(entry, "Value from .properties");
+
+ Services.locale.requestedLocales = reqLocs;
+ }
+
+ await addon.uninstall();
+});
+
+/**
+ * This test verifies that registries are able to load and asynchronously return
+ * correct strings available in the language pack.
+ */
+add_task(async function test_locale_registries_async() {
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+
+ {
+ // Toolkit string
+ let bundles = L10nRegistry.getInstance().generateBundles(
+ ["und"],
+ ["toolkit_test.ftl"]
+ );
+ let bundle0 = (await bundles.next()).value;
+ equal(bundle0.hasMessage("message-id1"), true);
+ }
+
+ {
+ // Browser string
+ let bundles = L10nRegistry.getInstance().generateBundles(
+ ["und"],
+ ["browser.ftl"]
+ );
+ let bundle0 = (await bundles.next()).value;
+ equal(bundle0.hasMessage("message-browser"), true);
+ }
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
+
+add_task(async function test_langpack_app_shutdown() {
+ let langpackId = `langpack-und-${AppConstants.MOZ_BUILD_APP.replace(
+ "/",
+ "-"
+ )}`;
+ let check = (yes, msg) => {
+ equal(resourceProtocol.hasSubstitution(langpackId), yes, msg);
+ };
+
+ await promiseStartupManager();
+
+ check(false, "no initial resource substitution");
+
+ await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+
+ check(true, "langpack resource available after startup");
+
+ await promiseShutdownManager();
+
+ check(true, "langpack resource available after app shutdown");
+
+ await promiseStartupManager();
+
+ let addon = await AddonManager.getAddonByID(ID);
+ await addon.uninstall();
+
+ check(false, "langpack resource removed during shutdown for uninstall");
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_amazing_disappearing_langpacks() {
+ let check = yes => {
+ equal(
+ L10nRegistry.getInstance().getAvailableLocales().includes("und"),
+ yes,
+ "check L10nRegistry"
+ );
+ equal(
+ Services.locale.availableLocales.includes("und"),
+ yes,
+ "check availableLocales"
+ );
+ };
+
+ await promiseStartupManager();
+
+ check(false);
+
+ await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+
+ check(true);
+
+ await promiseShutdownManager();
+
+ check(false);
+
+ await AddonTestUtils.manuallyUninstall(AddonTestUtils.profileExtensions, ID);
+
+ await promiseStartupManager();
+
+ check(false);
+});
+
+/**
+ * This test verifies that language pack will get disabled after app
+ * gets upgraded.
+ */
+add_task(async function test_disable_after_app_update() {
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+ Assert.ok(addon.isActive);
+
+ await promiseRestartManager("59");
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(!addon.isActive);
+ Assert.ok(addon.appDisabled);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
+
+/**
+ * This test verifies that a postponed language pack update will be
+ * applied after a restart.
+ */
+add_task(async function test_after_app_update() {
+ await promiseStartupManager("58");
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+ Assert.ok(addon.isActive);
+
+ await promiseRestartManager("60");
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(!addon.isActive);
+ Assert.ok(addon.appDisabled);
+ Assert.equal(addon.version, "58.0.20230105.121014");
+
+ let update = await promiseFindAddonUpdates(addon);
+ Assert.ok(update.updateAvailable, "update is available");
+ let install = update.updateAvailable;
+ let postponed = promisePostponeInstall(install);
+ install.install();
+ await postponed;
+ Assert.equal(
+ install.state,
+ AddonManager.STATE_POSTPONED,
+ "install postponed"
+ );
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.version, "60.1.20230309.91233");
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
+
+// Support setting the request locale.
+function promiseLocaleChanged(requestedLocales) {
+ let changed = ExtensionUtils.promiseObserved(
+ "intl:requested-locales-changed"
+ );
+ Services.locale.requestedLocales = requestedLocales;
+ return changed;
+}
+
+/**
+ * This test verifies that an addon update for the next version can be
+ * retrieved and staged for restart.
+ */
+add_task(async function test_staged_langpack_for_app_update() {
+ let originalLocales = Services.locale.requestedLocales;
+
+ await promiseStartupManager("58");
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+ Assert.ok(addon.isActive);
+ await promiseLocaleChanged(["und"]);
+
+ // Mimick a major release update happening while a
+ // langpack from a dotrelease what already available
+ // (and then assert that the dotrelease langpack
+ // is the one staged and then installed on browser
+ // restart)
+ await AddonManager.stageLangpacksForAppUpdate("60");
+ await promiseRestartManager("60");
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.version, "60.1.20230309.91233");
+
+ // Mimick a second dotrelease update, along with
+ // staging of the langpacks released along with it
+ // (then assert that the langpack for the second
+ // dotrelease is staged and then installed on
+ // browser restart).
+ await promiseRestartManager("60.1");
+ await AddonManager.stageLangpacksForAppUpdate("60.2");
+ await promiseRestartManager("60.2");
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.version, "60.2.20230319.94511");
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+
+ Services.locale.requestedLocales = originalLocales;
+});
+
+/**
+ * This test verifies that an addon update for the next version can be
+ * retrieved and staged for restart, but a restart failure falls back.
+ */
+add_task(async function test_staged_langpack_for_app_update_fail() {
+ let originalLocales = Services.locale.requestedLocales;
+
+ await promiseStartupManager("58");
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+ Assert.ok(addon.isActive);
+ await promiseLocaleChanged(["und"]);
+
+ await AddonManager.stageLangpacksForAppUpdate("60");
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.version, "58.0.20230105.121014");
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ Services.locale.requestedLocales = originalLocales;
+});
+
+/**
+ * This test verifies that an update restart works when the langpack
+ * cannot be updated.
+ */
+add_task(async function test_staged_langpack_for_app_update_not_found() {
+ let originalLocales = Services.locale.requestedLocales;
+
+ await promiseStartupManager("58");
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+ Assert.ok(addon.isActive);
+ await promiseLocaleChanged(["und"]);
+
+ await AddonManager.stageLangpacksForAppUpdate("59");
+ await promiseRestartManager("59");
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.version, "58.0.20230105.121014");
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ Services.locale.requestedLocales = originalLocales;
+});
+
+/**
+ * This test verifies that a compat update with an invalid max_version
+ * will be disabled, at least allowing Firefox to startup without failures.
+ */
+add_task(async function test_staged_langpack_compat_startup() {
+ let originalLocales = Services.locale.requestedLocales;
+
+ await promiseStartupManager("58");
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+ Assert.ok(addon.isActive);
+ await promiseLocaleChanged(["und"]);
+
+ // Mimick a compatibility update
+ let compatUpdate = {
+ targetApplications: [
+ {
+ id: "toolkit@mozilla.org",
+ minVersion: "58",
+ maxVersion: "*",
+ },
+ ],
+ };
+ addon.__AddonInternal__.applyCompatibilityUpdate(compatUpdate);
+
+ await promiseRestartManager("59");
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(!addon.isActive, "addon is not active after upgrade");
+ ok(!addon.isCompatible, "compatibility update fixed");
+
+ await promiseRestartManager("58");
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.isActive, "addon is active after downgrade");
+ ok(addon.isCompatible, "compatibility update fixed");
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ Services.locale.requestedLocales = originalLocales;
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_paths.js b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_paths.js
new file mode 100644
index 0000000000..564e9086ac
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_paths.js
@@ -0,0 +1,47 @@
+let profileDir;
+add_task(async function setup() {
+ profileDir = gProfD.clone();
+ profileDir.append("extensions");
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+ await promiseStartupManager();
+});
+
+// When installing an unpacked addon we derive the ID from the
+// directory name. Make sure that if the directory name is not a valid
+// addon ID that we reject it.
+add_task(async function test_bad_unpacked_path() {
+ let MANIFEST_ID = "webext_bad_path@tests.mozilla.org";
+
+ let manifest = {
+ name: "path test",
+ description: "test of a bad directory name",
+ manifest_version: 2,
+ version: "1.0",
+
+ browser_specific_settings: {
+ gecko: {
+ id: MANIFEST_ID,
+ },
+ },
+ };
+
+ const directories = ["not a valid ID", '"quotes"@tests.mozilla.org'];
+
+ for (let dir of directories) {
+ try {
+ await promiseWriteWebManifestForExtension(manifest, profileDir, dir);
+ } catch (ex) {
+ // This can fail if the underlying filesystem (looking at you windows)
+ // doesn't handle some of the characters in the ID. In that case,
+ // just ignore this test on this platform.
+ continue;
+ }
+ await promiseRestartManager();
+
+ let addon = await promiseAddonByID(dir);
+ Assert.equal(addon, null);
+ addon = await promiseAddonByID(MANIFEST_ID);
+ Assert.equal(addon, null);
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_theme.js b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_theme.js
new file mode 100644
index 0000000000..8e8e79c2c7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_theme.js
@@ -0,0 +1,365 @@
+"use strict";
+
+/**
+ * This file contains test for 'theme' type WebExtension addons. Tests focus mostly
+ * on interoperability between the different theme formats (XUL and LWT) and
+ * Addon Manager integration.
+ *
+ * Coverage may overlap with other tests in this folder.
+ */
+
+const THEME_IDS = [
+ "theme3@tests.mozilla.org",
+ "theme2@personas.mozilla.org", // Unused. Legacy. Evil.
+ "default-theme@mozilla.org",
+];
+const REAL_THEME_IDS = [THEME_IDS[0], THEME_IDS[2]];
+const DEFAULT_THEME = THEME_IDS[2];
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION
+);
+
+// We remember the last/ currently active theme for tracking events.
+var gActiveTheme = null;
+
+add_task(async function setup_to_default_browserish_state() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ await promiseWriteWebManifestForExtension(
+ {
+ author: "Some author",
+ manifest_version: 2,
+ name: "Web Extension Name",
+ version: "1.0",
+ theme: { images: { theme_frame: "example.png" } },
+ browser_specific_settings: {
+ gecko: {
+ id: THEME_IDS[0],
+ },
+ },
+ },
+ profileDir
+ );
+
+ await promiseStartupManager();
+
+ if (AppConstants.MOZ_DEV_EDITION) {
+ // Developer Edition selects the wrong theme by default.
+ let defaultTheme = await AddonManager.getAddonByID(DEFAULT_THEME);
+ await defaultTheme.enable();
+ }
+
+ let [t1, t2, d] = await promiseAddonsByIDs(THEME_IDS);
+ Assert.ok(t1, "Theme addon should exist");
+ Assert.equal(t2, null, "Theme addon is not a thing anymore");
+ Assert.ok(d, "Theme addon should exist");
+
+ await t1.disable();
+ await new Promise(executeSoon);
+ Assert.ok(!t1.isActive, "Theme should be disabled");
+ Assert.ok(d.isActive, "Default theme should be active");
+
+ await promiseRestartManager();
+
+ [t1, t2, d] = await promiseAddonsByIDs(THEME_IDS);
+ Assert.ok(!t1.isActive, "Theme should still be disabled");
+ Assert.ok(d.isActive, "Default theme should still be active");
+
+ gActiveTheme = d.id;
+});
+
+/**
+ * Set the `userDisabled` property of one specific theme and check if the theme
+ * switching works as expected by checking the state of all installed themes.
+ *
+ * @param {String} which ID of the addon to set the `userDisabled` property on
+ * @param {Boolean} disabled Flag value to switch to
+ */
+async function setDisabledStateAndCheck(which, disabled = false) {
+ if (disabled) {
+ Assert.equal(which, gActiveTheme, "Only the active theme can be disabled");
+ }
+
+ let themeToDisable = disabled ? which : gActiveTheme;
+ let themeToEnable = disabled ? DEFAULT_THEME : which;
+
+ let expectedStates = {
+ [themeToDisable]: true,
+ [themeToEnable]: false,
+ };
+ let addonEvents = {
+ [themeToDisable]: [{ event: "onDisabling" }, { event: "onDisabled" }],
+ [themeToEnable]: [{ event: "onEnabling" }, { event: "onEnabled" }],
+ };
+
+ // Set the state of the theme to change.
+ let theme = await promiseAddonByID(which);
+ await expectEvents({ addonEvents }, () => {
+ if (disabled) {
+ theme.disable();
+ } else {
+ theme.enable();
+ }
+ });
+
+ let isDisabled;
+ for (theme of await promiseAddonsByIDs(REAL_THEME_IDS)) {
+ isDisabled = theme.id in expectedStates ? expectedStates[theme.id] : true;
+ Assert.equal(
+ theme.userDisabled,
+ isDisabled,
+ `Theme '${theme.id}' should be ${isDisabled ? "dis" : "en"}abled`
+ );
+ Assert.equal(
+ theme.pendingOperations,
+ AddonManager.PENDING_NONE,
+ "There should be no pending operations when no restart is expected"
+ );
+ Assert.equal(
+ theme.isActive,
+ !isDisabled,
+ `Theme '${theme.id} should be ${isDisabled ? "in" : ""}active`
+ );
+ }
+
+ await promiseRestartManager();
+
+ // All should still be good after a restart of the Addon Manager.
+ for (theme of await promiseAddonsByIDs(REAL_THEME_IDS)) {
+ isDisabled = theme.id in expectedStates ? expectedStates[theme.id] : true;
+ Assert.equal(
+ theme.userDisabled,
+ isDisabled,
+ `Theme '${theme.id}' should be ${isDisabled ? "dis" : "en"}abled`
+ );
+ Assert.equal(
+ theme.isActive,
+ !isDisabled,
+ `Theme '${theme.id}' should be ${isDisabled ? "in" : ""}active`
+ );
+ Assert.equal(
+ theme.pendingOperations,
+ AddonManager.PENDING_NONE,
+ "There should be no pending operations left"
+ );
+ if (!isDisabled) {
+ gActiveTheme = theme.id;
+ }
+ }
+}
+
+add_task(async function test_WebExtension_themes() {
+ // Enable the WebExtension theme.
+ await setDisabledStateAndCheck(THEME_IDS[0]);
+
+ // Disabling WebExtension should revert to the default theme.
+ await setDisabledStateAndCheck(THEME_IDS[0], true);
+
+ // Enable it again.
+ await setDisabledStateAndCheck(THEME_IDS[0]);
+});
+
+add_task(async function test_default_theme() {
+ // Explicitly enable the default theme.
+ await setDisabledStateAndCheck(DEFAULT_THEME);
+
+ // Swith to the WebExtension theme.
+ await setDisabledStateAndCheck(THEME_IDS[0]);
+
+ // Enable it again.
+ await setDisabledStateAndCheck(DEFAULT_THEME);
+});
+
+add_task(async function uninstall_offers_undo() {
+ let defaultTheme = await AddonManager.getAddonByID(DEFAULT_THEME);
+ const ID = THEME_IDS[0];
+ let theme = await promiseAddonByID(ID);
+
+ Assert.ok(theme, "Webextension theme is present");
+
+ async function promiseAddonEvent(event, id) {
+ let [addon] = await AddonTestUtils.promiseAddonEvent(event);
+ if (id) {
+ Assert.equal(addon.id, id, `Got event for expected addon (${event})`);
+ }
+ }
+
+ async function uninstallTheme() {
+ let uninstallingPromise = promiseAddonEvent("onUninstalling", ID);
+ await theme.uninstall(true);
+ await uninstallingPromise;
+
+ Assert.ok(
+ hasFlag(theme.pendingOperations, AddonManager.PENDING_UNINSTALL),
+ "Theme being uninstalled has PENDING_UNINSTALL flag"
+ );
+ }
+
+ async function cancelUninstallTheme() {
+ let cancelPromise = promiseAddonEvent("onOperationCancelled", ID);
+ theme.cancelUninstall();
+ await cancelPromise;
+
+ Assert.equal(
+ theme.pendingOperations,
+ AddonManager.PENDING_NONE,
+ "PENDING_UNINSTALL flag is cleared when uninstall is canceled"
+ );
+ }
+
+ // A theme should still be disabled if the uninstallation of a disabled theme
+ // is undone.
+ Assert.ok(!theme.isActive, "Webextension theme is not active");
+ Assert.ok(defaultTheme.isActive, "Default theme is active");
+ await uninstallTheme();
+ await cancelUninstallTheme();
+ Assert.ok(!theme.isActive, "Webextension theme is still not active");
+ Assert.ok(defaultTheme.isActive, "Default theme is still active");
+
+ // Enable theme, the previously active theme should be disabled.
+ await Promise.all([
+ promiseAddonEvent("onDisabled", DEFAULT_THEME),
+ promiseAddonEvent("onEnabled", ID),
+ theme.enable(),
+ ]);
+ Assert.ok(theme.isActive, "Webextension theme is active after enabling");
+ Assert.ok(!defaultTheme.isActive, "Default theme is not active any more");
+
+ // Uninstall active theme, default theme should become active.
+ await Promise.all([
+ // Note: no listener for onDisabled & ID because the uninstall is pending.
+ promiseAddonEvent("onEnabled", DEFAULT_THEME),
+ uninstallTheme(),
+ ]);
+ Assert.ok(!theme.isActive, "Webextension theme is not active upon uninstall");
+ Assert.ok(defaultTheme.isActive, "Default theme is active again");
+
+ // Undo uninstall, default theme should be deactivated.
+ await Promise.all([
+ // Note: no listener for onEnabled & ID because the uninstall was pending.
+ promiseAddonEvent("onDisabled", DEFAULT_THEME),
+ cancelUninstallTheme(),
+ ]);
+ Assert.ok(theme.isActive, "Webextension theme is active upon undo uninstall");
+ Assert.ok(!defaultTheme.isActive, "Default theme is not active again");
+
+ // Immediately remove the theme. Default theme should be activated.
+ await Promise.all([
+ promiseAddonEvent("onEnabled", DEFAULT_THEME),
+ theme.uninstall(),
+ ]);
+
+ await promiseRestartManager();
+});
+
+// Test that default_locale works with WE themes
+add_task(async function default_locale_themes() {
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ default_locale: "en",
+ name: "__MSG_name__",
+ description: "__MSG_description__",
+ theme: {
+ colors: {
+ frame: "black",
+ tab_background_text: "white",
+ },
+ },
+ },
+ files: {
+ "_locales/en/messages.json": `{
+ "name": {
+ "message": "the name"
+ },
+ "description": {
+ "message": "the description"
+ }
+ }`,
+ },
+ });
+
+ addon = await promiseAddonByID(addon.id);
+ equal(addon.name, "the name");
+ equal(addon.description, "the description");
+ equal(addon.type, "theme");
+ await addon.uninstall();
+});
+
+add_task(async function test_theme_update() {
+ let addon = await AddonManager.getAddonByID(DEFAULT_THEME);
+ ok(!addon.userDisabled, "default theme is enabled");
+
+ await AddonTestUtils.promiseRestartManager("2");
+
+ addon = await AddonManager.getAddonByID(DEFAULT_THEME);
+ ok(!addon.userDisabled, "default theme is enabled after upgrade");
+});
+
+add_task(async function test_builtin_theme_permissions() {
+ const ADDON_ID = "mytheme@mozilla.org";
+
+ let themeDef = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: ADDON_ID } },
+ version: "1.0",
+ theme: {},
+ },
+ };
+
+ function checkPerms(addon) {
+ // builtin themes enable or disable based on disabled state
+ Assert.equal(
+ addon.userDisabled,
+ hasFlag(addon.permissions, AddonManager.PERM_CAN_ENABLE),
+ "enable permission is correct"
+ );
+ Assert.equal(
+ !addon.userDisabled,
+ hasFlag(addon.permissions, AddonManager.PERM_CAN_DISABLE),
+ "disable permission is correct"
+ );
+ // builtin themes do not get any other permission
+ Assert.ok(
+ !hasFlag(addon.permissions, AddonManager.PERM_CAN_INSTALL),
+ "cannot install by user"
+ );
+ Assert.ok(
+ !hasFlag(addon.permissions, AddonManager.PERM_CAN_UPGRADE),
+ "cannot upgrade"
+ );
+ Assert.ok(
+ !hasFlag(addon.permissions, AddonManager.PERM_CAN_UNINSTALL),
+ "cannot uninstall"
+ );
+ Assert.ok(
+ !hasFlag(
+ addon.permissions,
+ AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
+ ),
+ "can change private browsing access"
+ );
+ Assert.ok(
+ hasFlag(addon.permissions, AddonManager.PERM_API_CAN_UNINSTALL),
+ "can uninstall via API"
+ );
+ }
+
+ await setupBuiltinExtension(themeDef, "first-loc", false);
+ await AddonManager.maybeInstallBuiltinAddon(
+ ADDON_ID,
+ "1.0",
+ "resource://first-loc/"
+ );
+
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ checkPerms(addon);
+ await addon.enable();
+ checkPerms(addon);
+
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/xpcshell-unpack.ini b/toolkit/mozapps/extensions/test/xpcshell/xpcshell-unpack.ini
new file mode 100644
index 0000000000..df6907c076
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell-unpack.ini
@@ -0,0 +1,13 @@
+ [DEFAULT]
+head = head_addons.js head_unpack.js
+tail =
+firefox-appdir = browser
+skip-if = toolkit == 'android'
+dupe-manifest =
+tags = addons
+
+[test_webextension_paths.js]
+tags = webextensions
+
+[test_filepointer.js]
+skip-if = !allow_legacy_extensions || require_signing
diff --git a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..1ad7344b2e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
@@ -0,0 +1,226 @@
+[DEFAULT]
+skip-if = toolkit == "android"
+tags = addons
+head = head_addons.js
+firefox-appdir = browser
+dupe-manifest =
+support-files =
+ data/**
+
+[test_aom_startup.js]
+[test_AbuseReporter.js]
+[test_AddonRepository.js]
+[test_AddonRepository_cache.js]
+[test_AddonRepository_cache_locale.js]
+[test_AddonRepository_langpacks.js]
+[test_AddonRepository_paging.js]
+[test_ProductAddonChecker_signatures.js]
+head = head_addons.js head_cert_handling.js
+[test_ProductAddonChecker.js]
+[test_QuarantinedDomains_AMRemoteSettings.js]
+head = head_addons.js head_amremotesettings.js
+[test_XPIStates.js]
+skip-if = condprof # Bug 1769184 - by design for now
+[test_XPIcancel.js]
+[test_addon_manager_telemetry_events.js]
+[test_addonStartup.js]
+[test_amo_stats_telemetry.js]
+[test_bad_json.js]
+[test_badschema.js]
+[test_builtin_location.js]
+[test_cacheflush.js]
+[test_childprocess.js]
+head =
+[test_colorways_builtin_theme_upgrades.js]
+skip-if = appname == "thunderbird" # Bug 1809438 - No colorways in Thunderbird
+[test_cookies.js]
+[test_corrupt.js]
+[test_crash_annotation_quoting.js]
+[test_db_path.js]
+head =
+[test_delay_update_webextension.js]
+tags = webextensions
+[test_dependencies.js]
+[test_dictionary_webextension.js]
+[test_distribution.js]
+[test_distribution_langpack.js]
+[test_embedderDisabled.js]
+[test_error.js]
+# Bug 1508482
+skip-if = os == "win"
+[test_ext_management.js]
+tags = webextensions
+[test_general.js]
+[test_getInstallSourceFromHost.js]
+[test_gmpProvider.js]
+skip-if =
+ appname != "firefox"
+[test_harness.js]
+[test_hidden.js]
+[test_install.js]
+[test_installOrigins.js]
+[test_install_cancel.js]
+[test_install_file_change.js]
+[test_install_icons.js]
+[test_installtrigger_schemes.js]
+[test_installtrigger_deprecation.js]
+head = head_addons.js head_amremotesettings.js
+[test_isDebuggable.js]
+[test_isReady.js]
+[test_loadManifest_isPrivileged.js]
+[test_locale.js]
+[test_moved_extension_metadata.js]
+skip-if = true # bug 1777900
+[test_no_addons.js]
+[test_nodisable_hidden.js]
+[test_onPropertyChanged_appDisabled.js]
+head = head_addons.js head_compat.js
+skip-if = tsan # Times out, bug 1674773
+[test_permissions.js]
+[test_permissions_prefs.js]
+[test_pref_properties.js]
+[test_provider_markSafe.js]
+[test_provider_shutdown.js]
+[test_provider_unsafe_access_shutdown.js]
+[test_provider_unsafe_access_startup.js]
+[test_proxies.js]
+skip-if = require_signing
+[test_recommendations.js]
+skip-if = require_signing
+[test_registerchrome.js]
+[test_registry.js]
+skip-if = os != "win"
+[test_reinstall_disabled_addon.js]
+[test_reload.js]
+# There's a problem removing a temp file without manually clearing the cache on Windows
+skip-if = os == "win"
+tags = webextensions
+[test_safemode.js]
+[test_schema_change.js]
+[test_seen.js]
+[test_shutdown.js]
+[test_shutdown_barriers.js]
+[test_shutdown_early.js]
+skip-if = condprof # Bug 1769184 - by design for now
+[test_sideload_scopes.js]
+head = head_addons.js head_sideload.js
+skip-if =
+ os == "linux" # Bug 1613268
+ condprof # Bug 1769184 - by design for now
+[test_sideloads.js]
+[test_sideloads_after_rebuild.js]
+skip-if = condprof # Bug 1769184 - by design for now
+head = head_addons.js head_sideload.js
+[test_signed_inject.js]
+# Bug 1394122
+skip-if = true
+[test_signed_install.js]
+[test_signed_langpack.js]
+[test_signed_long.js]
+[test_signed_updatepref.js]
+skip-if =
+ require_signing
+ !allow_legacy_extensions
+[test_signed_verify.js]
+[test_sitePermsAddonProvider.js]
+skip-if =
+ appname == "thunderbird" # Disabled in extensions.manifest
+[test_startup.js]
+head = head_addons.js head_sideload.js
+skip-if =
+ os == "linux" # Bug 1613268
+ condprof # Bug 1769184 - by design for now
+[test_startup_enable.js]
+[test_startup_isPrivileged.js]
+[test_startup_scan.js]
+head = head_addons.js head_sideload.js
+[test_strictcompatibility.js]
+head = head_addons.js head_compat.js
+[test_syncGUID.js]
+[test_system_allowed.js]
+head = head_addons.js head_system_addons.js
+[test_system_delay_update.js]
+head = head_addons.js head_system_addons.js
+[test_system_profile_location.js]
+[test_system_repository.js]
+head = head_addons.js head_system_addons.js
+[test_system_reset.js]
+head = head_addons.js head_system_addons.js
+[test_system_update_blank.js]
+head = head_addons.js head_system_addons.js
+[test_system_update_checkSizeHash.js]
+head = head_addons.js head_system_addons.js
+[test_system_update_custom.js]
+head = head_addons.js head_system_addons.js
+[test_system_update_empty.js]
+head = head_addons.js head_system_addons.js
+skip-if = true # Failing intermittently due to a race condition in the test, see bug 1348981
+[test_system_update_enterprisepolicy.js]
+head = head_addons.js head_system_addons.js
+[test_system_update_fail.js]
+head = head_addons.js head_system_addons.js
+[test_system_update_newset.js]
+head = head_addons.js head_system_addons.js
+[test_system_update_overlapping.js]
+head = head_addons.js head_system_addons.js
+[test_system_update_uninstall_check.js]
+head = head_addons.js head_system_addons.js
+[test_system_update_upgrades.js]
+head = head_addons.js head_system_addons.js
+[test_system_update_installTelemetryInfo.js]
+head = head_addons.js head_system_addons.js
+[test_system_upgrades.js]
+skip-if = condprof # Bug 1769184 - by design for now
+head = head_addons.js head_system_addons.js
+[test_systemaddomstartupprefs.js]
+skip-if = condprof # Bug 1769184 - by design for now
+head = head_addons.js head_system_addons.js
+[test_temporary.js]
+skip-if = os == "win" # Bug 1469904
+tags = webextensions
+[test_trash_directory.js]
+skip-if = os != "win"
+[test_types.js]
+[test_undouninstall.js]
+skip-if = os == "win" # Bug 1358846
+[test_update.js]
+[test_updateCancel.js]
+[test_update_addontype.js]
+[test_update_compatmode.js]
+head = head_addons.js head_compat.js
+[test_update_ignorecompat.js]
+skip-if = true # Bug 676922 Bug 1437697
+[test_update_isPrivileged.js]
+skip-if = condprof # Bug 1769184 - by design for now
+[test_update_noSystemAddonUpdate.js]
+head = head_addons.js head_system_addons.js
+[test_update_strictcompat.js]
+head = head_addons.js head_compat.js
+[test_update_theme.js]
+[test_update_webextensions.js]
+tags = webextensions
+[test_updatecheck.js]
+[test_updatecheck_errors.js]
+[test_updatecheck_json.js]
+[test_updateid.js]
+[test_updateversion.js]
+[test_upgrade.js]
+head = head_addons.js head_compat.js
+run-sequentially = Uses global XCurProcD dir.
+[test_upgrade_incompatible.js]
+[test_webextension.js]
+tags = webextensions
+[test_webextension_events.js]
+tags = webextensions
+[test_webextension_icons.js]
+tags = webextensions
+[test_webextension_install.js]
+tags = webextensions
+[test_webextension_install_syntax_error.js]
+tags = webextensions
+[test_webextension_langpack.js]
+tags = webextensions
+[test_webextension_theme.js]
+tags = webextensions
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
diff --git a/toolkit/mozapps/extensions/test/xpinstall/amosigned.xpi b/toolkit/mozapps/extensions/test/xpinstall/amosigned.xpi
new file mode 100644
index 0000000000..f2948e6994
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/amosigned.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpinstall/authRedirect.sjs b/toolkit/mozapps/extensions/test/xpinstall/authRedirect.sjs
new file mode 100644
index 0000000000..fffcb9f255
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/authRedirect.sjs
@@ -0,0 +1,21 @@
+// Simple script redirects to the query part of the uri if the browser
+// authenticates with username "testuser" password "testpass"
+
+function handleRequest(request, response) {
+ if (request.hasHeader("Authorization")) {
+ if (
+ request.getHeader("Authorization") == "Basic dGVzdHVzZXI6dGVzdHBhc3M="
+ ) {
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ response.setHeader("Location", request.queryString);
+ response.write("See " + request.queryString);
+ } else {
+ response.setStatusLine(request.httpVersion, 403, "Forbidden");
+ response.write("Invalid credentials");
+ }
+ } else {
+ response.setStatusLine(request.httpVersion, 401, "Authentication required");
+ response.setHeader("WWW-Authenticate", 'basic realm="XPInstall"', false);
+ response.write("Unauthenticated request");
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser.ini b/toolkit/mozapps/extensions/test/xpinstall/browser.ini
new file mode 100644
index 0000000000..7156e5e2c9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser.ini
@@ -0,0 +1,122 @@
+[DEFAULT]
+support-files =
+ amosigned.xpi
+ authRedirect.sjs
+ bug540558.html
+ bug638292.html
+ bug645699.html
+ cookieRedirect.sjs
+ corrupt.xpi
+ empty.xpi
+ enabled.html
+ hashRedirect.sjs
+ head.js
+ incompatible.xpi
+ installchrome.html
+ installtrigger.html
+ installtrigger_frame.html
+ navigate.html
+ recommended.xpi
+ redirect.sjs
+ slowinstall.sjs
+ startsoftwareupdate.html
+ triggerredirect.html
+ unsigned.xpi
+ unsigned_mv3.xpi
+ webmidi_permission.xpi
+ ../xpcshell/data/signing_checks/privileged.xpi
+
+[browser_amosigned_trigger.js]
+https_first_disabled = true # Bug 1737265
+[browser_amosigned_trigger_iframe.js]
+https_first_disabled = true # Bug 1737265
+[browser_amosigned_url.js]
+https_first_disabled = true # Bug 1737265
+[browser_auth.js]
+https_first_disabled = true # Bug 1737265
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_auth2.js]
+[browser_auth3.js]
+[browser_auth4.js]
+https_first_disabled = true # Bug 1737265
+[browser_badargs.js]
+https_first_disabled = true # Bug 1737265
+[browser_badargs2.js]
+https_first_disabled = true # Bug 1737265
+[browser_badhash.js]
+[browser_badhashtype.js]
+[browser_block_fullscreen_prompt.js]
+https_first_disabled = true # Bug 1737265
+skip-if = (os == 'mac' && debug) #Bug 1590136
+[browser_bug540558.js]
+https_first_disabled = true # Bug 1737265
+[browser_bug611242.js]
+[browser_bug638292.js]
+[browser_bug645699.js]
+[browser_bug645699_postDownload.js]
+[browser_bug672485.js]
+skip-if = true # disabled due to a leak. See bug 682410.
+[browser_containers.js]
+https_first_disabled = true # Bug 1737265
+[browser_cookies.js]
+[browser_cookies2.js]
+https_first_disabled = true # Bug 1737265
+[browser_cookies3.js]
+https_first_disabled = true # Bug 1737265
+[browser_cookies4.js]
+skip-if = true # Bug 1084646
+[browser_corrupt.js]
+https_first_disabled = true # Bug 1737265
+[browser_datauri.js]
+[browser_doorhanger_installs.js]
+https_first_disabled = true # Bug 1737265
+skip-if = (os == 'win' && os_version == '10.0' && bits == 64) #Bug 1615449
+[browser_empty.js]
+[browser_enabled.js]
+[browser_hash.js]
+https_first_disabled = true # Bug 1737265
+[browser_hash2.js]
+https_first_disabled = true # Bug 1737265
+[browser_httphash.js]
+https_first_disabled = true # Bug 1737265
+[browser_httphash2.js]
+[browser_httphash3.js]
+https_first_disabled = true # Bug 1737265
+[browser_httphash4.js]
+https_first_disabled = true # Bug 1737265
+[browser_httphash5.js]
+https_first_disabled = true # Bug 1737265
+[browser_httphash6.js]
+skip-if = true # Bug 1449788
+[browser_installchrome.js]
+https_first_disabled = true # Bug 1737265
+[browser_localfile.js]
+[browser_localfile2.js]
+[browser_localfile3.js]
+[browser_localfile4.js]
+[browser_localfile4_postDownload.js]
+[browser_newwindow.js]
+skip-if = !debug # This is a test for leaks, see comment in the test.
+[browser_offline.js]
+[browser_privatebrowsing.js]
+https_first_disabled = true # Bug 1737265
+skip-if = debug # Bug 1541577 - leaks on debug
+[browser_relative.js]
+https_first_disabled = true # Bug 1737265
+[browser_required_useractivation.js]
+[browser_softwareupdate.js]
+https_first_disabled = true # Bug 1737265
+[browser_trigger_redirect.js]
+https_first_disabled = true # Bug 1737265
+[browser_unsigned_trigger.js]
+https_first_disabled = true # Bug 1737265
+skip-if = require_signing
+[browser_unsigned_trigger_iframe.js]
+https_first_disabled = true # Bug 1737265
+skip-if = require_signing
+[browser_unsigned_trigger_xorigin.js]
+https_first_disabled = true # Bug 1737265
+[browser_unsigned_url.js]
+https_first_disabled = true # Bug 1737265
+skip-if = require_signing
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_trigger.js b/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_trigger.js
new file mode 100644
index 0000000000..28d93f41b8
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_trigger.js
@@ -0,0 +1,86 @@
+/* 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"
+);
+
+AddonTestUtils.initMochitest(this);
+
+// ----------------------------------------------------------------------------
+// Tests installing an unsigned add-on through an InstallTrigger call in web
+// content.
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ IconURL: TESTROOT + "icon.png",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ AddonTestUtils.checkInstallInfo(install, {
+ method: "installTrigger",
+ source: "test-host",
+ sourceURL: /http:\/\/example.com\/.*\/installtrigger.html/,
+ });
+
+ return addon.uninstall();
+}
+
+const finish_test = async function (count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ const results = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.document.getElementById("return").textContent,
+ status: content.document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(results.return, "true", "installTrigger should have claimed success");
+ is(results.status, "0", "Callback should have seen a success");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_trigger_iframe.js b/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_trigger_iframe.js
new file mode 100644
index 0000000000..0e8b0d658f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_trigger_iframe.js
@@ -0,0 +1,77 @@
+// ----------------------------------------------------------------------------
+// Test for bug 589598 - Ensure that installing through InstallTrigger
+// works in an iframe in web content.
+
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var inner_url = encodeURIComponent(
+ TESTROOT +
+ "installtrigger.html?" +
+ encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ IconURL: TESTROOT + "icon.png",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ )
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger_frame.html?" + inner_url
+ );
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+const finish_test = async function (count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ const results = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.frames[0].document.getElementById("return").textContent,
+ status: content.frames[0].document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(
+ results.return,
+ "true",
+ "installTrigger in iframe should have claimed success"
+ );
+ is(results.status, "0", "Callback in iframe should have seen a success");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_url.js b/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_url.js
new file mode 100644
index 0000000000..d7c0b9539d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_url.js
@@ -0,0 +1,60 @@
+/* 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"
+);
+
+AddonTestUtils.initMochitest(this);
+
+// ----------------------------------------------------------------------------
+// Tests installing an unsigned add-on by navigating directly to the url
+function test() {
+ waitForExplicitFinish();
+ SpecialPowers.pushPrefEnv(
+ {
+ set: [
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ },
+ runTest
+ );
+}
+
+function runTest() {
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+ BrowserTestUtils.loadURIString(gBrowser, TESTROOT + "amosigned.xpi");
+ });
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ AddonTestUtils.checkInstallInfo(install, {
+ method: "link",
+ source: "unknown",
+ sourceURL: undefined,
+ });
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_auth.js b/toolkit/mozapps/extensions/test/xpinstall/browser_auth.js
new file mode 100644
index 0000000000..8bf05fbf2d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_auth.js
@@ -0,0 +1,68 @@
+// ----------------------------------------------------------------------------
+// Test whether an install succeeds when authentication is required
+// This verifies bug 312473
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ // Turn off the authentication dialog blocking for this test.
+ Services.prefs.setBoolPref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow",
+ true
+ );
+
+ Harness.authenticationCallback = get_auth_info;
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ Services.prefs.setIntPref("network.auth.subresource-http-auth-allow", 2);
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI":
+ TESTROOT + "authRedirect.sjs?" + TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function get_auth_info() {
+ return ["testuser", "testpass"];
+}
+
+function download_failed(install) {
+ ok(false, "Install should not have failed");
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+ var authMgr = Cc["@mozilla.org/network/http-auth-manager;1"].getService(
+ Ci.nsIHttpAuthManager
+ );
+ authMgr.clearAll();
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ Services.prefs.clearUserPref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow"
+ );
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_auth2.js b/toolkit/mozapps/extensions/test/xpinstall/browser_auth2.js
new file mode 100644
index 0000000000..5f5fc498aa
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_auth2.js
@@ -0,0 +1,73 @@
+// ----------------------------------------------------------------------------
+// Test whether an install fails when authentication is required and bad
+// credentials are given
+// This verifies bug 312473
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ // Turn off the authentication dialog blocking for this test.
+ Services.prefs.setBoolPref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow",
+ true
+ );
+
+ requestLongerTimeout(2);
+ Harness.authenticationCallback = get_auth_info;
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI":
+ TESTROOT + "authRedirect.sjs?" + TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function get_auth_info() {
+ return ["baduser", "badpass"];
+}
+
+function download_failed(install) {
+ is(
+ install.error,
+ AddonManager.ERROR_NETWORK_FAILURE,
+ "Install should have failed"
+ );
+}
+
+function install_ended(install, addon) {
+ ok(false, "Add-on should not have installed");
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+ var authMgr = Cc["@mozilla.org/network/http-auth-manager;1"].getService(
+ Ci.nsIHttpAuthManager
+ );
+ authMgr.clearAll();
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ Services.prefs.clearUserPref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow"
+ );
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_auth3.js b/toolkit/mozapps/extensions/test/xpinstall/browser_auth3.js
new file mode 100644
index 0000000000..784ae6a7d2
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_auth3.js
@@ -0,0 +1,72 @@
+// ----------------------------------------------------------------------------
+// Test whether an install fails when authentication is required and it is
+// canceled
+// This verifies bug 312473
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ // Turn off the authentication dialog blocking for this test.
+ Services.prefs.setBoolPref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow",
+ true
+ );
+
+ Harness.authenticationCallback = get_auth_info;
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI":
+ TESTROOT + "authRedirect.sjs?" + TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function get_auth_info() {
+ return null;
+}
+
+function download_failed(install) {
+ is(
+ install.error,
+ AddonManager.ERROR_NETWORK_FAILURE,
+ "Install should have failed"
+ );
+}
+
+function install_ended(install, addon) {
+ ok(false, "Add-on should not have installed");
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+ var authMgr = Cc["@mozilla.org/network/http-auth-manager;1"].getService(
+ Ci.nsIHttpAuthManager
+ );
+ authMgr.clearAll();
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ Services.prefs.clearUserPref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow"
+ );
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_auth4.js b/toolkit/mozapps/extensions/test/xpinstall/browser_auth4.js
new file mode 100644
index 0000000000..10ee85fc1e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_auth4.js
@@ -0,0 +1,71 @@
+// Test whether a request for auth for an XPI switches to the appropriate tab
+var gNewTab;
+
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ // Turn off the authentication dialog blocking for this test.
+ Services.prefs.setBoolPref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow",
+ true
+ );
+
+ Harness.authenticationCallback = get_auth_info;
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI":
+ TESTROOT + "authRedirect.sjs?" + TESTROOT + "amosigned.xpi",
+ })
+ );
+ gNewTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser.getBrowserForTab(gNewTab),
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function get_auth_info() {
+ is(
+ gBrowser.selectedTab,
+ gNewTab,
+ "Should have focused the tab loading the XPI"
+ );
+ return ["testuser", "testpass"];
+}
+
+function download_failed(install) {
+ ok(false, "Install should not have failed");
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+ var authMgr = Cc["@mozilla.org/network/http-auth-manager;1"].getService(
+ Ci.nsIHttpAuthManager
+ );
+ authMgr.clearAll();
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ Services.prefs.clearUserPref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow"
+ );
+
+ gBrowser.removeTab(gNewTab);
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_badargs.js b/toolkit/mozapps/extensions/test/xpinstall/browser_badargs.js
new file mode 100644
index 0000000000..bab7042e71
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_badargs.js
@@ -0,0 +1,49 @@
+// ----------------------------------------------------------------------------
+// Test whether passing a simple string to InstallTrigger.install throws an
+// exception
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ waitForExplicitFinish();
+
+ var triggers = encodeURIComponent(JSON.stringify(TESTROOT + "amosigned.xpi"));
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TESTROOT);
+
+ ContentTask.spawn(gBrowser.selectedBrowser, null, function () {
+ return new Promise(resolve => {
+ addEventListener(
+ "load",
+ () => {
+ content.addEventListener("InstallTriggered", () => {
+ resolve(content.document.getElementById("return").textContent);
+ });
+ },
+ true
+ );
+ });
+ }).then(page_loaded);
+
+ // In non-e10s the exception in the content page would trigger a test failure
+ if (!gMultiProcessBrowser) {
+ expectUncaughtException();
+ }
+
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function page_loaded(result) {
+ is(result, "exception", "installTrigger should have failed");
+
+ // In non-e10s the exception from the page is thrown after the event so we
+ // have to spin the event loop to make sure it arrives so expectUncaughtException
+ // sees it.
+ executeSoon(() => {
+ gBrowser.removeCurrentTab();
+ finish();
+ });
+}
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_badargs2.js b/toolkit/mozapps/extensions/test/xpinstall/browser_badargs2.js
new file mode 100644
index 0000000000..e9a66362b2
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_badargs2.js
@@ -0,0 +1,55 @@
+// ----------------------------------------------------------------------------
+// Test whether passing an undefined url InstallTrigger.install throws an
+// exception
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ waitForExplicitFinish();
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: undefined,
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TESTROOT);
+
+ ContentTask.spawn(gBrowser.selectedBrowser, null, function () {
+ return new Promise(resolve => {
+ addEventListener(
+ "load",
+ () => {
+ content.addEventListener("InstallTriggered", () => {
+ resolve(content.document.getElementById("return").textContent);
+ });
+ },
+ true
+ );
+ });
+ }).then(page_loaded);
+
+ // In non-e10s the exception in the content page would trigger a test failure
+ if (!gMultiProcessBrowser) {
+ expectUncaughtException();
+ }
+
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function page_loaded(result) {
+ is(result, "exception", "installTrigger should have failed");
+
+ // In non-e10s the exception from the page is thrown after the event so we
+ // have to spin the event loop to make sure it arrives so expectUncaughtException
+ // sees it.
+ executeSoon(() => {
+ gBrowser.removeCurrentTab();
+ finish();
+ });
+}
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_badhash.js b/toolkit/mozapps/extensions/test/xpinstall/browser_badhash.js
new file mode 100644
index 0000000000..f40fc380dd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_badhash.js
@@ -0,0 +1,46 @@
+// ----------------------------------------------------------------------------
+// Test whether an install fails when an invalid hash is included
+// This verifies bug 302284
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ Hash: "sha1:643b08418599ddbd1ea8a511c90696578fb844b9",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_failed(install) {
+ is(install.error, AddonManager.ERROR_INCORRECT_HASH, "Install should fail");
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+ PermissionTestUtils.remove("http://example.com/", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_badhashtype.js b/toolkit/mozapps/extensions/test/xpinstall/browser_badhashtype.js
new file mode 100644
index 0000000000..bf502edd26
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_badhashtype.js
@@ -0,0 +1,46 @@
+// ----------------------------------------------------------------------------
+// Test whether an install fails when an unknown hash type is included
+// This verifies bug 302284
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ Hash: "foo:3d0dc22e1f394e159b08aaf5f0f97de4d5c65f4f",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_failed(install) {
+ is(install.error, AddonManager.ERROR_INCORRECT_HASH, "Install should fail");
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+ PermissionTestUtils.remove("http://example.com/", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_block_fullscreen_prompt.js b/toolkit/mozapps/extensions/test/xpinstall/browser_block_fullscreen_prompt.js
new file mode 100644
index 0000000000..97f09d9a9c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_block_fullscreen_prompt.js
@@ -0,0 +1,129 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// This test tends to trigger a race in the fullscreen time telemetry,
+// where the fullscreen enter and fullscreen exit events (which use the
+// same histogram ID) overlap. That causes TelemetryStopwatch to log an
+// error.
+SimpleTest.ignoreAllUncaughtExceptions(true);
+
+/**
+ * Spawns content task in browser to enter / leave fullscreen
+ * @param browser - Browser to use for JS fullscreen requests
+ * @param {Boolean} fullscreenState - true to enter fullscreen, false to leave
+ */
+function changeFullscreen(browser, fullscreenState) {
+ return SpecialPowers.spawn(
+ browser,
+ [fullscreenState],
+ async function (state) {
+ if (state) {
+ await content.document.body.requestFullscreen();
+ } else {
+ await content.document.exitFullscreen();
+ }
+ }
+ );
+}
+
+function triggerInstall(browser, xpi_url) {
+ return SpecialPowers.spawn(browser, [xpi_url], async function (xpi_url) {
+ content.location = xpi_url;
+ });
+}
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ // Relax the user input requirements while running this test.
+ set: [["xpinstall.userActivation.required", false]],
+ });
+});
+
+// This tests if addon installation is blocked when requested in fullscreen
+add_task(async function testFullscreenBlockAddonInstallPrompt() {
+ // Open example.com
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, TESTROOT);
+
+ // Enter and wait for fullscreen
+ await changeFullscreen(gBrowser.selectedBrowser, true);
+ await TestUtils.waitForCondition(
+ () => window.fullScreen,
+ "Waiting for window to enter fullscreen"
+ );
+
+ // Trigger addon installation and expect it to be blocked
+ let addonEventPromise = TestUtils.topicObserved(
+ "addon-install-fullscreen-blocked"
+ );
+ await triggerInstall(gBrowser.selectedBrowser, "amosigned.xpi");
+ await addonEventPromise;
+
+ // Test if addon installation prompt has been blocked
+ let panelOpened;
+ try {
+ panelOpened = await TestUtils.waitForCondition(
+ () => PopupNotifications.isPanelOpen,
+ 100,
+ 10
+ );
+ } catch (ex) {
+ panelOpened = false;
+ }
+ is(panelOpened, false, "Addon installation prompt not opened");
+
+ window.fullScreen = false;
+ await BrowserTestUtils.waitForEvent(window, "fullscreenchange");
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+// This tests if the addon install prompt is closed when entering fullscreen
+add_task(async function testFullscreenCloseAddonInstallPrompt() {
+ // Open example.com
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com");
+
+ // Trigger addon installation
+ let addonEventPromise = TestUtils.topicObserved(
+ "webextension-permission-prompt"
+ );
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [TESTROOT + "amosigned.xpi"],
+ xpi_url => {
+ this.content.location = xpi_url;
+ }
+ );
+ // Wait for addon install event
+ info("Wait for webextension-permission-prompt");
+ await addonEventPromise;
+
+ // Test if addon installation prompt is visible
+ await TestUtils.waitForCondition(
+ () => PopupNotifications.isPanelOpen,
+ "Waiting for addon installation prompt to open"
+ );
+ Assert.ok(
+ PopupNotifications.getNotification(
+ "addon-webext-permissions",
+ gBrowser.selectedBrowser
+ ) != null,
+ "Opened notification is webextension permissions prompt"
+ );
+
+ // Switch to fullscreen and test for addon installation prompt close
+ await changeFullscreen(gBrowser.selectedBrowser, true);
+ await TestUtils.waitForCondition(
+ () => window.fullScreen,
+ "Waiting for window to enter fullscreen"
+ );
+ await TestUtils.waitForCondition(
+ () => !PopupNotifications.isPanelOpen,
+ "Waiting for addon installation prompt to close"
+ );
+
+ window.fullScreen = false;
+ await BrowserTestUtils.waitForEvent(window, "fullscreenchange");
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_bug540558.js b/toolkit/mozapps/extensions/test/xpinstall/browser_bug540558.js
new file mode 100644
index 0000000000..2a729849ac
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_bug540558.js
@@ -0,0 +1,31 @@
+// ----------------------------------------------------------------------------
+// Tests that calling InstallTrigger.installChrome works
+function test() {
+ // This test depends on InstallTrigger.installChrome availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = check_xpi_install;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(gBrowser, TESTROOT + "bug540558.html");
+}
+
+function check_xpi_install(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_bug611242.js b/toolkit/mozapps/extensions/test/xpinstall/browser_bug611242.js
new file mode 100644
index 0000000000..75e1a4e013
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_bug611242.js
@@ -0,0 +1,31 @@
+// ----------------------------------------------------------------------------
+// Test whether setting a new property in InstallTrigger then persists to other
+// page loads
+add_task(async function test() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: TESTROOT + "enabled.html" },
+ async function (browser) {
+ await SpecialPowers.spawn(browser, [], () => {
+ content.wrappedJSObject.InstallTrigger.enabled.k = function () {};
+ });
+
+ BrowserTestUtils.loadURIString(browser, TESTROOT2 + "enabled.html");
+ await BrowserTestUtils.browserLoaded(browser);
+ await SpecialPowers.spawn(browser, [], () => {
+ is(
+ content.wrappedJSObject.InstallTrigger.enabled.k,
+ undefined,
+ "Property should not be defined"
+ );
+ });
+ }
+ );
+});
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_bug638292.js b/toolkit/mozapps/extensions/test/xpinstall/browser_bug638292.js
new file mode 100644
index 0000000000..078d94cb50
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_bug638292.js
@@ -0,0 +1,51 @@
+// ----------------------------------------------------------------------------
+// Test whether an InstallTrigger.enabled is working
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ ],
+ });
+
+ let testtab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "bug638292.html"
+ );
+
+ async function verify(link, button) {
+ info("Clicking " + link);
+
+ let loadedPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true);
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#" + link,
+ { button },
+ gBrowser.selectedBrowser
+ );
+
+ let newtab = await loadedPromise;
+
+ let result = await SpecialPowers.spawn(
+ newtab.linkedBrowser,
+ [],
+ async function () {
+ return content.document.getElementById("enabled").textContent == "true";
+ }
+ );
+
+ ok(result, "installTrigger for " + link + " should have been enabled");
+
+ // Focus the old tab (link3 is opened in the background)
+ if (link != "link3") {
+ await BrowserTestUtils.switchTab(gBrowser, testtab);
+ }
+ gBrowser.removeTab(newtab);
+ }
+
+ await verify("link1", 0);
+ await verify("link2", 0);
+ await verify("link3", 1);
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_bug645699.js b/toolkit/mozapps/extensions/test/xpinstall/browser_bug645699.js
new file mode 100644
index 0000000000..e54512e4e5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_bug645699.js
@@ -0,0 +1,69 @@
+// ----------------------------------------------------------------------------
+// Tests installing an unsigned add-on through an InstallTrigger call in web
+// content. This should be blocked by the whitelist check.
+// This verifies bug 645699
+function test() {
+ if (
+ !SpecialPowers.Services.prefs.getBoolPref(
+ "extensions.InstallTrigger.enabled"
+ ) ||
+ !SpecialPowers.Services.prefs.getBoolPref(
+ "extensions.InstallTriggerImpl.enabled"
+ )
+ ) {
+ ok(true, "InstallTrigger is not enabled");
+ return;
+ }
+
+ // prompt prior to download
+ SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.postDownloadThirdPartyPrompt", false],
+ ["extensions.InstallTrigger.requireUserInput", false],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installBlockedCallback = allow_blocked;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.org/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(gBrowser, TESTROOT + "bug645699.html");
+}
+
+function allow_blocked(installInfo) {
+ is(
+ installInfo.browser,
+ gBrowser.selectedBrowser,
+ "Install should have been triggered by the right browser"
+ );
+ is(
+ installInfo.originatingURI.spec,
+ gBrowser.currentURI.spec,
+ "Install should have been triggered by the right uri"
+ );
+ return false;
+}
+
+function confirm_install(panel) {
+ ok(false, "Should not see the install dialog");
+ return false;
+}
+
+function finish_test(count) {
+ is(count, 0, "0 Add-ons should have been successfully installed");
+ PermissionTestUtils.remove("http://example.org/", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_bug645699_postDownload.js b/toolkit/mozapps/extensions/test/xpinstall/browser_bug645699_postDownload.js
new file mode 100644
index 0000000000..4e0fa6a8b6
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_bug645699_postDownload.js
@@ -0,0 +1,55 @@
+// ----------------------------------------------------------------------------
+// Tests installing an unsigned add-on through an InstallTrigger call in web
+// content. This should be blocked by the origin allow check.
+// This verifies bug 645699
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installBlockedCallback = allow_blocked;
+ Harness.installsCompletedCallback = finish_test;
+ // Prevent the Harness from ending the test on download cancel.
+ Harness.downloadCancelledCallback = () => {
+ return false;
+ };
+
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.org/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(gBrowser, TESTROOT + "bug645699.html");
+}
+
+function allow_blocked(installInfo) {
+ is(
+ installInfo.browser,
+ gBrowser.selectedBrowser,
+ "Install should have been triggered by the right browser"
+ );
+ is(
+ installInfo.originatingURI.spec,
+ gBrowser.currentURI.spec,
+ "Install should have been triggered by the right uri"
+ );
+ return false;
+}
+
+function confirm_install(panel) {
+ ok(false, "Should not see the install dialog");
+ return false;
+}
+
+function finish_test(count) {
+ is(count, 0, "0 Add-ons should have been successfully installed");
+ PermissionTestUtils.remove("http://example.org/", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_bug672485.js b/toolkit/mozapps/extensions/test/xpinstall/browser_bug672485.js
new file mode 100644
index 0000000000..7835fb3c40
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_bug672485.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+var gWindowWatcher = null;
+
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installCancelledCallback = cancelled_install;
+ Harness.installEndedCallback = complete_install;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ gWindowWatcher = Services.ww;
+ delete Services.ww;
+ is(Services.ww, undefined, "Services.ww should now be undefined");
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function confirm_install(panel) {
+ ok(false, "Should not see the install dialog");
+ return false;
+}
+
+function cancelled_install() {
+ ok(true, "Install should b cancelled");
+}
+
+function complete_install() {
+ ok(false, "Install should not have completed");
+ return false;
+}
+
+function finish_test(count) {
+ is(count, 0, "0 Add-ons should have been successfully installed");
+
+ gBrowser.removeCurrentTab();
+
+ Services.ww = gWindowWatcher;
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_containers.js b/toolkit/mozapps/extensions/test/xpinstall/browser_containers.js
new file mode 100644
index 0000000000..4aa6db40f0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_containers.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"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const MY_CONTEXT = 2;
+let gDidSeeChannel = false;
+
+function check_channel(subject) {
+ if (!(subject instanceof Ci.nsIHttpChannel)) {
+ return;
+ }
+ let channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ let uri = channel.URI;
+ if (!uri || !uri.spec.endsWith("amosigned.xpi")) {
+ return;
+ }
+ gDidSeeChannel = true;
+ ok(true, "Got request for " + uri.spec);
+
+ let loadInfo = channel.loadInfo;
+ is(
+ loadInfo.originAttributes.userContextId,
+ MY_CONTEXT,
+ "Got expected usercontextid"
+ );
+}
+// ----------------------------------------------------------------------------
+// Tests we send the right cookies when installing through an InstallTrigger call
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI("http://example.com/"),
+ { userContextId: MY_CONTEXT }
+ );
+
+ PermissionTestUtils.add(principal, "install", Services.perms.ALLOW_ACTION);
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ IconURL: TESTROOT + "icon.png",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "", {
+ userContextId: MY_CONTEXT,
+ });
+ Services.obs.addObserver(check_channel, "http-on-before-connect");
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ AddonTestUtils.checkInstallInfo(install, {
+ method: "installTrigger",
+ source: "test-host",
+ sourceURL: /http:\/\/example.com\/.*\/installtrigger.html/,
+ });
+ return addon.uninstall();
+}
+
+const finish_test = async function (count) {
+ ok(
+ gDidSeeChannel,
+ "Should have seen the request for the XPI and verified it was sent the right way."
+ );
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ Services.obs.removeObserver(check_channel, "http-on-before-connect");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ const results = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.document.getElementById("return").textContent,
+ status: content.document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(results.return, "true", "installTrigger should have claimed success");
+ is(results.status, "0", "Callback should have seen a success");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_cookies.js b/toolkit/mozapps/extensions/test/xpinstall/browser_cookies.js
new file mode 100644
index 0000000000..3bd09a84c5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_cookies.js
@@ -0,0 +1,42 @@
+// ----------------------------------------------------------------------------
+// Test that an install that requires cookies to be sent fails when no cookies
+// are set
+// This verifies bug 462739
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Cookie check":
+ TESTROOT + "cookieRedirect.sjs?" + TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_failed(install) {
+ is(install.error, AddonManager.ERROR_NETWORK_FAILURE, "Install should fail");
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_cookies2.js b/toolkit/mozapps/extensions/test/xpinstall/browser_cookies2.js
new file mode 100644
index 0000000000..603958d4ff
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_cookies2.js
@@ -0,0 +1,64 @@
+// ----------------------------------------------------------------------------
+// Test that an install that requires cookies to be sent succeeds when cookies
+// are set
+// This verifies bug 462739
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ Services.cookies.add(
+ "example.com",
+ "/browser/" + RELATIVE_DIR,
+ "xpinstall",
+ "true",
+ false,
+ false,
+ true,
+ Date.now() / 1000 + 60,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Cookie check":
+ TESTROOT + "cookieRedirect.sjs?" + TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ Services.cookies.remove(
+ "example.com",
+ "xpinstall",
+ "/browser/" + RELATIVE_DIR,
+ {}
+ );
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_cookies3.js b/toolkit/mozapps/extensions/test/xpinstall/browser_cookies3.js
new file mode 100644
index 0000000000..c133662ba1
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_cookies3.js
@@ -0,0 +1,68 @@
+// ----------------------------------------------------------------------------
+// Test that an install that requires cookies to be sent succeeds when cookies
+// are set and third party cookies are disabled.
+// This verifies bug 462739
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ Services.cookies.add(
+ "example.com",
+ "/browser/" + RELATIVE_DIR,
+ "xpinstall",
+ "true",
+ false,
+ false,
+ true,
+ Date.now() / 1000 + 60,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ Services.prefs.setIntPref("network.cookie.cookieBehavior", 1);
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Cookie check":
+ TESTROOT + "cookieRedirect.sjs?" + TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ Services.cookies.remove(
+ "example.com",
+ "xpinstall",
+ "/browser/" + RELATIVE_DIR,
+ {}
+ );
+
+ Services.prefs.clearUserPref("network.cookie.cookieBehavior");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_cookies4.js b/toolkit/mozapps/extensions/test/xpinstall/browser_cookies4.js
new file mode 100644
index 0000000000..140d9039ee
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_cookies4.js
@@ -0,0 +1,68 @@
+// ----------------------------------------------------------------------------
+// Test that an install that requires cookies to be sent fails when cookies
+// are set and third party cookies are disabled and the request is to a third
+// party.
+// This verifies bug 462739
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ Services.cookies.add(
+ "example.org",
+ "/browser/" + RELATIVE_DIR,
+ "xpinstall",
+ "true",
+ false,
+ false,
+ true,
+ Date.now() / 1000 + 60,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ Services.prefs.setIntPref("network.cookie.cookieBehavior", 1);
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Cookie check":
+ TESTROOT2 + "cookieRedirect.sjs?" + TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_failed(install) {
+ is(install.error, AddonManager.ERROR_NETWORK_FAILURE, "Install should fail");
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+
+ Services.cookies.remove(
+ "example.org",
+ "xpinstall",
+ "/browser/" + RELATIVE_DIR,
+ {}
+ );
+
+ Services.prefs.clearUserPref("network.cookie.cookieBehavior");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_corrupt.js b/toolkit/mozapps/extensions/test/xpinstall/browser_corrupt.js
new file mode 100644
index 0000000000..2981e11fd7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_corrupt.js
@@ -0,0 +1,53 @@
+// ----------------------------------------------------------------------------
+// Test whether an install fails when the xpi is corrupt.
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Corrupt XPI": TESTROOT + "corrupt.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_failed(install) {
+ is(install.error, AddonManager.ERROR_CORRUPT_FILE, "Install should fail");
+}
+
+const finish_test = async function (count) {
+ is(count, 0, "No add-ons should have been installed");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ const results = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.document.getElementById("return").textContent,
+ status: content.document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(results.status, "-207", "Callback should have seen the failure");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_datauri.js b/toolkit/mozapps/extensions/test/xpinstall/browser_datauri.js
new file mode 100644
index 0000000000..08ec7e41cb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_datauri.js
@@ -0,0 +1,80 @@
+/* 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"
+);
+
+AddonTestUtils.initMochitest(this);
+
+// ----------------------------------------------------------------------------
+// Checks that a chained redirect through a data URI and javascript is blocked
+
+function setup_redirect(aSettings) {
+ var url = TESTROOT + "redirect.sjs?mode=setup";
+ for (var name in aSettings) {
+ url += "&" + name + "=" + encodeURIComponent(aSettings[name]);
+ }
+
+ var req = new XMLHttpRequest();
+ req.open("GET", url, false);
+ req.send(null);
+}
+
+function test() {
+ waitForExplicitFinish();
+ SpecialPowers.pushPrefEnv(
+ {
+ set: [
+ ["network.allow_redirect_to_data", true],
+ ["security.data_uri.block_toplevel_data_uri_navigations", false],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ },
+ runTest
+ );
+}
+
+function runTest() {
+ Harness.installOriginBlockedCallback = install_blocked;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ setup_redirect({
+ Location:
+ "data:text/html,<script>window.location.href='" +
+ TESTROOT +
+ "amosigned.xpi'</script>",
+ });
+
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "redirect.sjs?mode=redirect"
+ );
+}
+
+function install_blocked(installInfo) {
+ is(
+ installInfo.installs.length,
+ 1,
+ "Got one AddonInstall instance as expected"
+ );
+ AddonTestUtils.checkInstallInfo(installInfo.installs[0], {
+ method: "link",
+ source: "unknown",
+ sourceURL: /moz-nullprincipal:\{.*\}/,
+ });
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+ finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_doorhanger_installs.js b/toolkit/mozapps/extensions/test/xpinstall/browser_doorhanger_installs.js
new file mode 100644
index 0000000000..cab0e91734
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_doorhanger_installs.js
@@ -0,0 +1,1542 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// 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 { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+const { Management } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+);
+
+const SECUREROOT =
+ "https://example.com/browser/toolkit/mozapps/extensions/test/xpinstall/";
+const PROGRESS_NOTIFICATION = "addon-progress";
+
+const CHROMEROOT = extractChromeRoot(gTestPath);
+
+AddonTestUtils.initMochitest(this);
+
+function waitForTick() {
+ return new Promise(resolve => executeSoon(resolve));
+}
+
+function getObserverTopic(aNotificationId) {
+ let topic = aNotificationId;
+ if (topic == "xpinstall-disabled") {
+ topic = "addon-install-disabled";
+ } else if (topic == "addon-progress") {
+ topic = "addon-install-started";
+ } else if (topic == "addon-installed") {
+ topic = "webextension-install-notify";
+ }
+ return topic;
+}
+
+async function waitForProgressNotification(
+ aPanelOpen = false,
+ aExpectedCount = 1,
+ wantDisabled = true,
+ expectedAnchorID = "unified-extensions-button",
+ win = window
+) {
+ let notificationId = PROGRESS_NOTIFICATION;
+ info("Waiting for " + notificationId + " notification");
+
+ let topic = getObserverTopic(notificationId);
+
+ let observerPromise = new Promise(resolve => {
+ Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
+ // Ignore the progress notification unless that is the notification we want
+ if (
+ notificationId != PROGRESS_NOTIFICATION &&
+ aTopic == getObserverTopic(PROGRESS_NOTIFICATION)
+ ) {
+ return;
+ }
+ Services.obs.removeObserver(observer, topic);
+ resolve();
+ }, topic);
+ });
+
+ let panelEventPromise;
+ if (aPanelOpen) {
+ panelEventPromise = Promise.resolve();
+ } else {
+ panelEventPromise = new Promise(resolve => {
+ win.PopupNotifications.panel.addEventListener(
+ "popupshowing",
+ function () {
+ resolve();
+ },
+ { once: true }
+ );
+ });
+ }
+
+ await observerPromise;
+ await panelEventPromise;
+ await waitForTick();
+
+ info("Saw a notification");
+ ok(win.PopupNotifications.isPanelOpen, "Panel should be open");
+ is(
+ win.PopupNotifications.panel.childNodes.length,
+ aExpectedCount,
+ "Should be the right number of notifications"
+ );
+ if (win.PopupNotifications.panel.childNodes.length) {
+ let nodes = Array.from(win.PopupNotifications.panel.childNodes);
+ let notification = nodes.find(
+ n => n.id == notificationId + "-notification"
+ );
+ ok(notification, `Should have seen the right notification`);
+ is(
+ notification.button.hasAttribute("disabled"),
+ wantDisabled,
+ "The install button should be disabled?"
+ );
+
+ let n = win.PopupNotifications.getNotification(PROGRESS_NOTIFICATION);
+ is(
+ n?.anchorElement?.id || n?.anchorElement?.parentElement?.id,
+ expectedAnchorID,
+ "expected the right anchor ID"
+ );
+ }
+
+ return win.PopupNotifications.panel;
+}
+
+function acceptAppMenuNotificationWhenShown(
+ id,
+ extensionId,
+ {
+ dismiss = false,
+ checkIncognito = false,
+ incognitoChecked = false,
+ incognitoHidden = false,
+ global = window,
+ } = {}
+) {
+ const { AppMenuNotifications, PanelUI, document } = global;
+ return new Promise(resolve => {
+ let permissionChangePromise = null;
+ function appMenuPopupHidden() {
+ PanelUI.panel.removeEventListener("popuphidden", appMenuPopupHidden);
+ ok(
+ !PanelUI.menuButton.hasAttribute("badge-status"),
+ "badge is not set after addon-installed"
+ );
+ resolve(permissionChangePromise);
+ }
+ function appMenuPopupShown() {
+ PanelUI.panel.removeEventListener("popupshown", appMenuPopupShown);
+ PanelUI.menuButton.click();
+ }
+ function popupshown() {
+ let notification = AppMenuNotifications.activeNotification;
+ if (!notification) {
+ return;
+ }
+
+ is(notification.id, id, `${id} notification shown`);
+ ok(PanelUI.isNotificationPanelOpen, "notification panel open");
+
+ PanelUI.notificationPanel.removeEventListener("popupshown", popupshown);
+
+ let checkbox = document.getElementById("addon-incognito-checkbox");
+ is(checkbox.hidden, incognitoHidden, "checkbox visibility is correct");
+ is(checkbox.checked, incognitoChecked, "checkbox is marked as expected");
+
+ // If we're unchecking or checking the incognito property, this will
+ // trigger an update in ExtensionPermission, let's wait for it before
+ // returning from this promise.
+ if (incognitoChecked != checkIncognito) {
+ permissionChangePromise = new Promise(resolve => {
+ const listener = (type, change) => {
+ if (extensionId == change.extensionId) {
+ // Let's make sure we received the right message
+ let { permissions } = checkIncognito
+ ? change.added
+ : change.removed;
+ ok(permissions.includes("internal:privateBrowsingAllowed"));
+ resolve();
+ }
+ };
+ Management.once("change-permissions", listener);
+ });
+ }
+
+ checkbox.checked = checkIncognito;
+
+ if (dismiss) {
+ // Dismiss the panel by clicking on the appMenu button.
+ PanelUI.panel.addEventListener("popupshown", appMenuPopupShown);
+ PanelUI.panel.addEventListener("popuphidden", appMenuPopupHidden);
+ PanelUI.menuButton.click();
+ return;
+ }
+
+ // Dismiss the panel by clicking the primary button.
+ let popupnotificationID = PanelUI._getPopupId(notification);
+ let popupnotification = document.getElementById(popupnotificationID);
+
+ popupnotification.button.click();
+ resolve(permissionChangePromise);
+ }
+ PanelUI.notificationPanel.addEventListener("popupshown", popupshown);
+ });
+}
+
+async function waitForNotification(
+ aId,
+ aExpectedCount = 1,
+ expectedAnchorID = "unified-extensions-button",
+ win = window
+) {
+ info("Waiting for " + aId + " notification");
+
+ let topic = getObserverTopic(aId);
+
+ let observerPromise;
+ if (aId !== "addon-webext-permissions") {
+ observerPromise = new Promise(resolve => {
+ Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
+ // Ignore the progress notification unless that is the notification we want
+ if (
+ aId != PROGRESS_NOTIFICATION &&
+ aTopic == getObserverTopic(PROGRESS_NOTIFICATION)
+ ) {
+ return;
+ }
+ Services.obs.removeObserver(observer, topic);
+ resolve();
+ }, topic);
+ });
+ }
+
+ let panelEventPromise = new Promise(resolve => {
+ win.PopupNotifications.panel.addEventListener(
+ "PanelUpdated",
+ function eventListener(e) {
+ // Skip notifications that are not the one that we are supposed to be looking for
+ if (!e.detail.includes(aId)) {
+ return;
+ }
+ win.PopupNotifications.panel.removeEventListener(
+ "PanelUpdated",
+ eventListener
+ );
+ resolve();
+ }
+ );
+ });
+
+ await observerPromise;
+ await panelEventPromise;
+ await waitForTick();
+
+ info("Saw a " + aId + " notification");
+ ok(win.PopupNotifications.isPanelOpen, "Panel should be open");
+ is(
+ win.PopupNotifications.panel.childNodes.length,
+ aExpectedCount,
+ "Should be the right number of notifications"
+ );
+ if (win.PopupNotifications.panel.childNodes.length) {
+ let nodes = Array.from(win.PopupNotifications.panel.childNodes);
+ let notification = nodes.find(n => n.id == aId + "-notification");
+ ok(notification, "Should have seen the " + aId + " notification");
+
+ let n = win.PopupNotifications.getNotification(aId);
+ is(
+ n?.anchorElement?.id || n?.anchorElement?.parentElement?.id,
+ expectedAnchorID,
+ "expected the right anchor ID"
+ );
+ }
+ await SimpleTest.promiseFocus(win.PopupNotifications.window);
+
+ return win.PopupNotifications.panel;
+}
+
+function waitForNotificationClose(win = window) {
+ if (!win.PopupNotifications.isPanelOpen) {
+ return Promise.resolve();
+ }
+ return new Promise(resolve => {
+ info("Waiting for notification to close");
+ win.PopupNotifications.panel.addEventListener(
+ "popuphidden",
+ function () {
+ resolve();
+ },
+ { once: true }
+ );
+ });
+}
+
+async function waitForInstallDialog(id = "addon-webext-permissions") {
+ let panel = await waitForNotification(id);
+ return panel.childNodes[0];
+}
+
+function removeTabAndWaitForNotificationClose() {
+ let closePromise = waitForNotificationClose();
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ return closePromise;
+}
+
+function acceptInstallDialog(installDialog) {
+ installDialog.button.click();
+}
+
+async function waitForSingleNotification(aCallback) {
+ while (PopupNotifications.panel.childNodes.length != 1) {
+ await new Promise(resolve => executeSoon(resolve));
+
+ info("Waiting for single notification");
+ // Notification should never close while we wait
+ ok(PopupNotifications.isPanelOpen, "Notification should still be open");
+ }
+}
+
+function setupRedirect(aSettings) {
+ var url =
+ "https://example.com/browser/toolkit/mozapps/extensions/test/xpinstall/redirect.sjs?mode=setup";
+ for (var name in aSettings) {
+ url += "&" + name + "=" + aSettings[name];
+ }
+
+ var req = new XMLHttpRequest();
+ req.open("GET", url, false);
+ req.send(null);
+}
+
+var TESTS = [
+ async function test_disabledInstall() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["xpinstall.enabled", false]],
+ });
+ let notificationPromise = waitForNotification("xpinstall-disabled");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "amosigned.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ let panel = await notificationPromise;
+
+ let notification = panel.childNodes[0];
+ is(
+ notification.button.label,
+ "Enable",
+ "Should have seen the right button"
+ );
+ is(
+ notification.getAttribute("label"),
+ "Software installation is currently disabled. Click Enable and try again.",
+ "notification label is correct"
+ );
+
+ let closePromise = waitForNotificationClose();
+ // Click on Enable
+ EventUtils.synthesizeMouseAtCenter(notification.button, {});
+ await closePromise;
+
+ try {
+ ok(
+ Services.prefs.getBoolPref("xpinstall.enabled"),
+ "Installation should be enabled"
+ );
+ } catch (e) {
+ ok(false, "xpinstall.enabled should be set");
+ }
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Shouldn't be any pending installs");
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_blockedInstall() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.postDownloadThirdPartyPrompt", false]],
+ });
+
+ let notificationPromise = waitForNotification("addon-install-blocked");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "amosigned.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ let panel = await notificationPromise;
+
+ let notification = panel.childNodes[0];
+ is(
+ notification.button.label,
+ "Continue to Installation",
+ "Should have seen the right button"
+ );
+ is(
+ notification
+ .querySelector("#addon-install-blocked-info")
+ .getAttribute("href"),
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "unlisted-extensions-risks",
+ "Got the expected SUMO page as a learn more link in the addon-install-blocked panel"
+ );
+ let message = panel.ownerDocument.getElementById(
+ "addon-install-blocked-message"
+ );
+ is(
+ message.textContent,
+ "You are attempting to install an add-on from example.com. Make sure you trust this site before continuing.",
+ "Should have seen the right message"
+ );
+
+ let dialogPromise = waitForInstallDialog();
+ // Click on Allow
+ EventUtils.synthesizeMouse(notification.button, 20, 10, {});
+
+ // Notification should have changed to progress notification
+ ok(PopupNotifications.isPanelOpen, "Notification should still be open");
+ notification = panel.childNodes[0];
+ is(
+ notification.id,
+ "addon-progress-notification",
+ "Should have seen the progress notification"
+ );
+
+ let installDialog = await dialogPromise;
+
+ notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "amosigned-xpi@tests.mozilla.org"
+ );
+
+ installDialog.button.click();
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID(
+ "amosigned-xpi@tests.mozilla.org"
+ );
+ await addon.uninstall();
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_blockedInstallDomain() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.postDownloadThirdPartyPrompt", true],
+ ["extensions.install_origins.enabled", true],
+ ],
+ });
+
+ let progressPromise = waitForProgressNotification();
+ let notificationPromise = waitForNotification("addon-install-failed");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: TESTROOT2 + "webmidi_permission.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ await progressPromise;
+ let panel = await notificationPromise;
+
+ let notification = panel.childNodes[0];
+ is(
+ notification.getAttribute("label"),
+ "The add-on WebMIDI test addon can not be installed from this location.",
+ "Should have seen the right message"
+ );
+
+ await removeTabAndWaitForNotificationClose();
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_allowedInstallDomain() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.postDownloadThirdPartyPrompt", true],
+ ["extensions.install_origins.enabled", true],
+ ],
+ });
+
+ let notificationPromise = waitForNotification("addon-install-blocked");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: TESTROOT + "webmidi_permission.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ let panel = await notificationPromise;
+
+ let notification = panel.childNodes[0];
+ is(
+ notification.button.label,
+ "Continue to Installation",
+ "Should have seen the right button"
+ );
+ let message = panel.ownerDocument.getElementById(
+ "addon-install-blocked-message"
+ );
+ is(
+ message.textContent,
+ "You are attempting to install an add-on from example.com. Make sure you trust this site before continuing.",
+ "Should have seen the right message"
+ );
+
+ // Next we get the permissions prompt, which also warns of the unsigned state of the addon
+ notificationPromise = waitForNotification("addon-webext-permissions");
+ // Click on Allow on the 3rd party panel
+ notification.button.click();
+ panel = await notificationPromise;
+ notification = panel.childNodes[0];
+
+ is(notification.button.label, "Add", "Should have seen the right button");
+
+ is(
+ notification.id,
+ "addon-webext-permissions-notification",
+ "Should have seen the permissions panel"
+ );
+ let singlePerm = panel.ownerDocument.getElementById(
+ "addon-webext-perm-single-entry"
+ );
+ is(
+ singlePerm.textContent,
+ "Access MIDI devices",
+ "Should have seen the right permission text"
+ );
+
+ notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "webmidi@test.mozilla.org",
+ { incognitoHidden: false, checkIncognito: true }
+ );
+
+ // Click on Allow on the permissions panel
+ notification.button.click();
+
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID("webmidi@test.mozilla.org");
+ await TestUtils.topicObserved("webextension-sitepermissions-startup");
+
+ // This addon should have a site permission with private browsing.
+ let uri = Services.io.newURI(addon.siteOrigin);
+ let pbPrincipal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {
+ privateBrowsingId: 1,
+ }
+ );
+ let permission = Services.perms.testExactPermissionFromPrincipal(
+ pbPrincipal,
+ "midi"
+ );
+ is(
+ permission,
+ Services.perms.ALLOW_ACTION,
+ "api access in private browsing granted"
+ );
+
+ await addon.uninstall();
+
+ // Verify the permission has not been retained.
+ let { permissions } = await ExtensionPermissions.get(
+ "webmidi@test.mozilla.org"
+ );
+ ok(
+ !permissions.includes("internal:privateBrowsingAllowed"),
+ "permission is not set after uninstall"
+ );
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_blockedPostDownload() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.postDownloadThirdPartyPrompt", true]],
+ });
+
+ let notificationPromise = waitForNotification("addon-install-blocked");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "amosigned.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ let panel = await notificationPromise;
+
+ let notification = panel.childNodes[0];
+ is(
+ notification.button.label,
+ "Continue to Installation",
+ "Should have seen the right button"
+ );
+ let message = panel.ownerDocument.getElementById(
+ "addon-install-blocked-message"
+ );
+ is(
+ message.textContent,
+ "You are attempting to install an add-on from example.com. Make sure you trust this site before continuing.",
+ "Should have seen the right message"
+ );
+
+ let dialogPromise = waitForInstallDialog();
+ // Click on Allow
+ EventUtils.synthesizeMouse(notification.button, 20, 10, {});
+
+ let installDialog = await dialogPromise;
+
+ notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "amosigned-xpi@tests.mozilla.org"
+ );
+
+ installDialog.button.click();
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID(
+ "amosigned-xpi@tests.mozilla.org"
+ );
+ await addon.uninstall();
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_recommendedPostDownload() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.postDownloadThirdPartyPrompt", true]],
+ });
+
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "recommended.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+
+ let installDialog = await waitForInstallDialog();
+
+ let notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "{811d77f1-f306-4187-9251-b4ff99bad60b}"
+ );
+
+ installDialog.button.click();
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID(
+ "{811d77f1-f306-4187-9251-b4ff99bad60b}"
+ );
+ await addon.uninstall();
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_priviledgedNo3rdPartyPrompt() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.postDownloadThirdPartyPrompt", true]],
+ });
+ AddonManager.checkUpdateSecurity = false;
+ registerCleanupFunction(() => {
+ AddonManager.checkUpdateSecurity = true;
+ });
+
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "privileged.xpi",
+ })
+ );
+
+ let installDialogPromise = waitForInstallDialog();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+
+ let notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "test@tests.mozilla.org",
+ { incognitoHidden: true }
+ );
+
+ (await installDialogPromise).button.click();
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID("test@tests.mozilla.org");
+ await addon.uninstall();
+
+ await BrowserTestUtils.removeTab(tab);
+ await SpecialPowers.popPrefEnv();
+ AddonManager.checkUpdateSecurity = true;
+ },
+
+ async function test_permaBlockInstall() {
+ let notificationPromise = waitForNotification("addon-install-blocked");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "amosigned.xpi",
+ })
+ );
+ let target = TESTROOT + "installtrigger.html?" + triggers;
+
+ BrowserTestUtils.openNewForegroundTab(gBrowser, target);
+ let notification = (await notificationPromise).firstElementChild;
+ let neverAllowBtn = notification.menupopup.firstElementChild;
+
+ neverAllowBtn.click();
+
+ await TestUtils.waitForCondition(
+ () => !PopupNotifications.isPanelOpen,
+ "Waiting for notification to close"
+ );
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let installPerm = PermissionTestUtils.testPermission(
+ gBrowser.currentURI,
+ "install"
+ );
+ is(
+ installPerm,
+ Ci.nsIPermissionManager.DENY_ACTION,
+ "Addon installation should be blocked for site"
+ );
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+ PermissionTestUtils.remove(target, "install");
+ },
+
+ async function test_permaBlockedInstallNoPrompt() {
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "amosigned.xpi",
+ })
+ );
+ let target = TESTROOT + "installtrigger.html?" + triggers;
+
+ PermissionTestUtils.add(target, "install", Services.perms.DENY_ACTION);
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, target);
+
+ let panelOpened;
+ try {
+ panelOpened = await TestUtils.waitForCondition(
+ () => PopupNotifications.isPanelOpen,
+ 100,
+ 10
+ );
+ } catch (ex) {
+ panelOpened = false;
+ }
+ is(panelOpened, false, "Addon prompt should not open");
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+ PermissionTestUtils.remove(target, "install");
+ },
+
+ async function test_whitelistedInstall() {
+ let originalTab = gBrowser.selectedTab;
+ let tab;
+ gBrowser.selectedTab = originalTab;
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ let progressPromise = waitForProgressNotification();
+ let dialogPromise = waitForInstallDialog();
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "amosigned.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ ).then(newTab => (tab = newTab));
+ await progressPromise;
+ let installDialog = await dialogPromise;
+ await BrowserTestUtils.waitForCondition(
+ () => !!tab,
+ "tab should be present"
+ );
+
+ is(
+ gBrowser.selectedTab,
+ tab,
+ "tab selected in response to the addon-install-confirmation notification"
+ );
+
+ let notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "amosigned-xpi@tests.mozilla.org",
+ { dismiss: true }
+ );
+ acceptInstallDialog(installDialog);
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID(
+ "amosigned-xpi@tests.mozilla.org"
+ );
+
+ // Test that the addon does not have permission. Reload it to ensure it would
+ // have been set if possible.
+ await addon.reload();
+ let policy = WebExtensionPolicy.getByID(addon.id);
+ ok(
+ !policy.privateBrowsingAllowed,
+ "private browsing permission was not granted"
+ );
+
+ await addon.uninstall();
+
+ PermissionTestUtils.remove("http://example.com/", "install");
+
+ await removeTabAndWaitForNotificationClose();
+ },
+
+ async function test_failedDownload() {
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ let progressPromise = waitForProgressNotification();
+ let failPromise = waitForNotification("addon-install-failed");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "missing.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ await progressPromise;
+ let panel = await failPromise;
+
+ let notification = panel.childNodes[0];
+ is(
+ notification.getAttribute("label"),
+ "The add-on could not be downloaded because of a connection failure.",
+ "Should have seen the right message"
+ );
+
+ PermissionTestUtils.remove("http://example.com/", "install");
+ await removeTabAndWaitForNotificationClose();
+ },
+
+ async function test_corruptFile() {
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ let progressPromise = waitForProgressNotification();
+ let failPromise = waitForNotification("addon-install-failed");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "corrupt.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ await progressPromise;
+ let panel = await failPromise;
+
+ let notification = panel.childNodes[0];
+ is(
+ notification.getAttribute("label"),
+ "The add-on downloaded from this site could not be installed " +
+ "because it appears to be corrupt.",
+ "Should have seen the right message"
+ );
+
+ PermissionTestUtils.remove("http://example.com/", "install");
+ await removeTabAndWaitForNotificationClose();
+ },
+
+ async function test_incompatible() {
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ let progressPromise = waitForProgressNotification();
+ let failPromise = waitForNotification("addon-install-failed");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "incompatible.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ await progressPromise;
+ let panel = await failPromise;
+
+ let notification = panel.childNodes[0];
+ let brandBundle = Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+ let brandShortName = brandBundle.GetStringFromName("brandShortName");
+ let message = `XPI Test could not be installed because it is not compatible with ${brandShortName} ${Services.appinfo.version}.`;
+ is(
+ notification.getAttribute("label"),
+ message,
+ "Should have seen the right message"
+ );
+
+ PermissionTestUtils.remove("http://example.com/", "install");
+ await removeTabAndWaitForNotificationClose();
+ },
+
+ async function test_localFile() {
+ let cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+ let path;
+ try {
+ path = cr.convertChromeURL(makeURI(CHROMEROOT + "corrupt.xpi")).spec;
+ } catch (ex) {
+ path = CHROMEROOT + "corrupt.xpi";
+ }
+
+ let failPromise = new Promise(resolve => {
+ Services.obs.addObserver(function observer() {
+ Services.obs.removeObserver(observer, "addon-install-failed");
+ resolve();
+ }, "addon-install-failed");
+ });
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ BrowserTestUtils.loadURIString(gBrowser, path);
+ await failPromise;
+
+ // Wait for the browser code to add the failure notification
+ await waitForSingleNotification();
+
+ let notification = PopupNotifications.panel.childNodes[0];
+ is(
+ notification.id,
+ "addon-install-failed-notification",
+ "Should have seen the install fail"
+ );
+ is(
+ notification.getAttribute("label"),
+ "This add-on could not be installed because it appears to be corrupt.",
+ "Should have seen the right message"
+ );
+
+ await removeTabAndWaitForNotificationClose();
+ },
+
+ async function test_urlBar() {
+ let progressPromise = waitForProgressNotification();
+ let dialogPromise = waitForInstallDialog();
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ gURLBar.value = TESTROOT + "amosigned.xpi";
+ gURLBar.focus();
+ EventUtils.synthesizeKey("KEY_Enter");
+
+ await progressPromise;
+ let installDialog = await dialogPromise;
+
+ let notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "amosigned-xpi@tests.mozilla.org",
+ { checkIncognito: true }
+ );
+ installDialog.button.click();
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID(
+ "amosigned-xpi@tests.mozilla.org"
+ );
+ // The panel is reloading the addon due to the permission change, we need some way
+ // to wait for the reload to finish. addon.startupPromise doesn't do it for
+ // us, so we'll just restart again.
+ await addon.reload();
+
+ // This addon should have private browsing permission.
+ let policy = WebExtensionPolicy.getByID(addon.id);
+ ok(policy.privateBrowsingAllowed, "private browsing permission granted");
+
+ await addon.uninstall();
+
+ await removeTabAndWaitForNotificationClose();
+ },
+
+ async function test_wrongHost() {
+ let requestedUrl = TESTROOT2 + "enabled.html";
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ requestedUrl
+ );
+ BrowserTestUtils.loadURIString(gBrowser, TESTROOT2 + "enabled.html");
+ await loadedPromise;
+
+ let progressPromise = waitForProgressNotification();
+ let notificationPromise = waitForNotification("addon-install-failed");
+ BrowserTestUtils.loadURIString(gBrowser, TESTROOT + "corrupt.xpi");
+ await progressPromise;
+ let panel = await notificationPromise;
+
+ let notification = panel.childNodes[0];
+ is(
+ notification.getAttribute("label"),
+ "The add-on downloaded from this site could not be installed " +
+ "because it appears to be corrupt.",
+ "Should have seen the right message"
+ );
+
+ await removeTabAndWaitForNotificationClose();
+ },
+
+ async function test_renotifyBlocked() {
+ let notificationPromise = waitForNotification("addon-install-blocked");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "amosigned.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ let panel = await notificationPromise;
+
+ let closePromise = waitForNotificationClose();
+ // hide the panel (this simulates the user dismissing it)
+ panel.hidePopup();
+ await closePromise;
+
+ info("Timeouts after this probably mean bug 589954 regressed");
+
+ await new Promise(resolve => executeSoon(resolve));
+
+ notificationPromise = waitForNotification("addon-install-blocked");
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 2, "Should be two pending installs");
+
+ await removeTabAndWaitForNotificationClose(gBrowser.selectedTab);
+
+ installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should have cancelled the installs");
+ },
+
+ async function test_cancel() {
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ let notificationPromise = waitForNotification(PROGRESS_NOTIFICATION);
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "slowinstall.sjs?file=amosigned.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ let panel = await notificationPromise;
+ let notification = panel.childNodes[0];
+
+ ok(PopupNotifications.isPanelOpen, "Notification should still be open");
+ is(
+ PopupNotifications.panel.childNodes.length,
+ 1,
+ "Should be only one notification"
+ );
+ is(
+ notification.id,
+ "addon-progress-notification",
+ "Should have seen the progress notification"
+ );
+
+ // Cancel the download
+ let install = notification.notification.options.installs[0];
+ let cancelledPromise = new Promise(resolve => {
+ install.addListener({
+ onDownloadCancelled() {
+ install.removeListener(this);
+ resolve();
+ },
+ });
+ });
+ EventUtils.synthesizeMouseAtCenter(notification.secondaryButton, {});
+ await cancelledPromise;
+
+ await waitForTick();
+
+ ok(!PopupNotifications.isPanelOpen, "Notification should be closed");
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending install");
+
+ PermissionTestUtils.remove("http://example.com/", "install");
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ },
+
+ async function test_failedSecurity() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_INSTALL_REQUIREBUILTINCERTS, false],
+ ["extensions.postDownloadThirdPartyPrompt", false],
+ ],
+ });
+
+ setupRedirect({
+ Location: TESTROOT + "amosigned.xpi",
+ });
+
+ let notificationPromise = waitForNotification("addon-install-blocked");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "redirect.sjs?mode=redirect",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ SECUREROOT + "installtrigger.html?" + triggers
+ );
+ let panel = await notificationPromise;
+
+ let notification = panel.childNodes[0];
+ // Click on Allow
+ EventUtils.synthesizeMouse(notification.button, 20, 10, {});
+
+ // Notification should have changed to progress notification
+ ok(PopupNotifications.isPanelOpen, "Notification should still be open");
+ is(
+ PopupNotifications.panel.childNodes.length,
+ 1,
+ "Should be only one notification"
+ );
+ notification = panel.childNodes[0];
+ is(
+ notification.id,
+ "addon-progress-notification",
+ "Should have seen the progress notification"
+ );
+
+ // Wait for it to fail
+ await new Promise(resolve => {
+ Services.obs.addObserver(function observer() {
+ Services.obs.removeObserver(observer, "addon-install-failed");
+ resolve();
+ }, "addon-install-failed");
+ });
+
+ // Allow the browser code to add the failure notification and then wait
+ // for the progress notification to dismiss itself
+ await waitForSingleNotification();
+ is(
+ PopupNotifications.panel.childNodes.length,
+ 1,
+ "Should be only one notification"
+ );
+ notification = panel.childNodes[0];
+ is(
+ notification.id,
+ "addon-install-failed-notification",
+ "Should have seen the install fail"
+ );
+
+ await removeTabAndWaitForNotificationClose();
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_incognito_checkbox() {
+ // Grant permission up front.
+ const permissionName = "internal:privateBrowsingAllowed";
+ let incognitoPermission = {
+ permissions: [permissionName],
+ origins: [],
+ };
+ await ExtensionPermissions.add(
+ "amosigned-xpi@tests.mozilla.org",
+ incognitoPermission
+ );
+
+ let progressPromise = waitForProgressNotification();
+ let dialogPromise = waitForInstallDialog();
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ gURLBar.value = TESTROOT + "amosigned.xpi";
+ gURLBar.focus();
+ EventUtils.synthesizeKey("KEY_Enter");
+
+ await progressPromise;
+ let installDialog = await dialogPromise;
+
+ let notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "amosigned-xpi@tests.mozilla.org",
+ { incognitoChecked: true }
+ );
+ installDialog.button.click();
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID(
+ "amosigned-xpi@tests.mozilla.org"
+ );
+ // The panel is reloading the addon due to the permission change, we need some way
+ // to wait for the reload to finish. addon.startupPromise doesn't do it for
+ // us, so we'll just restart again.
+ await AddonTestUtils.promiseWebExtensionStartup(
+ "amosigned-xpi@tests.mozilla.org"
+ );
+
+ // This addon should no longer have private browsing permission.
+ let policy = WebExtensionPolicy.getByID(addon.id);
+ ok(!policy.privateBrowsingAllowed, "private browsing permission removed");
+
+ await addon.uninstall();
+
+ await removeTabAndWaitForNotificationClose();
+ },
+
+ async function test_incognito_checkbox_new_window() {
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await SimpleTest.promiseFocus(win);
+ // Grant permission up front.
+ const permissionName = "internal:privateBrowsingAllowed";
+ let incognitoPermission = {
+ permissions: [permissionName],
+ origins: [],
+ };
+ await ExtensionPermissions.add(
+ "amosigned-xpi@tests.mozilla.org",
+ incognitoPermission
+ );
+
+ let panelEventPromise = new Promise(resolve => {
+ win.PopupNotifications.panel.addEventListener(
+ "PanelUpdated",
+ function eventListener(e) {
+ if (e.detail.includes("addon-webext-permissions")) {
+ win.PopupNotifications.panel.removeEventListener(
+ "PanelUpdated",
+ eventListener
+ );
+ resolve();
+ }
+ }
+ );
+ });
+
+ win.gBrowser.selectedTab = BrowserTestUtils.addTab(
+ win.gBrowser,
+ "about:blank"
+ );
+ await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser);
+ win.gURLBar.value = TESTROOT + "amosigned.xpi";
+ win.gURLBar.focus();
+ EventUtils.synthesizeKey("KEY_Enter", {}, win);
+
+ await panelEventPromise;
+ await waitForTick();
+
+ let panel = win.PopupNotifications.panel;
+ let installDialog = panel.childNodes[0];
+
+ let notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "amosigned-xpi@tests.mozilla.org",
+ { incognitoChecked: true, global: win }
+ );
+ acceptInstallDialog(installDialog);
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID(
+ "amosigned-xpi@tests.mozilla.org"
+ );
+ // The panel is reloading the addon due to the permission change, we need some way
+ // to wait for the reload to finish. addon.startupPromise doesn't do it for
+ // us, so we'll just restart again.
+ await AddonTestUtils.promiseWebExtensionStartup(
+ "amosigned-xpi@tests.mozilla.org"
+ );
+
+ // This addon should no longer have private browsing permission.
+ let policy = WebExtensionPolicy.getByID(addon.id);
+ ok(!policy.privateBrowsingAllowed, "private browsing permission removed");
+
+ await addon.uninstall();
+
+ await BrowserTestUtils.closeWindow(win);
+ },
+
+ async function test_blockedInstallDomain_with_unified_extensions() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.install_origins.enabled", true]],
+ });
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await SimpleTest.promiseFocus(win);
+
+ let progressPromise = waitForProgressNotification(
+ false,
+ 1,
+ true,
+ "unified-extensions-button",
+ win
+ );
+ let notificationPromise = waitForNotification(
+ "addon-install-failed",
+ 1,
+ "unified-extensions-button",
+ win
+ );
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: TESTROOT2 + "webmidi_permission.xpi",
+ })
+ );
+ await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ await progressPromise;
+ await notificationPromise;
+
+ await BrowserTestUtils.closeWindow(win);
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_mv3_installOrigins_disallowed_with_unified_extensions() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Disable signature check because we load an unsigned MV3 extension.
+ ["xpinstall.signatures.required", false],
+ ["extensions.install_origins.enabled", true],
+ ],
+ });
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await SimpleTest.promiseFocus(win);
+
+ let notificationPromise = waitForNotification(
+ "addon-install-failed",
+ 1,
+ "unified-extensions-button",
+ win
+ );
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ // This XPI does not have any `install_origins` in its manifest.
+ XPI: "unsigned_mv3.xpi",
+ })
+ );
+ await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ await notificationPromise;
+
+ await BrowserTestUtils.closeWindow(win);
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_mv3_installOrigins_allowed_with_unified_extensions() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Disable signature check because we load an unsigned MV3 extension.
+ ["xpinstall.signatures.required", false],
+ // When this pref is disabled, install should be possible.
+ ["extensions.install_origins.enabled", false],
+ ],
+ });
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await SimpleTest.promiseFocus(win);
+
+ let notificationPromise = waitForNotification(
+ "addon-install-blocked",
+ 1,
+ "unified-extensions-button",
+ win
+ );
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ // This XPI does not have any `install_origins` in its manifest.
+ XPI: "unsigned_mv3.xpi",
+ })
+ );
+ await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ let panel = await notificationPromise;
+
+ let closePromise = waitForNotificationClose(win);
+ // hide the panel (this simulates the user dismissing it)
+ panel.hidePopup();
+ await closePromise;
+
+ await BrowserTestUtils.closeWindow(win);
+ await SpecialPowers.popPrefEnv();
+ },
+];
+
+var gTestStart = null;
+
+var XPInstallObserver = {
+ observe(aSubject, aTopic, aData) {
+ var installInfo = aSubject.wrappedJSObject;
+ info(
+ "Observed " + aTopic + " for " + installInfo.installs.length + " installs"
+ );
+ installInfo.installs.forEach(function (aInstall) {
+ info(
+ "Install of " +
+ aInstall.sourceURI.spec +
+ " was in state " +
+ aInstall.state
+ );
+ });
+ },
+};
+
+add_task(async function () {
+ requestLongerTimeout(4);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.logging.enabled", true],
+ ["extensions.strictCompatibility", true],
+ ["extensions.install.requireSecureOrigin", false],
+ ["security.dialog_enable_delay", 0],
+ // These tests currently depends on InstallTrigger.install.
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+
+ Services.obs.addObserver(XPInstallObserver, "addon-install-started");
+ Services.obs.addObserver(XPInstallObserver, "addon-install-blocked");
+ Services.obs.addObserver(XPInstallObserver, "addon-install-failed");
+
+ registerCleanupFunction(async function () {
+ // Make sure no more test parts run in case we were timed out
+ TESTS = [];
+
+ let aInstalls = await AddonManager.getAllInstalls();
+ aInstalls.forEach(function (aInstall) {
+ aInstall.cancel();
+ });
+
+ Services.obs.removeObserver(XPInstallObserver, "addon-install-started");
+ Services.obs.removeObserver(XPInstallObserver, "addon-install-blocked");
+ Services.obs.removeObserver(XPInstallObserver, "addon-install-failed");
+ });
+
+ for (let i = 0; i < TESTS.length; ++i) {
+ if (gTestStart) {
+ info("Test part took " + (Date.now() - gTestStart) + "ms");
+ }
+
+ ok(!PopupNotifications.isPanelOpen, "Notification should be closed");
+
+ let installs = await AddonManager.getAllInstalls();
+
+ is(installs.length, 0, "Should be no active installs");
+ info("Running " + TESTS[i].name);
+ gTestStart = Date.now();
+ await TESTS[i]();
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_empty.js b/toolkit/mozapps/extensions/test/xpinstall/browser_empty.js
new file mode 100644
index 0000000000..99bb2eba0d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_empty.js
@@ -0,0 +1,39 @@
+// ----------------------------------------------------------------------------
+// Test whether an install fails when there is no install script present.
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Empty XPI": TESTROOT + "empty.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_failed(install) {
+ is(install.error, AddonManager.ERROR_CORRUPT_FILE, "Install should fail");
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_enabled.js b/toolkit/mozapps/extensions/test/xpinstall/browser_enabled.js
new file mode 100644
index 0000000000..b8ee1b254f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_enabled.js
@@ -0,0 +1,103 @@
+"use strict";
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+});
+
+// Test whether an InstallTrigger.enabled is working
+add_task(async function test_enabled() {
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "enabled.html"
+ );
+
+ let text = await ContentTask.spawn(
+ gBrowser.selectedBrowser,
+ undefined,
+ () => content.document.getElementById("enabled").textContent
+ );
+
+ is(text, "true", "installTrigger should have been enabled");
+ gBrowser.removeCurrentTab();
+});
+
+// Test whether an InstallTrigger.enabled is working
+add_task(async function test_disabled() {
+ Services.prefs.setBoolPref("xpinstall.enabled", false);
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "enabled.html"
+ );
+
+ let text = await ContentTask.spawn(
+ gBrowser.selectedBrowser,
+ undefined,
+ () => content.document.getElementById("enabled").textContent
+ );
+
+ is(text, "false", "installTrigger should have not been enabled");
+ Services.prefs.clearUserPref("xpinstall.enabled");
+ gBrowser.removeCurrentTab();
+});
+
+// Test whether an InstallTrigger.install call fails when xpinstall is disabled
+add_task(async function test_disabled2() {
+ let installDisabledCalled = false;
+
+ Harness.installDisabledCallback = installInfo => {
+ installDisabledCalled = true;
+ ok(true, "Saw installation disabled");
+ };
+
+ Harness.installBlockedCallback = installInfo => {
+ ok(false, "Should never see the blocked install notification");
+ return false;
+ };
+
+ Harness.installConfirmCallback = panel => {
+ ok(false, "Should never see an install confirmation dialog");
+ return false;
+ };
+
+ Harness.setup();
+ Services.prefs.setBoolPref("xpinstall.enabled", false);
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": TESTROOT + "amosigned.xpi",
+ })
+ );
+
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+
+ await BrowserTestUtils.waitForContentEvent(
+ gBrowser.selectedBrowser,
+ "InstallTriggered",
+ true,
+ undefined,
+ true
+ );
+
+ let text = await ContentTask.spawn(
+ gBrowser.selectedBrowser,
+ undefined,
+ () => content.document.getElementById("return").textContent
+ );
+
+ is(text, "false", "installTrigger should have not been enabled");
+ ok(installDisabledCalled, "installDisabled callback was called");
+
+ Services.prefs.clearUserPref("xpinstall.enabled");
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+});
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_hash.js b/toolkit/mozapps/extensions/test/xpinstall/browser_hash.js
new file mode 100644
index 0000000000..7c8431acaf
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_hash.js
@@ -0,0 +1,47 @@
+// ----------------------------------------------------------------------------
+// Test whether an install succeeds when a valid hash is included
+// This verifies bug 302284
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ Hash: "sha1:ee95834ad862245a9ef99ccecc2a857cadc16404",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_hash2.js b/toolkit/mozapps/extensions/test/xpinstall/browser_hash2.js
new file mode 100644
index 0000000000..314df09784
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_hash2.js
@@ -0,0 +1,47 @@
+// ----------------------------------------------------------------------------
+// Test whether an install succeeds using case-insensitive hashes
+// This verifies bug 603021
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ Hash: "sha1:EE95834AD862245A9EF99CCECC2A857CADC16404",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_httphash.js b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash.js
new file mode 100644
index 0000000000..5ca912e44e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash.js
@@ -0,0 +1,55 @@
+// ----------------------------------------------------------------------------
+// Test whether an install succeeds when a valid hash is included in the HTTPS
+// request
+// This verifies bug 591070
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+ Services.prefs.setBoolPref(PREF_INSTALL_REQUIREBUILTINCERTS, false);
+
+ var url = "https://example.com/browser/" + RELATIVE_DIR + "hashRedirect.sjs";
+ url +=
+ "?sha1:ee95834ad862245a9ef99ccecc2a857cadc16404|" +
+ TESTROOT +
+ "amosigned.xpi";
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: url,
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+ Services.prefs.clearUserPref(PREF_INSTALL_REQUIREBUILTINCERTS);
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_httphash2.js b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash2.js
new file mode 100644
index 0000000000..3c09c3f58f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash2.js
@@ -0,0 +1,52 @@
+// ----------------------------------------------------------------------------
+// Test whether an install fails when a invalid hash is included in the HTTPS
+// request
+// This verifies bug 591070
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+ Services.prefs.setBoolPref(PREF_INSTALL_REQUIREBUILTINCERTS, false);
+
+ var url = "https://example.com/browser/" + RELATIVE_DIR + "hashRedirect.sjs";
+ url += "?sha1:foobar|" + TESTROOT + "amosigned.xpi";
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: url,
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_failed(install) {
+ is(install.error, AddonManager.ERROR_INCORRECT_HASH, "Download should fail");
+}
+
+function finish_test(count) {
+ is(count, 0, "0 Add-ons should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+ Services.prefs.clearUserPref(PREF_INSTALL_REQUIREBUILTINCERTS);
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_httphash3.js b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash3.js
new file mode 100644
index 0000000000..904a1ec3b6
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash3.js
@@ -0,0 +1,52 @@
+// ----------------------------------------------------------------------------
+// Tests that the HTTPS hash is ignored when InstallTrigger is passed a hash.
+// This verifies bug 591070
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+ Services.prefs.setBoolPref(PREF_INSTALL_REQUIREBUILTINCERTS, false);
+
+ var url = "https://example.com/browser/" + RELATIVE_DIR + "hashRedirect.sjs";
+ url += "?sha1:foobar|" + TESTROOT + "amosigned.xpi";
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: url,
+ Hash: "sha1:ee95834ad862245a9ef99ccecc2a857cadc16404",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+ Services.prefs.clearUserPref(PREF_INSTALL_REQUIREBUILTINCERTS);
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_httphash4.js b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash4.js
new file mode 100644
index 0000000000..e2abb7be9b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash4.js
@@ -0,0 +1,49 @@
+// ----------------------------------------------------------------------------
+// Test that hashes are ignored in the headers of HTTP requests
+// This verifies bug 591070
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var url = "http://example.com/browser/" + RELATIVE_DIR + "hashRedirect.sjs";
+ url += "?sha1:foobar|" + TESTROOT + "amosigned.xpi";
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: url,
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_httphash5.js b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash5.js
new file mode 100644
index 0000000000..311e02f44b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash5.js
@@ -0,0 +1,53 @@
+// ----------------------------------------------------------------------------
+// Test that only the first HTTPS hash is used
+// This verifies bug 591070
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+ Services.prefs.setBoolPref(PREF_INSTALL_REQUIREBUILTINCERTS, false);
+
+ var url = "https://example.com/browser/" + RELATIVE_DIR + "hashRedirect.sjs";
+ url += "?sha1:ee95834ad862245a9ef99ccecc2a857cadc16404|";
+ url += "https://example.com/browser/" + RELATIVE_DIR + "hashRedirect.sjs";
+ url += "?sha1:foobar|" + TESTROOT + "amosigned.xpi";
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: url,
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+ Services.prefs.clearUserPref(PREF_INSTALL_REQUIREBUILTINCERTS);
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_httphash6.js b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash6.js
new file mode 100644
index 0000000000..a27d300c60
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash6.js
@@ -0,0 +1,107 @@
+// ----------------------------------------------------------------------------
+// Tests that a new hash is accepted when restarting a failed download
+// This verifies bug 593535
+function setup_redirect(aSettings) {
+ var url =
+ "https://example.com/browser/" + RELATIVE_DIR + "redirect.sjs?mode=setup";
+ for (var name in aSettings) {
+ url += "&" + name + "=" + aSettings[name];
+ }
+
+ var req = new XMLHttpRequest();
+ req.open("GET", url, false);
+ req.send(null);
+}
+
+var gInstall = null;
+
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installsCompletedCallback = finish_failed_download;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+ Services.prefs.setBoolPref(PREF_INSTALL_REQUIREBUILTINCERTS, false);
+
+ // Set up the redirect to give a bad hash
+ setup_redirect({
+ "X-Target-Digest": "sha1:foo",
+ Location: "http://example.com/browser/" + RELATIVE_DIR + "amosigned.xpi",
+ });
+
+ var url =
+ "https://example.com/browser/" +
+ RELATIVE_DIR +
+ "redirect.sjs?mode=redirect";
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: url,
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_failed(install) {
+ is(
+ install.error,
+ AddonManager.ERROR_INCORRECT_HASH,
+ "Should have seen a hash failure"
+ );
+ // Stash the failed download while the harness cleans itself up
+ gInstall = install;
+}
+
+function finish_failed_download() {
+ // Setup to track the successful re-download
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ // Give it the right hash this time
+ setup_redirect({
+ "X-Target-Digest": "sha1:ee95834ad862245a9ef99ccecc2a857cadc16404",
+ Location: "http://example.com/browser/" + RELATIVE_DIR + "amosigned.xpi",
+ });
+
+ // The harness expects onNewInstall events for all installs that are about to start
+ Harness.onNewInstall(gInstall);
+
+ // Restart the install as a regular webpage install so the harness tracks it
+ AddonManager.installAddonFromWebpage(
+ "application/x-xpinstall",
+ gBrowser.selectedBrowser,
+ gBrowser.contentPrincipal,
+ gInstall
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+ Services.prefs.clearUserPref(PREF_INSTALL_REQUIREBUILTINCERTS);
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_installchrome.js b/toolkit/mozapps/extensions/test/xpinstall/browser_installchrome.js
new file mode 100644
index 0000000000..5644ce2932
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_installchrome.js
@@ -0,0 +1,36 @@
+// ----------------------------------------------------------------------------
+// Tests that calling InstallTrigger.installChrome works
+function test() {
+ // This test depends on InstallTrigger.installChrome availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT +
+ "installchrome.html? " +
+ encodeURIComponent(TESTROOT + "amosigned.xpi")
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_localfile.js b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile.js
new file mode 100644
index 0000000000..93238cdd87
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile.js
@@ -0,0 +1,42 @@
+// ----------------------------------------------------------------------------
+// Tests installing an local file works when loading the url
+function test() {
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ var cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+
+ var chromeroot = extractChromeRoot(gTestPath);
+ var xpipath = chromeroot + "unsigned.xpi";
+ try {
+ xpipath = cr.convertChromeURL(makeURI(chromeroot + "amosigned.xpi")).spec;
+ } catch (ex) {
+ // scenario where we are running from a .jar and already extracted
+ }
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+ BrowserTestUtils.loadURIString(gBrowser, xpipath);
+ });
+}
+
+function install_ended(install, addon) {
+ Assert.deepEqual(
+ install.installTelemetryInfo,
+ { source: "file-url" },
+ "Got the expected install.installTelemetryInfo"
+ );
+
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_localfile2.js b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile2.js
new file mode 100644
index 0000000000..bfd52b18b9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile2.js
@@ -0,0 +1,61 @@
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ ],
+ });
+});
+
+// ----------------------------------------------------------------------------
+// Test whether an install fails if the url is a local file when requested from
+// web content
+add_task(async function test() {
+ var cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+
+ var chromeroot = getChromeRoot(gTestPath);
+ var xpipath = chromeroot + "amosigned.xpi";
+ try {
+ xpipath = cr.convertChromeURL(makeURI(xpipath)).spec;
+ } catch (ex) {
+ // scenario where we are running from a .jar and already extracted
+ }
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": xpipath,
+ })
+ );
+
+ // In non-e10s the exception in the content page would trigger a test failure
+ if (!gMultiProcessBrowser) {
+ expectUncaughtException();
+ }
+
+ let URI = TESTROOT + "installtrigger.html?manualStartInstall" + triggers;
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: URI },
+ async function (browser) {
+ await SpecialPowers.spawn(browser, [], async function () {
+ let installTriggered = ContentTaskUtils.waitForEvent(
+ docShell.chromeEventHandler,
+ "InstallTriggered",
+ true,
+ null,
+ true
+ );
+ content.wrappedJSObject.startInstall();
+ await installTriggered;
+ let doc = content.document;
+ is(
+ doc.getElementById("return").textContent,
+ "exception",
+ "installTrigger should have failed"
+ );
+ });
+ }
+ );
+});
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_localfile3.js b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile3.js
new file mode 100644
index 0000000000..0d6beb0168
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile3.js
@@ -0,0 +1,42 @@
+// ----------------------------------------------------------------------------
+// Tests installing an add-on from a local file with whitelisting disabled.
+// This should be blocked by the whitelist check.
+function test() {
+ Harness.installBlockedCallback = allow_blocked;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ // Disable direct request whitelisting, installing from file should be blocked.
+ Services.prefs.setBoolPref("xpinstall.whitelist.directRequest", false);
+
+ var cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+
+ var chromeroot = extractChromeRoot(gTestPath);
+ var xpipath = chromeroot + "amosigned.xpi";
+ try {
+ xpipath = cr.convertChromeURL(makeURI(xpipath)).spec;
+ } catch (ex) {
+ // scenario where we are running from a .jar and already extracted
+ }
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+ BrowserTestUtils.loadURIString(gBrowser, xpipath);
+ });
+}
+
+function allow_blocked(installInfo) {
+ ok(true, "Seen blocked");
+ return false;
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+
+ Services.prefs.clearUserPref("xpinstall.whitelist.directRequest");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_localfile4.js b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile4.js
new file mode 100644
index 0000000000..4dc763373a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile4.js
@@ -0,0 +1,55 @@
+// ----------------------------------------------------------------------------
+// Tests installing an add-on from a local file with file origins disabled.
+// This should be blocked by the origin allowed check.
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ // prompt prior to download
+ SpecialPowers.pushPrefEnv({
+ set: [["extensions.postDownloadThirdPartyPrompt", false]],
+ });
+
+ Harness.installBlockedCallback = allow_blocked;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ // Disable local file install, installing by file referrer should be blocked.
+ Services.prefs.setBoolPref("xpinstall.whitelist.fileRequest", false);
+
+ var cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+
+ var chromeroot = extractChromeRoot(gTestPath);
+ var xpipath = chromeroot;
+ try {
+ xpipath = cr.convertChromeURL(makeURI(chromeroot)).spec;
+ } catch (ex) {
+ // scenario where we are running from a .jar and already extracted
+ }
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ xpipath + "installtrigger.html?" + triggers
+ );
+}
+
+function allow_blocked(installInfo) {
+ ok(true, "Seen blocked");
+ return false;
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+
+ Services.prefs.clearUserPref("xpinstall.whitelist.fileRequest");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_localfile4_postDownload.js b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile4_postDownload.js
new file mode 100644
index 0000000000..df3fa1c350
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile4_postDownload.js
@@ -0,0 +1,54 @@
+// ----------------------------------------------------------------------------
+// Tests installing an add-on from a local file with file origins disabled.
+// This should be blocked by the origin allowed check.
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installBlockedCallback = allow_blocked;
+ Harness.installsCompletedCallback = finish_test;
+ // Prevent the Harness from ending the test on download cancel.
+ Harness.downloadCancelledCallback = () => {
+ return false;
+ };
+ Harness.setup();
+
+ // Disable local file install, installing by file referrer should be blocked.
+ Services.prefs.setBoolPref("xpinstall.whitelist.fileRequest", false);
+
+ var cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+
+ var chromeroot = extractChromeRoot(gTestPath);
+ var xpipath = chromeroot;
+ try {
+ xpipath = cr.convertChromeURL(makeURI(chromeroot)).spec;
+ } catch (ex) {
+ // scenario where we are running from a .jar and already extracted
+ }
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ xpipath + "installtrigger.html?" + triggers
+ );
+}
+
+function allow_blocked(installInfo) {
+ ok(true, "Seen blocked");
+ return false;
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+
+ Services.prefs.clearUserPref("xpinstall.whitelist.fileRequest");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_newwindow.js b/toolkit/mozapps/extensions/test/xpinstall/browser_newwindow.js
new file mode 100644
index 0000000000..c4bc5c5d56
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_newwindow.js
@@ -0,0 +1,89 @@
+// This functionality covered in this test is also covered in other tests.
+// The purpose of this test is to catch window leaks. It should fail in
+// debug builds if a window reference is held onto after an install finishes.
+// See bug 1541577 for further details.
+
+let win;
+let popupPromise;
+let newtabPromise;
+const exampleURI = Services.io.newURI("http://example.com");
+async function test() {
+ waitForExplicitFinish(); // have to call this ourselves because we're async.
+
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.security.https_first", false]],
+ });
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install => {
+ return install.addon.uninstall();
+ };
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ win = await BrowserTestUtils.openNewBrowserWindow();
+ Harness.setup(win);
+
+ PermissionTestUtils.add(exampleURI, "install", Services.perms.ALLOW_ACTION);
+
+ const triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ IconURL: TESTROOT + "icon.png",
+ },
+ })
+ );
+
+ const url = `${TESTROOT}installtrigger.html?${triggers}`;
+ newtabPromise = BrowserTestUtils.openNewForegroundTab(win.gBrowser, url);
+ popupPromise = BrowserTestUtils.waitForEvent(
+ win.PanelUI.notificationPanel,
+ "popupshown"
+ );
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+async function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove(exampleURI, "install");
+
+ const results = await SpecialPowers.spawn(
+ win.gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.document.getElementById("return").textContent,
+ status: content.document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(results.return, "true", "installTrigger should have claimed success");
+ is(results.status, "0", "Callback should have seen a success");
+
+ // Explicitly click the "OK" button to avoid the panel reopening in the other window once this
+ // window closes (see also bug 1535069):
+ await popupPromise;
+ win.PanelUI.notificationPanel
+ .querySelector("popupnotification[popupid=addon-installed]")
+ .button.click();
+
+ // Wait for the promise returned by BrowserTestUtils.openNewForegroundTab
+ // to be resolved before removing the window to prevent an uncaught exception
+ // triggered from inside openNewForegroundTab to trigger a test failure due
+ // to a race between openNewForegroundTab and closeWindow calls, e.g. as for
+ // Bug 1728482).
+ await newtabPromise;
+
+ // Now finish the test:
+ await BrowserTestUtils.closeWindow(win);
+ Harness.finish(win);
+ win = null;
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_offline.js b/toolkit/mozapps/extensions/test/xpinstall/browser_offline.js
new file mode 100644
index 0000000000..b17ddd9f81
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_offline.js
@@ -0,0 +1,79 @@
+var proxyPrefValue;
+
+// ----------------------------------------------------------------------------
+// Tests that going offline cancels an in progress download.
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadProgressCallback = download_progress;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_progress(addon, value, maxValue) {
+ try {
+ // Tests always connect to localhost, and per bug 87717, localhost is now
+ // reachable in offline mode. To avoid this, disable any proxy.
+ proxyPrefValue = Services.prefs.getIntPref("network.proxy.type");
+ Services.prefs.setIntPref("network.proxy.type", 0);
+ Services.io.manageOfflineStatus = false;
+ Services.io.offline = true;
+ } catch (ex) {}
+}
+
+function finish_test(count) {
+ function wait_for_online() {
+ info("Checking if the browser is still offline...");
+
+ let tab = gBrowser.selectedTab;
+ BrowserTestUtils.waitForContentEvent(
+ tab.linkedBrowser,
+ "DOMContentLoaded",
+ true
+ ).then(async function () {
+ let url = await ContentTask.spawn(
+ tab.linkedBrowser,
+ null,
+ async function () {
+ return content.document.documentURI;
+ }
+ );
+ info("loaded: " + url);
+ if (/^about:neterror\?e=netOffline/.test(url)) {
+ wait_for_online();
+ } else {
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+ }
+ });
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, "http://example.com/");
+ }
+
+ is(count, 0, "No add-ons should have been installed");
+ try {
+ Services.prefs.setIntPref("network.proxy.type", proxyPrefValue);
+ Services.io.offline = false;
+ } catch (ex) {}
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ wait_for_online();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_privatebrowsing.js b/toolkit/mozapps/extensions/test/xpinstall/browser_privatebrowsing.js
new file mode 100644
index 0000000000..45873e2eff
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_privatebrowsing.js
@@ -0,0 +1,133 @@
+/* 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"
+);
+
+let gDidSeeChannel = false;
+
+AddonTestUtils.initMochitest(this);
+
+function check_channel(subject) {
+ if (!(subject instanceof Ci.nsIHttpChannel)) {
+ return;
+ }
+ let channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ let uri = channel.URI;
+ if (!uri || !uri.spec.endsWith("amosigned.xpi")) {
+ return;
+ }
+ gDidSeeChannel = true;
+ ok(true, "Got request for " + uri.spec);
+
+ let loadInfo = channel.loadInfo;
+ is(
+ loadInfo.originAttributes.privateBrowsingId,
+ 1,
+ "Request should have happened using private browsing"
+ );
+}
+// ----------------------------------------------------------------------------
+// Tests we send the right cookies when installing through an InstallTrigger call
+let gPrivateWin;
+async function test() {
+ waitForExplicitFinish(); // have to call this ourselves because we're async.
+
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.security.https_first_pbm", false]],
+ });
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ gPrivateWin = await BrowserTestUtils.openNewBrowserWindow({ private: true });
+ Harness.setup(gPrivateWin);
+
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI("http://example.com/"),
+ { privateBrowsingId: 1 }
+ );
+
+ PermissionTestUtils.add(principal, "install", Services.perms.ALLOW_ACTION);
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ IconURL: TESTROOT + "icon.png",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gPrivateWin.gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gPrivateWin.gBrowser
+ );
+ Services.obs.addObserver(check_channel, "http-on-before-connect");
+ BrowserTestUtils.loadURIString(
+ gPrivateWin.gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ AddonTestUtils.checkInstallInfo(install, {
+ method: "installTrigger",
+ source: "test-host",
+ sourceURL: /http:\/\/example.com\/.*\/installtrigger.html/,
+ });
+ return addon.uninstall();
+}
+
+const finish_test = async function (count) {
+ ok(
+ gDidSeeChannel,
+ "Should have seen the request for the XPI and verified it was sent the right way."
+ );
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ Services.obs.removeObserver(check_channel, "http-on-before-connect");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ const results = await SpecialPowers.spawn(
+ gPrivateWin.gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.document.getElementById("return").textContent,
+ status: content.document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(results.return, "true", "installTrigger should have claimed success");
+ is(results.status, "0", "Callback should have seen a success");
+
+ await TestUtils.waitForCondition(() =>
+ gPrivateWin.AppMenuNotifications._notifications.some(
+ n => n.id == "addon-installed"
+ )
+ );
+ // Explicitly remove the notification to avoid the panel reopening in the
+ // other window once this window closes (see also bug 1535069):
+ gPrivateWin.AppMenuNotifications.removeNotification("addon-installed");
+
+ // Now finish the test:
+ await BrowserTestUtils.closeWindow(gPrivateWin);
+ Harness.finish(gPrivateWin);
+ gPrivateWin = null;
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_relative.js b/toolkit/mozapps/extensions/test/xpinstall/browser_relative.js
new file mode 100644
index 0000000000..130445c24f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_relative.js
@@ -0,0 +1,67 @@
+// ----------------------------------------------------------------------------
+// Tests that InstallTrigger deals with relative urls correctly.
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: "amosigned.xpi",
+ IconURL: "icon.png",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+const finish_test = async function (count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ const results = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.document.getElementById("return").textContent,
+ status: content.document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(results.return, "true", "installTrigger should have claimed success");
+ is(results.status, "0", "Callback should have seen a success");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_required_useractivation.js b/toolkit/mozapps/extensions/test/xpinstall/browser_required_useractivation.js
new file mode 100644
index 0000000000..6c8894699d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_required_useractivation.js
@@ -0,0 +1,156 @@
+/* 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"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const XPI_URL = `${TESTROOT}amosigned.xpi`;
+
+async function runTestCase(spawnArgs, spawnFn, { expectInstall, clickLink }) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Make use the user activation requirements is enabled while running this test.
+ ["xpinstall.userActivation.required", true],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(TESTROOT, async browser => {
+ const expectedError = `${XPI_URL} install cancelled because of missing user gesture activation`;
+
+ let promiseDone;
+
+ if (expectInstall) {
+ promiseDone = TestUtils.topicObserved("addon-install-blocked").then(
+ ([subject]) => {
+ // Cancel the pending installation flow.
+ subject.wrappedJSObject.cancel();
+ }
+ );
+ } else {
+ promiseDone = new Promise(resolve => {
+ function messageHandler(msgObj) {
+ if (
+ msgObj instanceof Ci.nsIScriptError &&
+ msgObj.message.includes(expectedError)
+ ) {
+ ok(
+ true,
+ "Expect error on triggering navigation to xpi without user gesture activation"
+ );
+ cleanupListener();
+ resolve();
+ }
+ }
+ let listenerCleared = false;
+ function cleanupListener() {
+ if (!listenerCleared) {
+ Services.console.unregisterListener(messageHandler);
+ }
+ listenerCleared = true;
+ }
+ Services.console.registerListener(messageHandler);
+ registerCleanupFunction(cleanupListener);
+ });
+ }
+
+ await SpecialPowers.spawn(browser, spawnArgs, spawnFn);
+
+ if (clickLink) {
+ info("Click link element");
+ // Wait for the install to trigger the third party website doorhanger.
+ // Trigger the link by simulating a mouse click, and expect it to trigger the
+ // install flow instead (the window is still navigated to the xpi url from the
+ // webpage JS code, but doing it while handling a DOM event does make it pass
+ // the user activation check).
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#link-to-xpi-file",
+ {},
+ browser
+ );
+ }
+
+ info("Wait test case to be completed");
+ await promiseDone;
+ ok(true, "Test case run completed");
+ });
+}
+
+add_task(async function testSuccessOnUserActivatedLink() {
+ await runTestCase(
+ [XPI_URL],
+ xpiURL => {
+ const { document } = this.content;
+ const link = document.createElement("a");
+ link.id = "link-to-xpi-file";
+ link.setAttribute("href", xpiURL);
+ link.textContent = "Link to XPI File";
+
+ // Empty the test case and add the link, if the link is not visible
+ // without scrolling, BrowserTestUtils.synthesizeMouseAtCenter may
+ // fail to trigger the mouse event.
+ document.body.innerHTML = "";
+ document.body.appendChild(link);
+ },
+ { expectInstall: true, clickLink: true }
+ );
+});
+
+add_task(async function testSuccessOnJSWithUserActivation() {
+ await runTestCase(
+ [XPI_URL],
+ xpiURL => {
+ const { document } = this.content;
+ const link = document.createElement("a");
+ link.id = "link-to-xpi-file";
+ link.setAttribute("href", "#");
+ link.textContent = "Link to XPI File";
+
+ // Empty the test case and add the link, if the link is not visible
+ // without scrolling, BrowserTestUtils.synthesizeMouseAtCenter may
+ // fail to trigger the mouse event.
+ document.body.innerHTML = "";
+ document.body.appendChild(link);
+
+ this.content.eval(`
+ const linkEl = document.querySelector("#link-to-xpi-file");
+ linkEl.onclick = () => {
+ // This is expected to trigger the install flow successfully if handling
+ // a user gesture DOM event, but to fail when triggered outside of it (as
+ // done a few line below).
+ window.location = "${xpiURL}";
+ };
+ `);
+ },
+ { expectInstall: true, clickLink: true }
+ );
+});
+
+add_task(async function testFailureOnJSWithoutUserActivation() {
+ await runTestCase(
+ [XPI_URL],
+ xpiURL => {
+ this.content.eval(`window.location = "${xpiURL}";`);
+ },
+ { expectInstall: false }
+ );
+});
+
+add_task(async function testFailureOnJSWithoutUserActivation() {
+ await runTestCase(
+ [XPI_URL],
+ xpiURL => {
+ this.content.eval(`
+ const frame = document.createElement("iframe");
+ frame.src = "${xpiURL}";
+ document.body.innerHTML = "";
+ document.body.appendChild(frame);
+ `);
+ },
+ { expectInstall: false }
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_signed_url.js b/toolkit/mozapps/extensions/test/xpinstall/browser_signed_url.js
new file mode 100644
index 0000000000..85738cfb60
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_signed_url.js
@@ -0,0 +1,29 @@
+// ----------------------------------------------------------------------------
+// Tests installing an signed add-on by navigating directly to the url
+function test() {
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+ BrowserTestUtils.loadURIString(gBrowser, TESTROOT + "amosigned.xpi");
+ });
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_softwareupdate.js b/toolkit/mozapps/extensions/test/xpinstall/browser_softwareupdate.js
new file mode 100644
index 0000000000..1d285fec9d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_softwareupdate.js
@@ -0,0 +1,36 @@
+// ----------------------------------------------------------------------------
+// Tests that calling InstallTrigger.startSoftwareUpdate works
+function test() {
+ // This test depends on InstallTrigger.startSoftwareUpdate availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT +
+ "startsoftwareupdate.html? " +
+ encodeURIComponent(TESTROOT + "amosigned.xpi")
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_trigger_redirect.js b/toolkit/mozapps/extensions/test/xpinstall/browser_trigger_redirect.js
new file mode 100644
index 0000000000..b124bad586
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_trigger_redirect.js
@@ -0,0 +1,45 @@
+// ----------------------------------------------------------------------------
+// Tests that the InstallTrigger callback can redirect to a relative url.
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(gBrowser, TESTROOT + "triggerredirect.html");
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ is(
+ gBrowser.currentURI.spec,
+ TESTROOT + "triggerredirect.html#foo",
+ "Should have redirected"
+ );
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger.js b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger.js
new file mode 100644
index 0000000000..808ba61420
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger.js
@@ -0,0 +1,68 @@
+// ----------------------------------------------------------------------------
+// Tests installing an unsigned add-on through an InstallTrigger call in web
+// content.
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "unsigned.xpi",
+ IconURL: TESTROOT + "icon.png",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+const finish_test = async function (count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ const results = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.document.getElementById("return").textContent,
+ status: content.document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(results.return, "true", "installTrigger should have claimed success");
+ is(results.status, "0", "Callback should have seen a success");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_iframe.js b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_iframe.js
new file mode 100644
index 0000000000..0355955f00
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_iframe.js
@@ -0,0 +1,77 @@
+// ----------------------------------------------------------------------------
+// Test for bug 589598 - Ensure that installing through InstallTrigger
+// works in an iframe in web content.
+
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var inner_url = encodeURIComponent(
+ TESTROOT +
+ "installtrigger.html?" +
+ encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "unsigned.xpi",
+ IconURL: TESTROOT + "icon.png",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ )
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT + "installtrigger_frame.html?" + inner_url
+ );
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+const finish_test = async function (count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ const results = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.frames[0].document.getElementById("return").textContent,
+ status: content.frames[0].document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(
+ results.return,
+ "true",
+ "installTrigger in iframe should have claimed success"
+ );
+ is(results.status, "0", "Callback in iframe should have seen a success");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_xorigin.js b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_xorigin.js
new file mode 100644
index 0000000000..bd6fc46f80
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_xorigin.js
@@ -0,0 +1,58 @@
+// ----------------------------------------------------------------------------
+// Ensure that an inner frame from a different origin can't initiate an install
+
+var wasOriginBlocked = false;
+
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installOriginBlockedCallback = install_blocked;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var inner_url = encodeURIComponent(
+ TESTROOT +
+ "installtrigger.html?" +
+ encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ IconURL: TESTROOT + "icon.png",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ )
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ TESTROOT2 + "installtrigger_frame.html?" + inner_url
+ );
+}
+
+function install_blocked(installInfo) {
+ wasOriginBlocked = true;
+}
+
+function finish_test(count) {
+ ok(
+ wasOriginBlocked,
+ "Should have been blocked due to the cross origin request."
+ );
+
+ is(count, 0, "No add-ons should have been installed");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_url.js b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_url.js
new file mode 100644
index 0000000000..060fed91f9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_url.js
@@ -0,0 +1,43 @@
+// ----------------------------------------------------------------------------
+// Tests installing an unsigned add-on by navigating directly to the url
+function test() {
+ waitForExplicitFinish();
+ SpecialPowers.pushPrefEnv(
+ {
+ set: [
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ },
+ runTest
+ );
+}
+
+function runTest() {
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+ BrowserTestUtils.loadURIString(gBrowser, TESTROOT + "unsigned.xpi");
+ });
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/bug540558.html b/toolkit/mozapps/extensions/test/xpinstall/bug540558.html
new file mode 100644
index 0000000000..045e3e2d7c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/bug540558.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page tests that window.InstallTrigger.install works -->
+
+<head>
+<title>InstallTrigger tests</title>
+<script type="text/javascript">
+/* exported startInstall */
+function startInstall() {
+ window.InstallTrigger.install({
+ "Unsigned XPI": "amosigned.xpi",
+ });
+}
+</script>
+</head>
+<body onload="startInstall()">
+<p>InstallTrigger tests</p>
+<p id="return"></p>
+<p id="status"></p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/bug638292.html b/toolkit/mozapps/extensions/test/xpinstall/bug638292.html
new file mode 100644
index 0000000000..198207d4bf
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/bug638292.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page tests InstallTrigger is defined in a new window -->
+
+<head>
+<title>InstallTrigger tests</title>
+</head>
+<body>
+<p>InstallTrigger tests</p>
+<p><a id="link1" target="_blank" href="enabled.html">Open window with target</a></p>
+<p><a id="link2" onclick="window.open(this.href); return false" href="enabled.html">Open window with JS</a></p>
+<p><a id="link3" href="enabled.html">Open window with middle-click</a></p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/bug645699.html b/toolkit/mozapps/extensions/test/xpinstall/bug645699.html
new file mode 100644
index 0000000000..d993ad070d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/bug645699.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<head>
+<title>InstallTrigger tests</title>
+<script type="text/javascript">
+/* globals InstallTrigger */
+/* exported startInstall */
+function startInstall() {
+ var whiteUrl = "https://example.org/";
+
+ try {
+ Object.defineProperty(window, "location", { value: { href: whiteUrl } });
+ throw new Error("Object.defineProperty(window, 'location', ...) should have thrown");
+ } catch (exc) {
+ if (!(exc instanceof TypeError))
+ throw exc;
+ }
+ Object.defineProperty(document, "documentURIObject", { spec: { href: whiteUrl } });
+
+ InstallTrigger.install({
+ "Unsigned XPI": "http://example.com/browser/toolkit/mozapps/extensions/test/xpinstall/amosigned.xpi",
+ });
+}
+</script>
+</head>
+<body onload="startInstall()">
+<p>InstallTrigger tests</p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/cookieRedirect.sjs b/toolkit/mozapps/extensions/test/xpinstall/cookieRedirect.sjs
new file mode 100644
index 0000000000..5ddc6f9bd4
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/cookieRedirect.sjs
@@ -0,0 +1,23 @@
+// Simple script redirects to the query part of the uri if the cookie "xpinstall"
+// has the value "true", otherwise gives a 500 error.
+
+function handleRequest(request, response) {
+ let cookie = null;
+ if (request.hasHeader("Cookie")) {
+ let cookies = request.getHeader("Cookie").split(";");
+ for (let i = 0; i < cookies.length; i++) {
+ if (cookies[i].substring(0, 10) == "xpinstall=") {
+ cookie = cookies[i].substring(10);
+ }
+ }
+ }
+
+ if (cookie == "true") {
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ response.setHeader("Location", request.queryString);
+ response.write("See " + request.queryString);
+ } else {
+ response.setStatusLine(request.httpVersion, 500, "Internal Server Error");
+ response.write("Invalid request");
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/corrupt.xpi b/toolkit/mozapps/extensions/test/xpinstall/corrupt.xpi
new file mode 100644
index 0000000000..35d7bd5e5d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/corrupt.xpi
@@ -0,0 +1 @@
+This is a corrupt zip file
diff --git a/toolkit/mozapps/extensions/test/xpinstall/empty.xpi b/toolkit/mozapps/extensions/test/xpinstall/empty.xpi
new file mode 100644
index 0000000000..74ed2b8174
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/empty.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpinstall/enabled.html b/toolkit/mozapps/extensions/test/xpinstall/enabled.html
new file mode 100644
index 0000000000..dea8a59036
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/enabled.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page will test if InstallTrigger seems to be enabled -->
+
+<head>
+<title>InstallTrigger tests</title>
+<script type="text/javascript">
+/* globals InstallTrigger */
+/* exported init */
+function init() {
+ document.getElementById("enabled").textContent = InstallTrigger.enabled() ? "true" : "false";
+ dump("Sending PageLoaded\n");
+ var event = new CustomEvent("PageLoaded");
+ window.dispatchEvent(event);
+}
+</script>
+</head>
+<body onload="init()">
+<p>InstallTrigger tests</p>
+<p id="enabled"></p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/hashRedirect.sjs b/toolkit/mozapps/extensions/test/xpinstall/hashRedirect.sjs
new file mode 100644
index 0000000000..8137f9e58a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/hashRedirect.sjs
@@ -0,0 +1,14 @@
+// Simple script redirects takes the query part of te request and splits it on
+// the | character. Anything before is included as the X-Target-Digest header
+// the latter part is used as the url to redirect to
+
+function handleRequest(request, response) {
+ let pos = request.queryString.indexOf("|");
+ let header = request.queryString.substring(0, pos);
+ let url = request.queryString.substring(pos + 1);
+
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ response.setHeader("X-Target-Digest", header);
+ response.setHeader("Location", url);
+ response.write("See " + url);
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/head.js b/toolkit/mozapps/extensions/test/xpinstall/head.js
new file mode 100644
index 0000000000..7863f56e09
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/head.js
@@ -0,0 +1,545 @@
+/* eslint no-unused-vars: ["error", {vars: "local", args: "none"}] */
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+const { PromptTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromptTestUtils.sys.mjs"
+);
+
+const RELATIVE_DIR = "toolkit/mozapps/extensions/test/xpinstall/";
+
+const TESTROOT = "http://example.com/browser/" + RELATIVE_DIR;
+const TESTROOT2 = "http://example.org/browser/" + RELATIVE_DIR;
+const PROMPT_URL = "chrome://global/content/commonDialog.xhtml";
+const ADDONS_URL = "chrome://mozapps/content/extensions/aboutaddons.html";
+const PREF_LOGGING_ENABLED = "extensions.logging.enabled";
+const PREF_INSTALL_REQUIREBUILTINCERTS =
+ "extensions.install.requireBuiltInCerts";
+const PREF_INSTALL_REQUIRESECUREORIGIN =
+ "extensions.install.requireSecureOrigin";
+const CHROME_NAME = "mochikit";
+
+function getChromeRoot(path) {
+ if (path === undefined) {
+ return "chrome://" + CHROME_NAME + "/content/browser/" + RELATIVE_DIR;
+ }
+ return getRootDirectory(path);
+}
+
+function extractChromeRoot(path) {
+ var chromeRootPath = getChromeRoot(path);
+ var jar = getJar(chromeRootPath);
+ if (jar) {
+ var tmpdir = extractJarToTmp(jar);
+ return "file://" + tmpdir.path + "/";
+ }
+ return chromeRootPath;
+}
+
+function setInstallTriggerPrefs() {
+ Services.prefs.setBoolPref("extensions.InstallTrigger.enabled", true);
+ Services.prefs.setBoolPref("extensions.InstallTriggerImpl.enabled", true);
+ // Relax the user input requirements while running tests that call this test helper.
+ Services.prefs.setBoolPref("xpinstall.userActivation.required", false);
+ registerCleanupFunction(clearInstallTriggerPrefs);
+}
+
+function clearInstallTriggerPrefs() {
+ Services.prefs.clearUserPref("extensions.InstallTrigger.enabled");
+ Services.prefs.clearUserPref("extensions.InstallTriggerImpl.enabled");
+ Services.prefs.clearUserPref("xpinstall.userActivation.required");
+}
+
+/**
+ * This is a test harness designed to handle responding to UI during the process
+ * of installing an XPI. A test can set callbacks to hear about specific parts
+ * of the sequence.
+ * Before use setup must be called and finish must be called afterwards.
+ */
+var Harness = {
+ // If set then the callback is called when an install is attempted and
+ // software installation is disabled.
+ installDisabledCallback: null,
+ // If set then the callback is called when an install is attempted and
+ // then canceled.
+ installCancelledCallback: null,
+ // If set then the callback will be called when an install's origin is blocked.
+ installOriginBlockedCallback: null,
+ // If set then the callback will be called when an install is blocked by the
+ // whitelist. The callback should return true to continue with the install
+ // anyway.
+ installBlockedCallback: null,
+ // If set will be called in the event of authentication being needed to get
+ // the xpi. Should return a 2 element array of username and password, or
+ // null to not authenticate.
+ authenticationCallback: null,
+ // If set this will be called to allow checking the contents of the xpinstall
+ // confirmation dialog. The callback should return true to continue the install.
+ installConfirmCallback: null,
+ // If set will be called when downloading of an item has begun.
+ downloadStartedCallback: null,
+ // If set will be called during the download of an item.
+ downloadProgressCallback: null,
+ // If set will be called when an xpi fails to download.
+ downloadFailedCallback: null,
+ // If set will be called when an xpi download is cancelled.
+ downloadCancelledCallback: null,
+ // If set will be called when downloading of an item has ended.
+ downloadEndedCallback: null,
+ // If set will be called when installation by the extension manager of an xpi
+ // item starts
+ installStartedCallback: null,
+ // If set will be called when an xpi fails to install.
+ installFailedCallback: null,
+ // If set will be called when each xpi item to be installed completes
+ // installation.
+ installEndedCallback: null,
+ // If set will be called when all triggered items are installed or the install
+ // is canceled.
+ installsCompletedCallback: null,
+ // If set the harness will wait for this DOM event before calling
+ // installsCompletedCallback
+ finalContentEvent: null,
+
+ waitingForEvent: false,
+ pendingCount: null,
+ installCount: null,
+ runningInstalls: null,
+
+ waitingForFinish: false,
+
+ // A unique value to return from the installConfirmCallback to indicate that
+ // the install UI shouldn't be closed automatically
+ leaveOpen: {},
+
+ // Setup and tear down functions
+ setup(win = window) {
+ if (!this.waitingForFinish) {
+ waitForExplicitFinish();
+ this.waitingForFinish = true;
+
+ Services.prefs.setBoolPref(PREF_INSTALL_REQUIRESECUREORIGIN, false);
+ Services.prefs.setBoolPref(PREF_LOGGING_ENABLED, true);
+ Services.prefs.setBoolPref(
+ "network.cookieJarSettings.unblocked_for_testing",
+ true
+ );
+
+ Services.obs.addObserver(this, "addon-install-started");
+ Services.obs.addObserver(this, "addon-install-disabled");
+ Services.obs.addObserver(this, "addon-install-origin-blocked");
+ Services.obs.addObserver(this, "addon-install-blocked");
+ Services.obs.addObserver(this, "addon-install-failed");
+
+ // For browser_auth tests which trigger auth dialogs.
+ Services.obs.addObserver(this, "tabmodal-dialog-loaded");
+ Services.obs.addObserver(this, "common-dialog-loaded");
+
+ this._boundWin = Cu.getWeakReference(win); // need this so our addon manager listener knows which window to use.
+ AddonManager.addInstallListener(this);
+ AddonManager.addAddonListener(this);
+
+ win.addEventListener("popupshown", this);
+ win.PanelUI.notificationPanel.addEventListener("popupshown", this);
+
+ var self = this;
+ registerCleanupFunction(async function () {
+ Services.prefs.clearUserPref(PREF_LOGGING_ENABLED);
+ Services.prefs.clearUserPref(PREF_INSTALL_REQUIRESECUREORIGIN);
+ Services.prefs.clearUserPref(
+ "network.cookieJarSettings.unblocked_for_testing"
+ );
+
+ Services.obs.removeObserver(self, "addon-install-started");
+ Services.obs.removeObserver(self, "addon-install-disabled");
+ Services.obs.removeObserver(self, "addon-install-origin-blocked");
+ Services.obs.removeObserver(self, "addon-install-blocked");
+ Services.obs.removeObserver(self, "addon-install-failed");
+
+ Services.obs.removeObserver(self, "tabmodal-dialog-loaded");
+ Services.obs.removeObserver(self, "common-dialog-loaded");
+
+ AddonManager.removeInstallListener(self);
+ AddonManager.removeAddonListener(self);
+
+ win.removeEventListener("popupshown", self);
+ win.PanelUI.notificationPanel.removeEventListener("popupshown", self);
+ win = null;
+
+ let aInstalls = await AddonManager.getAllInstalls();
+ is(
+ aInstalls.length,
+ 0,
+ "Should be no active installs at the end of the test"
+ );
+ await Promise.all(
+ aInstalls.map(async function (aInstall) {
+ info(
+ "Install for " +
+ aInstall.sourceURI +
+ " is in state " +
+ aInstall.state
+ );
+ if (aInstall.state == AddonManager.STATE_INSTALLED) {
+ await aInstall.addon.uninstall();
+ } else {
+ aInstall.cancel();
+ }
+ })
+ );
+ });
+ }
+
+ this.installCount = 0;
+ this.pendingCount = 0;
+ this.runningInstalls = [];
+ },
+
+ finish(win = window) {
+ // Some tests using this harness somehow finish leaving
+ // the addon-installed panel open. hiding here addresses
+ // that which fixes the rest of the tests. Since no test
+ // here cares about this panel, we just need it to close.
+ win.PanelUI.notificationPanel.hidePopup();
+ win.AppMenuNotifications.removeNotification("addon-installed");
+ delete this._boundWin;
+ finish();
+ },
+
+ endTest() {
+ let callback = this.installsCompletedCallback;
+ let count = this.installCount;
+
+ is(this.runningInstalls.length, 0, "Should be no running installs left");
+ this.runningInstalls.forEach(function (aInstall) {
+ info(
+ "Install for " + aInstall.sourceURI + " is in state " + aInstall.state
+ );
+ });
+
+ this.installOriginBlockedCallback = null;
+ this.installBlockedCallback = null;
+ this.authenticationCallback = null;
+ this.installConfirmCallback = null;
+ this.downloadStartedCallback = null;
+ this.downloadProgressCallback = null;
+ this.downloadCancelledCallback = null;
+ this.downloadFailedCallback = null;
+ this.downloadEndedCallback = null;
+ this.installStartedCallback = null;
+ this.installFailedCallback = null;
+ this.installEndedCallback = null;
+ this.installsCompletedCallback = null;
+ this.runningInstalls = null;
+
+ if (callback) {
+ executeSoon(() => callback(count));
+ }
+ },
+
+ promptReady(dialog) {
+ let promptType = dialog.args.promptType;
+
+ switch (promptType) {
+ case "alert":
+ case "alertCheck":
+ case "confirmCheck":
+ case "confirm":
+ case "confirmEx":
+ PromptTestUtils.handlePrompt(dialog, { buttonNumClick: 0 });
+ break;
+ case "promptUserAndPass":
+ // This is a login dialog, hopefully an authentication prompt
+ // for the xpi.
+ if (this.authenticationCallback) {
+ var auth = this.authenticationCallback();
+ if (auth && auth.length == 2) {
+ PromptTestUtils.handlePrompt(dialog, {
+ loginInput: auth[0],
+ passwordInput: auth[1],
+ buttonNumClick: 0,
+ });
+ } else {
+ PromptTestUtils.handlePrompt(dialog, { buttonNumClick: 1 });
+ }
+ } else {
+ PromptTestUtils.handlePrompt(dialog, { buttonNumClick: 1 });
+ }
+ break;
+ default:
+ ok(false, "prompt type " + promptType + " not handled in test.");
+ break;
+ }
+ },
+
+ popupReady(panel) {
+ if (this.installBlockedCallback) {
+ ok(false, "Should have been blocked by the whitelist");
+ }
+ this.pendingCount++;
+
+ // If there is a confirm callback then its return status determines whether
+ // to install the items or not. If not the test is over.
+ let result = true;
+ if (this.installConfirmCallback) {
+ result = this.installConfirmCallback(panel);
+ if (result === this.leaveOpen) {
+ return;
+ }
+ }
+
+ if (!result) {
+ panel.secondaryButton.click();
+ } else {
+ panel.button.click();
+ }
+ },
+
+ handleEvent(event) {
+ if (event.type === "popupshown") {
+ if (event.target == event.view.PanelUI.notificationPanel) {
+ event.view.PanelUI.notificationPanel.hidePopup();
+ } else if (event.target.firstElementChild) {
+ let popupId = event.target.firstElementChild.getAttribute("popupid");
+ if (popupId === "addon-webext-permissions") {
+ this.popupReady(event.target.firstElementChild);
+ } else if (popupId === "addon-install-failed") {
+ event.target.firstElementChild.button.click();
+ }
+ }
+ }
+ },
+
+ // Install blocked handling
+
+ installDisabled(installInfo) {
+ ok(
+ !!this.installDisabledCallback,
+ "Installation shouldn't have been disabled"
+ );
+ if (this.installDisabledCallback) {
+ this.installDisabledCallback(installInfo);
+ }
+ this.expectingCancelled = true;
+ this.expectingCancelled = false;
+ this.endTest();
+ },
+
+ installCancelled(installInfo) {
+ if (this.expectingCancelled) {
+ return;
+ }
+
+ ok(
+ !!this.installCancelledCallback,
+ "Installation shouldn't have been cancelled"
+ );
+ if (this.installCancelledCallback) {
+ this.installCancelledCallback(installInfo);
+ }
+ this.endTest();
+ },
+
+ installOriginBlocked(installInfo) {
+ ok(!!this.installOriginBlockedCallback, "Shouldn't have been blocked");
+ if (this.installOriginBlockedCallback) {
+ this.installOriginBlockedCallback(installInfo);
+ }
+ this.endTest();
+ },
+
+ installBlocked(installInfo) {
+ ok(
+ !!this.installBlockedCallback,
+ "Shouldn't have been blocked by the whitelist"
+ );
+ if (
+ this.installBlockedCallback &&
+ this.installBlockedCallback(installInfo)
+ ) {
+ this.installBlockedCallback = null;
+ installInfo.install();
+ } else {
+ this.expectingCancelled = true;
+ installInfo.installs.forEach(function (install) {
+ install.cancel();
+ });
+ this.expectingCancelled = false;
+ this.endTest();
+ }
+ },
+
+ // Addon Install Listener
+
+ onNewInstall(install) {
+ this.runningInstalls.push(install);
+
+ if (this.finalContentEvent && !this.waitingForEvent) {
+ this.waitingForEvent = true;
+ info("Waiting for " + this.finalContentEvent);
+ BrowserTestUtils.waitForContentEvent(
+ this._boundWin.get().gBrowser.selectedBrowser,
+ this.finalContentEvent,
+ true,
+ null,
+ true
+ ).then(() => {
+ info("Saw " + this.finalContentEvent + "," + this.waitingForEvent);
+ this.waitingForEvent = false;
+ if (this.pendingCount == 0) {
+ this.endTest();
+ }
+ });
+ }
+ },
+
+ onDownloadStarted(install) {
+ this.pendingCount++;
+ if (this.downloadStartedCallback) {
+ this.downloadStartedCallback(install);
+ }
+ },
+
+ onDownloadProgress(install) {
+ if (this.downloadProgressCallback) {
+ this.downloadProgressCallback(install);
+ }
+ },
+
+ onDownloadEnded(install) {
+ if (this.downloadEndedCallback) {
+ this.downloadEndedCallback(install);
+ }
+ },
+
+ onDownloadCancelled(install) {
+ isnot(
+ this.runningInstalls.indexOf(install),
+ -1,
+ "Should only see cancelations for started installs"
+ );
+ this.runningInstalls.splice(this.runningInstalls.indexOf(install), 1);
+
+ if (
+ this.downloadCancelledCallback &&
+ this.downloadCancelledCallback(install) === false
+ ) {
+ return;
+ }
+ this.checkTestEnded();
+ },
+
+ onDownloadFailed(install) {
+ if (this.downloadFailedCallback) {
+ this.downloadFailedCallback(install);
+ }
+ this.checkTestEnded();
+ },
+
+ onInstallStarted(install) {
+ if (this.installStartedCallback) {
+ this.installStartedCallback(install);
+ }
+ },
+
+ async onInstallEnded(install, addon) {
+ this.installCount++;
+ if (this.installEndedCallback) {
+ await this.installEndedCallback(install, addon);
+ }
+ this.checkTestEnded();
+ },
+
+ onInstallFailed(install) {
+ if (this.installFailedCallback) {
+ this.installFailedCallback(install);
+ }
+ this.checkTestEnded();
+ },
+
+ onUninstalled(addon) {
+ let idx = this.runningInstalls.findIndex(install => install.addon == addon);
+ if (idx != -1) {
+ this.runningInstalls.splice(idx, 1);
+ this.checkTestEnded();
+ }
+ },
+
+ onInstallCancelled(install) {
+ // This is ugly. We have a bunch of tests that cancel installs
+ // but don't expect this event to be raised.
+ // For at least one test (browser_whitelist3.js), we used to generate
+ // onDownloadCancelled when the user cancelled the installation at the
+ // confirmation prompt. We're now generating onInstallCancelled instead
+ // of onDownloadCancelled but making this code unconditional breaks a
+ // bunch of other tests. Ugh.
+ let idx = this.runningInstalls.indexOf(install);
+ if (idx != -1) {
+ this.runningInstalls.splice(this.runningInstalls.indexOf(install), 1);
+ this.checkTestEnded();
+ }
+ },
+
+ checkTestEnded() {
+ if (--this.pendingCount == 0 && !this.waitingForEvent) {
+ this.endTest();
+ }
+ },
+
+ // nsIObserver
+
+ observe(subject, topic, data) {
+ var installInfo = subject.wrappedJSObject;
+ switch (topic) {
+ case "addon-install-started":
+ is(
+ this.runningInstalls.length,
+ installInfo.installs.length,
+ "Should have seen the expected number of installs started"
+ );
+ break;
+ case "addon-install-disabled":
+ this.installDisabled(installInfo);
+ break;
+ case "addon-install-cancelled":
+ this.installCancelled(installInfo);
+ break;
+ case "addon-install-origin-blocked":
+ this.installOriginBlocked(installInfo);
+ break;
+ case "addon-install-blocked":
+ this.installBlocked(installInfo);
+ break;
+ case "addon-install-failed":
+ installInfo.installs.forEach(function (aInstall) {
+ isnot(
+ this.runningInstalls.indexOf(aInstall),
+ -1,
+ "Should only see failures for started installs"
+ );
+
+ ok(
+ aInstall.error != 0 || aInstall.addon.appDisabled,
+ "Failed installs should have an error or be appDisabled"
+ );
+
+ this.runningInstalls.splice(
+ this.runningInstalls.indexOf(aInstall),
+ 1
+ );
+ }, this);
+ break;
+ case "tabmodal-dialog-loaded":
+ let browser = subject.ownerGlobal.gBrowser.selectedBrowser;
+ let prompt = browser.tabModalPromptBox.getPrompt(subject);
+ this.promptReady(prompt.Dialog);
+ break;
+ case "common-dialog-loaded":
+ this.promptReady(subject.Dialog);
+ break;
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/incompatible.xpi b/toolkit/mozapps/extensions/test/xpinstall/incompatible.xpi
new file mode 100644
index 0000000000..de895fd1d9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/incompatible.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpinstall/installchrome.html b/toolkit/mozapps/extensions/test/xpinstall/installchrome.html
new file mode 100644
index 0000000000..d9ff573ab5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/installchrome.html
@@ -0,0 +1,23 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page will accept a url as the uri query and pass it to InstallTrigger.installChrome -->
+
+<head>
+<title>InstallTrigger tests</title>
+<script type="text/javascript">
+/* globals InstallTrigger */
+/* exported startInstall */
+function startInstall() {
+ InstallTrigger.installChrome(InstallTrigger.SKIN,
+ decodeURIComponent(document.location.search.substring(1)),
+ "test");
+}
+</script>
+</head>
+<body onload="startInstall()">
+<p>InstallTrigger tests</p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/installtrigger.html b/toolkit/mozapps/extensions/test/xpinstall/installtrigger.html
new file mode 100644
index 0000000000..d68e4acbe3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/installtrigger.html
@@ -0,0 +1,57 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page will accept some json as the uri query and pass it to InstallTrigger.install -->
+
+<head>
+<title>InstallTrigger tests</title>
+<script type="text/javascript">
+/* globals InstallTrigger */
+/* exported startInstall */
+function installCallback(url, status) {
+ document.getElementById("status").textContent = status;
+
+ dump("Sending InstallComplete\n");
+ var event = new CustomEvent("InstallComplete");
+ var target = window.parent ? window.parent : window;
+ target.dispatchEvent(event);
+}
+
+function startInstall(viaWindowLoaded = false) {
+ var event = new CustomEvent("InstallTriggered");
+ var text;
+ if (viaWindowLoaded) {
+ text = decodeURIComponent(document.location.search.substring(1));
+ } else {
+ text = decodeURIComponent(document.location.search.substring("?manualStartInstall".length));
+ }
+ var triggers = JSON.parse(text);
+ try {
+ document.getElementById("return").textContent = InstallTrigger.install(triggers, installCallback);
+ dump("Sending InstallTriggered\n");
+ window.dispatchEvent(event);
+ } catch (e) {
+ document.getElementById("return").textContent = "exception";
+ dump("Sending InstallTriggered\n");
+ window.dispatchEvent(event);
+ if (viaWindowLoaded) {
+ throw e;
+ }
+ }
+}
+
+window.onload = function () {
+ if (!document.location.search.startsWith("?manualStartInstall")) {
+ startInstall(true);
+ }
+}
+</script>
+</head>
+<body>
+<p>InstallTrigger tests</p>
+<p id="return"></p>
+<p id="status"></p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/installtrigger_frame.html b/toolkit/mozapps/extensions/test/xpinstall/installtrigger_frame.html
new file mode 100644
index 0000000000..7e4bccab18
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/installtrigger_frame.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page will accept some url as the uri query and load it in
+ an inner iframe, which will run InstallTrigger.install -->
+
+<head>
+<title>InstallTrigger frame tests</title>
+<script type="text/javascript">
+/* exported prepChild */
+function prepChild() {
+ // Pass our parameters over to the child
+ var child = window.frames[0];
+ var url = decodeURIComponent(document.location.search.substr(1));
+ child.location = url;
+}
+</script>
+</head>
+<body onload="prepChild()">
+
+<iframe src="about:blank">
+</iframe>
+
+<p>InstallTrigger tests</p>
+<p id="return"></p>
+<p id="status"></p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/navigate.html b/toolkit/mozapps/extensions/test/xpinstall/navigate.html
new file mode 100644
index 0000000000..96052009b9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/navigate.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page will accept some url as the uri query and navigate to it by
+ clicking a link -->
+
+<head>
+<title>Navigation tests</title>
+<script type="text/javascript">
+/* exported navigate */
+function navigate() {
+ var url = decodeURIComponent(document.location.search.substr(1));
+ var link = document.getElementById("link");
+ link.href = url;
+ link.click();
+}
+</script>
+</head>
+<body onload="navigate()">
+
+<p><a id="link">Test Link</a></p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/recommended.xpi b/toolkit/mozapps/extensions/test/xpinstall/recommended.xpi
new file mode 100644
index 0000000000..e180decfc5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/recommended.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpinstall/redirect.sjs b/toolkit/mozapps/extensions/test/xpinstall/redirect.sjs
new file mode 100644
index 0000000000..14236ee821
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/redirect.sjs
@@ -0,0 +1,39 @@
+// Script has two modes based on the query string. If the mode is "setup" then
+// parameters from the query string configure the redirection. If the mode is
+// "redirect" then a redirect is returned
+
+function handleRequest(request, response) {
+ let parts = request.queryString.split("&");
+ let settings = {};
+
+ parts.forEach(function (aString) {
+ let [k, v] = aString.split("=");
+ settings[k] = decodeURIComponent(v);
+ });
+
+ if (settings.mode == "setup") {
+ delete settings.mode;
+
+ // Object states must be an nsISupports
+ var state = {
+ settings,
+ QueryInterface: ChromeUtils.generateQI([]),
+ };
+ state.wrappedJSObject = state;
+
+ setObjectState("xpinstall-redirect-settings", state);
+ response.setStatusLine(request.httpVersion, 200, "Ok");
+ response.setHeader("Content-Type", "text/plain");
+ response.write("Setup complete");
+ } else if (settings.mode == "redirect") {
+ getObjectState("xpinstall-redirect-settings", function (aObject) {
+ settings = aObject.wrappedJSObject.settings;
+ });
+
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ for (var name in settings) {
+ response.setHeader(name, settings[name]);
+ }
+ response.write("Done");
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/restartless.xpi b/toolkit/mozapps/extensions/test/xpinstall/restartless.xpi
new file mode 100644
index 0000000000..9fee8f60b1
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/restartless.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpinstall/slowinstall.sjs b/toolkit/mozapps/extensions/test/xpinstall/slowinstall.sjs
new file mode 100644
index 0000000000..c925822a4d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/slowinstall.sjs
@@ -0,0 +1,103 @@
+// In an SJS file we need to get NetUtil ourselves, despite
+// what eslint might think applies for browser tests.
+// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix
+let { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+
+Cu.importGlobalProperties(["IOUtils", "PathUtils"]);
+
+const RELATIVE_PATH = "browser/toolkit/mozapps/extensions/test/xpinstall";
+const NOTIFICATION_TOPIC = "slowinstall-complete";
+
+/**
+ * Helper function to create a JS object representing the url parameters from
+ * the request's queryString.
+ *
+ * @param aQueryString
+ * The request's query string.
+ * @return A JS object representing the url parameters from the request's
+ * queryString.
+ */
+function parseQueryString(aQueryString) {
+ var paramArray = aQueryString.split("&");
+ var regex = /^([^=]+)=(.*)$/;
+ var params = {};
+ for (var i = 0, sz = paramArray.length; i < sz; i++) {
+ var match = regex.exec(paramArray[i]);
+ if (!match) {
+ throw new Error("Bad parameter in queryString! '" + paramArray[i] + "'");
+ }
+ params[decodeURIComponent(match[1])] = decodeURIComponent(match[2]);
+ }
+
+ return params;
+}
+
+function handleRequest(aRequest, aResponse) {
+ let id = +getState("ID");
+ setState("ID", "" + (id + 1));
+
+ function LOG(str) {
+ dump("slowinstall.sjs[" + id + "]: " + str + "\n");
+ }
+
+ aResponse.setStatusLine(aRequest.httpVersion, 200, "OK");
+
+ var params = {};
+ if (aRequest.queryString) {
+ params = parseQueryString(aRequest.queryString);
+ }
+
+ if (params.file) {
+ let xpiFile = "";
+
+ function complete_download() {
+ LOG("Completing download");
+
+ try {
+ // Doesn't seem to be a sane way to read using IOUtils and write to an
+ // nsIOutputStream so here we are.
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(xpiFile);
+ let stream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+ stream.init(file, -1, -1, stream.DEFER_OPEN + stream.CLOSE_ON_EOF);
+
+ NetUtil.asyncCopy(stream, aResponse.bodyOutputStream, () => {
+ LOG("Download complete");
+ aResponse.finish();
+ });
+ } catch (e) {
+ LOG("Exception " + e);
+ }
+ }
+
+ let waitForComplete = new Promise(resolve => {
+ function complete() {
+ Services.obs.removeObserver(complete, NOTIFICATION_TOPIC);
+ resolve();
+ }
+
+ Services.obs.addObserver(complete, NOTIFICATION_TOPIC);
+ });
+
+ aResponse.processAsync();
+
+ const dir = Services.dirsvc.get("CurWorkD", Ci.nsIFile).path;
+ xpiFile = PathUtils.join(dir, ...RELATIVE_PATH.split("/"), params.file);
+ LOG("Starting slow download of " + xpiFile);
+
+ IOUtils.stat(xpiFile).then(info => {
+ aResponse.setHeader("Content-Type", "binary/octet-stream");
+ aResponse.setHeader("Content-Length", info.size.toString());
+
+ LOG("Download paused");
+ waitForComplete.then(complete_download);
+ });
+ } else if (params.continue) {
+ dump(
+ "slowinstall.sjs: Received signal to complete all current downloads.\n"
+ );
+ Services.obs.notifyObservers(null, NOTIFICATION_TOPIC);
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/startsoftwareupdate.html b/toolkit/mozapps/extensions/test/xpinstall/startsoftwareupdate.html
new file mode 100644
index 0000000000..83792ebdb2
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/startsoftwareupdate.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page will accept a url as the uri query and pass it to InstallTrigger.startSoftwareUpdate -->
+
+<head>
+<title>InstallTrigger tests</title>
+<script type="text/javascript">
+/* globals InstallTrigger */
+/* exported startInstall */
+function startInstall() {
+ InstallTrigger.startSoftwareUpdate(decodeURIComponent(document.location.search.substring(1)));
+}
+</script>
+</head>
+<body onload="startInstall()">
+<p>InstallTrigger tests</p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/triggerredirect.html b/toolkit/mozapps/extensions/test/xpinstall/triggerredirect.html
new file mode 100644
index 0000000000..1b098d6948
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/triggerredirect.html
@@ -0,0 +1,37 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page will attempt an install and then try to load a new page in the tab -->
+
+<head>
+<title>InstallTrigger tests</title>
+<script type="text/javascript">
+/* globals InstallTrigger */
+/* exported startInstall */
+function installCallback(url, status) {
+ document.location = "#foo";
+
+ dump("Sending InstallComplete\n");
+ var event = new CustomEvent("InstallComplete");
+ window.dispatchEvent(event);
+}
+
+function startInstall() {
+ InstallTrigger.install({
+ "Unsigned XPI": {
+ URL: "amosigned.xpi",
+ IconURL: "icon.png",
+ toString() { return this.URL; },
+ },
+ }, installCallback);
+}
+</script>
+</head>
+<body onload="startInstall()">
+<p>InstallTrigger tests</p>
+<p id="return"></p>
+<p id="status"></p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/unsigned.xpi b/toolkit/mozapps/extensions/test/xpinstall/unsigned.xpi
new file mode 100644
index 0000000000..95f99a748f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/unsigned.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpinstall/unsigned_mv3.xpi b/toolkit/mozapps/extensions/test/xpinstall/unsigned_mv3.xpi
new file mode 100644
index 0000000000..7ef5534f45
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/unsigned_mv3.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpinstall/webmidi_permission.xpi b/toolkit/mozapps/extensions/test/xpinstall/webmidi_permission.xpi
new file mode 100644
index 0000000000..9a2effdd0f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/webmidi_permission.xpi
Binary files differ