diff options
Diffstat (limited to 'toolkit/mozapps/extensions/test/browser')
105 files changed, 21857 insertions, 0 deletions
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 Binary files differnew file mode 100644 index 0000000000..046a0285c7 --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/mozilla.rsa 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 Binary files differnew file mode 100644 index 0000000000..170a361620 --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/mozilla.rsa 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 Binary files differnew file mode 100644 index 0000000000..a026680e91 --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/mozilla.rsa 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 Binary files differnew file mode 100644 index 0000000000..68621e19be --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/mozilla.rsa 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 Binary files differnew file mode 100644 index 0000000000..8b6320adda --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/mozilla.rsa 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.toml b/toolkit/mozapps/extensions/test/browser/browser.toml new file mode 100644 index 0000000000..1daf6211f8 --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser.toml @@ -0,0 +1,193 @@ +[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", + "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 = [ + "os == 'linux' && asan", # Bug 1713895 - new Fission platform triage +] +prefs = [ + "dom.webmidi.enabled=true", + "midi.testing=true", +] + +["browser_AMBrowserExtensionsImport.js"] + +["browser_about_debugging_link.js"] + +["browser_addon_list_reordering.js"] + +["browser_amo_abuse_report.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"] +skip-if = ["a11y_checks"] # Bug 1858037 to investigate intermittent a11y_checks results for .popup-notification-primary-button.primary.footer-button + +["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"] +skip-if = ["os == 'mac' && verify && debug"] # Bug 1850159 + +["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_local_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_AMBrowserExtensionsImport.js b/toolkit/mozapps/extensions/test/browser/browser_AMBrowserExtensionsImport.js new file mode 100644 index 0000000000..654e3cd91e --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_AMBrowserExtensionsImport.js @@ -0,0 +1,220 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +const { AMBrowserExtensionsImport } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +// This test verifies the global notification in `about:addons` when there are +// pending imported add-ons. The appmenu UI is covered by tests in: +// `browser/components/extensions/test/browser/browser_AMBrowserExtensionsImport.js`. + +AddonTestUtils.initMochitest(this); + +const TEST_SERVER = AddonTestUtils.createHttpServer(); + +const ADDONS = { + ext1: { + manifest: { + name: "Ext 1", + version: "1.0", + browser_specific_settings: { gecko: { id: "ff@ext-1" } }, + permissions: ["history"], + }, + }, + ext2: { + manifest: { + name: "Ext 2", + version: "1.0", + browser_specific_settings: { gecko: { id: "ff@ext-2" } }, + permissions: ["history"], + }, + }, +}; +// Populated in `setup()`. +const XPIS = {}; +// Populated in `setup()`. +const ADDON_SEARCH_RESULTS = {}; + +const mockAddonRepository = ({ addons = [] }) => { + return { + async getMappedAddons(browserID, extensionIDs) { + return Promise.resolve({ + addons, + matchedIDs: [], + unmatchedIDs: [], + }); + }, + }; +}; + +const assertWarningShown = async ( + win, + stack, + expectedWarningType = "imported-addons", + expectAction = true +) => { + Assert.equal(stack.childElementCount, 1, "expected a global warning"); + const messageBar = stack.firstElementChild; + Assert.equal( + messageBar.getAttribute("warning-type"), + expectedWarningType, + `expected a warning for ${expectedWarningType}` + ); + Assert.equal( + messageBar.getAttribute("data-l10n-id"), + `extensions-warning-${expectedWarningType}2`, + "expected correct l10n ID" + ); + await win.document.l10n.translateElements([messageBar]); + + if (expectAction) { + const button = messageBar.querySelector("button"); + Assert.equal( + button.getAttribute("action"), + expectedWarningType, + `expected a button for ${expectedWarningType}` + ); + Assert.equal( + button.getAttribute("data-l10n-id"), + `extensions-warning-${expectedWarningType}-button`, + "expected correct l10n ID on the button" + ); + await win.document.l10n.translateElements([button]); + } +}; + +add_setup(async function setup() { + for (const [name, data] of Object.entries(ADDONS)) { + XPIS[name] = AddonTestUtils.createTempWebExtensionFile(data); + TEST_SERVER.registerFile(`/addons/${name}.xpi`, XPIS[name]); + + ADDON_SEARCH_RESULTS[name] = { + id: data.manifest.browser_specific_settings.gecko.id, + name: data.name, + version: data.version, + sourceURI: Services.io.newURI( + `http://localhost:${TEST_SERVER.identity.primaryPort}/addons/${name}.xpi` + ), + icons: {}, + }; + } + + registerCleanupFunction(() => { + // Clear the add-on repository override. + AMBrowserExtensionsImport._addonRepository = null; + }); +}); + +add_task(async function test_aboutaddons_global_message() { + const browserID = "some-browser-id"; + const extensionIDs = ["ext-1", "ext-2"]; + AMBrowserExtensionsImport._addonRepository = mockAddonRepository({ + addons: Object.values(ADDON_SEARCH_RESULTS), + }); + + // Global warnings should be displayed in all the `about:addons` views but + // the migration wizard links to the default view. That's why we load this + // view here, too (as opposed to, e.g., `"extensions"`). + const win = await loadInitialView(); + const stack = win.document.querySelector("global-warnings"); + + Assert.equal(stack.childElementCount, 0, "expected no global warning"); + + let promiseTopic = TestUtils.topicObserved( + "webextension-imported-addons-pending" + ); + // Start a first import... + await AMBrowserExtensionsImport.stageInstalls(browserID, extensionIDs); + await promiseTopic; + // We expect a warning about the imported add-ons to be shown. + await assertWarningShown(win, stack); + + // ...then cancel it. + promiseTopic = TestUtils.topicObserved( + "webextension-imported-addons-cancelled" + ); + await AMBrowserExtensionsImport.cancelInstalls(); + await promiseTopic; + + // At this point, the warning about the imported add-ons should be hidden. + Assert.equal(stack.childElementCount, 0, "expected no global warning"); + + // We start a second import here, then we make sure an imported-addons + // messagebar doesn't prevent the other global warning types to be shown. + promiseTopic = TestUtils.topicObserved( + "webextension-imported-addons-pending" + ); + const result = await AMBrowserExtensionsImport.stageInstalls( + browserID, + extensionIDs + ); + await promiseTopic; + await assertWarningShown(win, stack); + + info("Verify safe-mode is not hidden by an imported-addons messagebar"); + stack.inSafeMode = true; + stack.refresh(); + await assertWarningShown( + win, + stack, + "safe-mode", + false /* no button expected */ + ); + stack.inSafeMode = false; + + info( + "Verify check-compatibility is not hidden by an imported-addons messagebar" + ); + AddonManager.checkCompatibility = false; + stack.refresh(); + await assertWarningShown(win, stack, "check-compatibility"); + AddonManager.checkCompatibility = true; + + info("Verify update-security is not hidden by an imported-addons messagebar"); + await SpecialPowers.pushPrefEnv({ + set: [["extensions.checkUpdateSecurity", false]], + }); + stack.refresh(); + await assertWarningShown(win, stack, "update-security"); + await SpecialPowers.popPrefEnv(); + + // After making sure the imported-addons messagebar is visible again, we + // finally complete the pending import with the UI from the global warning. + info( + "Verify pending imported addons can be completed from the messagebar action" + ); + stack.refresh(); + await assertWarningShown(win, stack, "imported-addons"); + + // Complete the installation of the add-ons by clicking on the button in the + // global warning. + promiseTopic = TestUtils.topicObserved( + "webextension-imported-addons-complete" + ); + const endedPromises = result.importedAddonIDs.map(id => + AddonTestUtils.promiseInstallEvent( + "onInstallEnded", + install => install.addon.id === id + ) + ); + stack.firstElementChild.querySelector("button").click(); + await Promise.all([...endedPromises, promiseTopic]); + + // At this point, the warning about the imported add-ons should be hidden + // because the add-ons are installed. + Assert.equal(stack.childElementCount, 0, "expected no global warning"); + + for (const id of result.importedAddonIDs) { + const addon = await AddonManager.getAddonByID(id); + Assert.ok(addon.isActive, `expected add-on "${id}" to be enabled`); + await addon.uninstall(); + } + + await closeView(win); +}); 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..a80a57bb7e --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_addon_list_reordering.js @@ -0,0 +1,204 @@ +/* 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); + // We intentionally turn off this a11y check, because the following click + // is purposefully targeting a non-interactive element to clear the focused + // state with a mouse which can be done by assistive technology and keyboard + // by pressing `Esc` key, this rule check shall be ignored by a11y_checks suite. + AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false }); + // Click outside the list to clear any focus. + EventUtils.synthesizeMouseAtCenter( + win.document.querySelector(".header-name"), + {}, + win + ); + AccessibilityUtils.resetEnv(); + 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_amo_abuse_report.js b/toolkit/mozapps/extensions/test/browser/browser_amo_abuse_report.js new file mode 100644 index 0000000000..b470cf2d82 --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_amo_abuse_report.js @@ -0,0 +1,85 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 () => { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "extensions.abuseReport.amoFormURL", + "https://example.org/%LOCALE%/%APP%/feedback/addon/%addonID%/", + ], + ], + }); + + // Explicitly flip the amoFormEnabled pref on builds where the pref is + // expected to not be set to true by default. + if (AppConstants.MOZ_APP_NAME != "firefox") { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.abuseReport.amoFormEnabled", true]], + }); + } + + const { AbuseReporter } = ChromeUtils.importESModule( + "resource://gre/modules/AbuseReporter.sys.mjs" + ); + + Assert.equal( + AbuseReporter.amoFormEnabled, + true, + "Expect AMO abuse report form to be enabled" + ); + + // Setting up MockProvider to mock various addon types + // as installed. + await AbuseReportTestUtils.setup(); +}); + +add_task(async function test_opens_amo_form_in_a_tab() { + await openAboutAddons(); + + const ADDON_ID = "test-ext@mochitest"; + const expectedUrl = Services.urlFormatter + .formatURLPref("extensions.abuseReport.amoFormURL") + .replace("%addonID%", ADDON_ID); + + const promiseWaitForAMOFormTab = BrowserTestUtils.waitForNewTab( + gBrowser, + expectedUrl + ); + info("Call about:addons openAbuseReport helper function"); + gManagerWindow.openAbuseReport({ addonId: ADDON_ID }); + info(`Wait for the AMO form url ${expectedUrl} to be opened in a new tab`); + const tab = await promiseWaitForAMOFormTab; + Assert.equal( + tab.linkedBrowser.currentURI.spec, + expectedUrl, + "The newly opened tab has the expected url" + ); + Assert.equal(gBrowser.selectedTab, tab, "The newly opened tab is selected"); + + BrowserTestUtils.removeTab(tab); + await closeAboutAddons(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_report_button_shown_on_dictionary_addons() { + await openAboutAddons("dictionary"); + await AbuseReportTestUtils.assertReportActionShown( + 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(); +}); 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..772e327afc --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_colorwaybuiltins_migration.js @@ -0,0 +1,265 @@ +/* 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); + // Ensure that we send a click on the control that is accessible (while a + // mouse user could also activate a card by clicking on the entire container): + const themeCardLink = themeCard.querySelector(".addon-name-link"); + themeCardLink.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", + install => install.addon.id === EXPIRED_COLORWAY_THEME_ID1 + ), + AddonTestUtils.promiseInstallEvent( + "onInstallEnded", + install => install.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..368160698f --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_globalwarnings.js @@ -0,0 +1,176 @@ +/* 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.querySelector(".addon-name-link"), + {}, + 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, + "moz-message-bar", + "The message bar is a moz-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..51ffbc6cdd --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_gmpProvider.js @@ -0,0 +1,406 @@ +/* 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_L1_ID, WIDEVINE_L3_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_L1_ID || pluginId == WIDEVINE_L3_ID, + usedFallback: true, + }); + gMockAddons.push(mockAddon); +} + +var gInstalledAddonId = ""; +var gInstallDeferred = null; +var gPrefs = Services.prefs; +var getKey = GMPPrefs.getPrefKey; + +const MockGMPInstallManagerPrototype = { + checkForAddons: () => + Promise.resolve({ + 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.querySelector(".addon-name-link"), + {}, + 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( + "moz-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( + "moz-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( + "moz-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( + "moz-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 = Promise.withResolvers(); + + 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_L1_ID) { + if ( + AppConstants.MOZ_WMF_CDM && + AppConstants.platform == "win" && + UpdateUtils.ABI.match(/x64/) + ) { + Assert.ok(item, "Widevine L1 supported, found add-on element."); + } else { + Assert.ok( + !item, + "Widevine L1 not supported, couldn't find add-on element." + ); + } + } else if (addon.id == WIDEVINE_L3_ID) { + if ( + AppConstants.platform == "win" || + AppConstants.platform == "macosx" || + AppConstants.platform == "linux" + ) { + Assert.ok(item, "Widevine L3 supported, found add-on element."); + } else { + Assert.ok( + !item, + "Widevine L3 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..2b177bc7cd --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_history_navigation.js @@ -0,0 +1,623 @@ +/* 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); + // Ensure that we send a click on the control that is accessible (while a + // mouse user could also activate a card by clicking on the entire container): + const addonLink = addon.querySelector(".addon-name-link"); + addonLink.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.startLoadingURIString( + 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.startLoadingURIString(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..3ad8510aea --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_html_abuse_report.js @@ -0,0 +1,1093 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 () { + // Make sure the integrated abuse report panel is the one enabled + // while this test file runs (instead of the AMO hosted form). + // NOTE: behaviors expected when amoFormEnabled is true are tested + // in the separate browser_amo_abuse_report.js test file. + await SpecialPowers.pushPrefEnv({ + set: [["extensions.abuseReport.amoFormEnabled", false]], + }); + 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" + ); + const MozButtonGroup = + abuseReportEl.ownerGlobal.customElements.get("moz-button-group"); + + ok(MozButtonGroup, "Expect MozButtonGroup custom element to be defined"); + + const assertButtonInMozButtonGroup = ( + btnEl, + { expectPrimary = false } = {} + ) => { + // Let's include the l10n id into the assertion messages, + // to make it more likely to be immediately clear which + // button hit a failure if any of the following assertion + // fails. + let l10nId = btnEl.getAttribute("data-l10n-id"); + is( + btnEl.classList.contains("primary"), + expectPrimary, + `Expect button ${l10nId} to have${ + expectPrimary ? "" : " NOT" + } the primary class set` + ); + + ok( + btnEl.parentElement instanceof MozButtonGroup, + `Expect button ${l10nId} to be slotted inside the expected custom element` + ); + + is( + btnEl.getAttribute("slot"), + expectPrimary ? "primary" : null, + `Expect button ${l10nId} slot to ${ + expectPrimary ? "" : "NOT " + } be set to primary` + ); + }; + + // Verify button group from the initial panel. + assertButtonInMozButtonGroup(abuseReportEl._btnNext, { expectPrimary: true }); + assertButtonInMozButtonGroup(abuseReportEl._btnCancel, { + expectPrimary: false, + }); + await AbuseReportTestUtils.clickPanelButton(abuseReportEl._btnNext); + await onceUpdated; + // Verify button group from the submit panel mode. + assertButtonInMozButtonGroup(abuseReportEl._btnSubmit, { + expectPrimary: true, + }); + assertButtonInMozButtonGroup(abuseReportEl._btnGoBack, { + expectPrimary: false, + }); + 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 { + Assert.strictEqual( + 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 = []; + learnMoreLinks.push( + ..._suggestions.querySelectorAll( + 'a[is="moz-support-link"], .abuse-policy-learnmore' + ) + ); + + if (learnMoreLinks.length) { + is( + _suggestions.querySelectorAll( + 'a[is="moz-support-link"]:not([support-page])' + ).length, + 0, + "Every SUMO link should point to a specific page" + ); + 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..1efb28add3 --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_html_abuse_report_dialog.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] */ + +loadTestSubscript("head_abuse_report.js"); + +add_setup(async function () { + // Make sure the integrated abuse report panel is the one enabled + // while this test file runs (instead of the AMO hosted form). + // NOTE: behaviors expected when amoFormEnabled is true are tested + // in the separate browser_amo_abuse_report.js test file. + await SpecialPowers.pushPrefEnv({ + set: [["extensions.abuseReport.amoFormEnabled", false]], + }); + 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..939fe421c3 --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_html_detail_permissions.js @@ -0,0 +1,827 @@ +/* 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; + } + + Assert.greater( + 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..76e7f2b255 --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js @@ -0,0 +1,1675 @@ +/* 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 { QuarantinedDomains } = 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])') + ); +} + +async 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`; + } + const doc = row.ownerDocument; + await doc.l10n.translateElements([row]); + const rowHeaderEl = row.firstElementChild; + is(doc.l10n.getAttributes(rowHeaderEl).id, id, `The ${name} label is set`); + if (row.getAttribute("role") === "group") { + // For the rows on which the role="group" attribute is set, + // let's make sure that the element itself includes an aria-label + // which provides to the screen reader a label similar to the one + // rendered as the visual section header. + // + // NOTE: more screen reader accessibility assertions are being + // covered by the checkRowScreenReaderAccessibility test helper. + is( + row.getAttribute("aria-label"), + rowHeaderEl.textContent, + "expect an aria-label from role=group row to match row header el text" + ); + // For these rows we expect rowHeaderEl to be a span. + is(rowHeaderEl.tagName, "SPAN", "row header element should be a span"); + } else { + // For the other rows which we have not set a role="group" attribute + // on, we expect the rowHeaderEl to still be a label. + is( + rowHeaderEl.tagName, + "LABEL", + "row header element expected to be a label" + ); + } +} + +async function checkRowScreenReaderAccessibility( + row, + { groupName, expectedFluentId } +) { + const doc = row.ownerDocument; + // Make sure the row isn't missing any strings expected to be associated + // to the fluent ids (which would make translateElements to reject + // and the test to fail explicitly). + await doc.l10n.translateElements([row]); + is( + row.getAttribute("role"), + "group", + `Expect ${groupName} row to have role group` + ); + is( + doc.l10n.getAttributes(row).id, + expectedFluentId, + `Got expected fluent id associated to the ${groupName} row` + ); + // Make sure that screen readers will be able to announce to the + // user what is the group of controls being entered. + ok( + !!row.getAttribute("aria-label")?.length, + `Expect non empty aria-label on the ${groupName} row` + ); +} + +async function checkQuarantinedDomainsUserAllowedRows(card, rows) { + // Account for the rows related to per-addon quarantineIgnoredByUser UI, + // underling functionality of the UI is checked in its own test task. + const doc = card.ownerDocument; + if (card.addon.canChangeQuarantineIgnored) { + let row = rows.shift(); + await checkLabel(row, "quarantined-domains"); + await checkRowScreenReaderAccessibility(row, { + groupName: "quarantined domains exempt controls", + expectedFluentId: "addon-detail-group-label-quarantined-domains", + }); + + // quarantineIgnoredByUser UI 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.firstElementChild).id, + "addon-detail-quarantined-domains-help", + "The help row is for quarantined domains" + ); + } +} + +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) { + Assert.equal(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"); + Assert.greaterOrEqual( + 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"), + }, + { + id: "addon4@mochi.test", + name: "Test add-on 4", + creator: { name: "Some name" }, + description: "Short description", + userPermissions: { + origins: [], + permissions: ["alarms", "contextMenus"], + }, + type: "extension", + reviewCount: 0, + reviewURL: "http://addons.mozilla.org/reviews", + averageRating: 0, + }, + { + // 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); + // We intentionally turn off this a11y check, because the following click + // is purposefully targeting a non-interactive container to open the card + // with a mouse, while its inner link element is accessible and is being + // tested in other test cases, thus this rule check shall be ignored by + // a11y_checks suite. + AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false }); + EventUtils.synthesizeMouseAtCenter(card, { clickCount: 1 }, win); + AccessibilityUtils.resetEnv(); + 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); + // We intentionally turn off this a11y check, because the following click + // is purposefully targeting a non-interactive container to open the card + // with a mouse, while its inner link element is accessible and is being + // tested in other test cases, thus this rule check shall be ignored by + // a11y_checks suite. + AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false }); + EventUtils.synthesizeMouseAtCenter(card, { clickCount: 1 }, win); + AccessibilityUtils.resetEnv(); + 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(); + + await checkLabel(row, "updates"); + await checkRowScreenReaderAccessibility(row, { + groupName: "updates controls", + expectedFluentId: "addon-detail-group-label-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(); + await checkLabel(row, "private-browsing"); + await checkRowScreenReaderAccessibility(row, { + groupName: "private browsing controls", + expectedFluentId: "addon-detail-group-label-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" + ); + + await checkQuarantinedDomainsUserAllowedRows(card, rows); + + // Author. + row = rows.shift(); + await 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(); + await checkLabel(row, "version"); + let text = row.lastChild; + is(text.textContent, "3.1", "The version is set"); + + // Last updated. + row = rows.shift(); + await checkLabel(row, "last-updated"); + text = row.lastChild; + is(text.textContent, "March 7, 2019", "The last updated date is set"); + + // Homepage. + row = rows.shift(); + await checkLabel(row, "homepage"); + link = row.querySelector("a"); + checkLink(link, "http://example.com/addon1"); + + // Reviews. + row = rows.shift(); + await checkLabel(row, "rating"); + let rating = row.lastElementChild; + ok(rating.classList.contains("addon-detail-rating"), "Found the rating el"); + let mozFiveStar = rating.querySelector("moz-five-star"); + is(mozFiveStar.rating, 4.279, "Exact rating used for calculations"); + let stars = Array.from(mozFiveStar.starEls); + 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) { + mozFiveStar.rating = rating; + await mozFiveStar.updateComplete; + if (mozFiveStar.ownerDocument.hasPendingL10nMutations) { + await BrowserTestUtils.waitForEvent( + mozFiveStar.ownerDocument, + "L10nMutationsFinished" + ); + } + let starsString = Array.from(mozFiveStar.starEls) + .map(star => star.getAttribute("fill")) + .join(","); + is(starsString, expectation, `Rendering of rating ${rating}`); + + is( + mozFiveStar.starsWrapperEl.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(); + await checkLabel(row, "updates"); + + // Private browsing settings. + row = rows.shift(); + await 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" + ); + + await checkQuarantinedDomainsUserAllowedRows(card, rows); + + // Author. + row = rows.shift(); + await 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(); + await checkLabel(author, "author"); + let text = author.lastChild; + is(text.textContent, "Mozilla", "The author is set"); + + // Version. + let version = rows.shift(); + await checkLabel(version, "version"); + is(version.lastChild.textContent, "1.3", "It's always version 1.3"); + + // Last updated. + let lastUpdated = rows.shift(); + await 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(); + await checkLabel(row, "updates"); + + // Author. + let author = rows.shift(); + await 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.isVisible(sitepermissionsRow), + true, + "AddonSitePermissionsList should be visible for this addon type" + ); + + let [versionRow, ...restRows] = getDetailRows(card); + await 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(); +}); + +add_task(async function testQuarantinedDomainsUserAllowedUI() { + let regularExtId = "regular@mochi.test"; + let privilegedExtId = "privileged@mochi.test"; + let recommendedExtId = "recommended@mochi.test"; + let themeId = "theme@mochi.test"; + let provider = new MockProvider(); + provider.createAddons([ + { + id: privilegedExtId, + isPrivileged: true, + name: "A privileged extension", + type: "extension", + quarantineIgnoredByApp: true, + quarantineIgnoredByUser: false, + canChangeQuarantineIgnored: false, + }, + { + id: recommendedExtId, + isRecommended: true, + recommendationStates: ["recommended"], + name: "A Recommended extension", + type: "extension", + quarantineIgnoredByApp: true, + quarantineIgnoredByUser: false, + canChangeQuarantineIgnored: false, + }, + { + id: themeId, + name: "A fake regular theme", + type: "theme", + canChangeQuarantineIgnored: false, + }, + ]); + + let regularExtension = ExtensionTestUtils.loadExtension({ + manifest: { + name: "Some regular extension", + browser_specific_settings: { gecko: { id: regularExtId } }, + }, + useAddonManager: "permanent", + }); + + async function testQuarantinedUserAllowedUIRows(id, { expectVisible }) { + const perAddonPref = QuarantinedDomains.getUserAllowedAddonIdPrefName(id); + Services.prefs.clearUserPref(perAddonPref); + + let card = getAddonCard(win, id); + + const cardDetails = card.querySelector("addon-details"); + ok(cardDetails, "Card details found"); + const quarantinedUserAllowedControlsRow = cardDetails.querySelector( + ".addon-detail-row-quarantined-domains" + ); + + ok( + quarantinedUserAllowedControlsRow, + "Found quarantine domains controls row element" + ); + + is( + BrowserTestUtils.isVisible(quarantinedUserAllowedControlsRow), + expectVisible, + `Expect quarantineIgnoreByUser UI to ${ + expectVisible ? "be" : "NOT be" + } visible` + ); + const helpRow = quarantinedUserAllowedControlsRow.nextElementSibling; + is( + helpRow.classList.contains("addon-detail-help-row"), + true, + "Expect next sibling to be an addon-detail-help-row" + ); + is( + BrowserTestUtils.isVisible(helpRow), + expectVisible, + `Expect quarantineIgnoredByUser UI help to ${ + expectVisible ? "be" : "NOT be" + } visible` + ); + + if (!expectVisible) { + // The assertion that follows are going to be executed when the + // test helper function is called for an addon card detail view + // for which the quarantined domains rows are expected to be + // visible. + return; + } + + is( + doc.l10n.getAttributes(helpRow.firstElementChild).id, + "addon-detail-quarantined-domains-help", + "Expect addon-detail-help-row to be localized" + ); + const helpSupportLink = helpRow.querySelector("[is=moz-support-link]"); + ok(helpSupportLink, "Expect a moz-support-link"); + is( + helpSupportLink?.getAttribute("support-page"), + "quarantined-domains", + "Expect support link to point to SUMO quarantined-domains page" + ); + // Make sure none of the elements in the help row are missing + // the expected strings associated to the fluent ids being set + // (if any is missing, l10n.translateElements will reject and + // trigger an explicit test failure); + await doc.l10n.translateElements([helpRow]); + + const radioInputs = Array.from( + quarantinedUserAllowedControlsRow.querySelectorAll( + "input[name=quarantined-domains-user-allowed]" + ) + ); + + Assert.deepEqual( + radioInputs.map(el => el.value), + ["1", "0"], + "Got the expected radio inputs values" + ); + + Assert.deepEqual( + radioInputs.map(el => doc.l10n.getAttributes(el.nextElementSibling).id), + ["allow", "disallow"].map( + txt => `addon-detail-quarantined-domains-${txt}` + ), + "Got the expected fluent ids on the radio input text" + ); + + const checkRadioInputsState = ({ expectUserAllowed }) => { + is( + card.addon.quarantineIgnoredByUser, + expectUserAllowed, + `Expect the test extension to ${ + expectUserAllowed ? "be" : "NOT be" + } quarantineIgnoredByUser` + ); + is( + radioInputs[0].checked, + expectUserAllowed, + `Expect 'allow' radio button to ${ + expectUserAllowed ? "be" : "NOT be" + } checked` + ); + is( + radioInputs[1].checked, + !expectUserAllowed, + `Expect 'disallow' radio button ${ + expectUserAllowed ? "NOT be" : "be" + } checked` + ); + }; + + info("Verify initially NOT allowed to access quarantine domains"); + checkRadioInputsState({ expectUserAllowed: false }); + + info("Click 'allow' radio input"); + radioInputs[0].click(); + checkRadioInputsState({ expectUserAllowed: true }); + + info("Click 'disallow' radio input"); + radioInputs[1].click(); + checkRadioInputsState({ expectUserAllowed: false }); + + info("Verify quarantineIgnoredByUser changes reflected in about:addons UI"); + + info("Allow test extension on quarantined domains"); + let promisePropertyChanged = + AddonTestUtils.promiseAddonEvent("onPropertyChanged"); + card.addon.quarantineIgnoredByUser = true; + await promisePropertyChanged; + checkRadioInputsState({ expectUserAllowed: true }); + + info("Disallow test extension on quarantined domains"); + promisePropertyChanged = + AddonTestUtils.promiseAddonEvent("onPropertyChanged"); + card.addon.quarantineIgnoredByUser = false; + await promisePropertyChanged; + checkRadioInputsState({ expectUserAllowed: false }); + } + + await SpecialPowers.pushPrefEnv({ + set: [ + // Make sure the quarantined domains feature is initially enabled + // otherwise the "quarantineIgnoredByUser UI" rows are + // going to be hidden. + ["extensions.quarantinedDomains.enabled", true], + // Make sure this test is always running with the + // "per-addon quarantineIgnoredByUser UI" feature enabled. + ["extensions.quarantinedDomains.uiDisabled", false], + ], + }); + + // Clear any per-addon pref once this test file is exiting. + registerCleanupFunction(() => { + const prefBranch = Services.prefs.getBranch( + QuarantinedDomains.PREF_ADDONS_BRANCH_NAME + ); + for (const leafName of prefBranch.getChildList("")) { + const prefName = QuarantinedDomains.PREF_ADDONS_BRANCH_NAME + leafName; + info(`Clearing user pref ${prefName}`); + Services.prefs.clearUserPref(prefName); + } + }); + + await regularExtension.startup(); + + let win = await loadInitialView("extension"); + let doc = win.document; + + info("Test quarantineIgnoredByUser UI on a regular extension"); + let loaded = waitForViewLoad(win); + getAddonCard(win, regularExtId).querySelector('[action="expand"]').click(); + await loaded; + + await testQuarantinedUserAllowedUIRows(regularExtId, { expectVisible: true }); + + info("Go back to extensions list view"); + loaded = waitForViewLoad(win); + win.history.back(); + await loaded; + + info("Test quarantineIgnoredByUser UI on a privileged extension"); + loaded = waitForViewLoad(win); + getAddonCard(win, privilegedExtId).querySelector('[action="expand"]').click(); + await loaded; + + await testQuarantinedUserAllowedUIRows(privilegedExtId, { + expectVisible: false, + }); + + info("Go back to extensions list view"); + loaded = waitForViewLoad(win); + win.history.back(); + await loaded; + + info("Test quarantineIgnoredByUser UI on a recommended extension"); + loaded = waitForViewLoad(win); + getAddonCard(win, recommendedExtId) + .querySelector('[action="expand"]') + .click(); + await loaded; + + await testQuarantinedUserAllowedUIRows(recommendedExtId, { + expectVisible: false, + }); + + info("Switch to theme list view"); + loaded = waitForViewLoad(win); + doc.querySelector("#categories > [name=theme]").click(); + await loaded; + + info("Test quarantineIgnoredByUser UI on a non extension addon type (theme)"); + loaded = waitForViewLoad(win); + getAddonCard(win, themeId).querySelector('[action="expand"]').click(); + await loaded; + + await testQuarantinedUserAllowedUIRows(themeId, { expectVisible: false }); + + info("Verify regular extension card on quarantined domains feature disabled"); + await SpecialPowers.pushPrefEnv({ + set: [["extensions.quarantinedDomains.enabled", false]], + }); + + info("Switch to extension list view"); + loaded = waitForViewLoad(win); + doc.querySelector("#categories > [name=extension]").click(); + await loaded; + + loaded = waitForViewLoad(win); + getAddonCard(win, regularExtId).querySelector('[action="expand"]').click(); + await loaded; + + await testQuarantinedUserAllowedUIRows(regularExtId, { + expectVisible: false, + }); + + await SpecialPowers.popPrefEnv(); + + info("Verify regular extenson card uiDisabled pref set to true"); + await SpecialPowers.pushPrefEnv({ + set: [ + // Make sure the quarantineIgnoredByUser UI is also hidden + // when the quarantine domains feature is enabled but the + // "per-addon quarantineIgnoredByUser UI" feature is disabled. + ["extensions.quarantinedDomains.uiDisabled", true], + ], + }); + + info("Switch to extension list view"); + loaded = waitForViewLoad(win); + doc.querySelector("#categories > [name=extension]").click(); + await loaded; + + loaded = waitForViewLoad(win); + getAddonCard(win, regularExtId).querySelector('[action="expand"]').click(); + await loaded; + + await testQuarantinedUserAllowedUIRows(regularExtId, { + expectVisible: false, + }); + + await closeView(win); + await regularExtension.unload(); + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function testRatingsElementVisibleIfReviewURLExists() { + let win = await loadInitialView("extension"); + let id = "addon4@mochi.test"; + let card = getAddonCard(win, id); + + let loaded = waitForViewLoad(win); + card.querySelector('[action="expand"]').click(); + await loaded; + + card = getAddonCard(win, id); + + let rows = getDetailRows(card); + + let expectedRowCount = 5; + if (card.addon.canChangeQuarantineIgnored) { + expectedRowCount += 2; + } + is(rows.length, expectedRowCount, "Expected row count"); + + // Reviews. + // addon4@mochi.test is similar to addon1@mochi.test whose rows have already + // been checked in testFullDetails. Here we only check the last row + // which is unique to this test case due to the presence of "reviewURL". + let row = rows.pop(); + await checkLabel(row, "rating"); + let rating = row.lastElementChild; + ok(rating.classList.contains("addon-detail-rating"), "Found the rating el"); + ok(!row.hidden, "The rating row is shown"); + let mozFiveStar = rating.querySelector("moz-five-star"); + is(mozFiveStar.rating, 0, "0 rating when there are no reviews"); + let stars = Array.from(mozFiveStar.starEls); + let fullAttrs = stars.map(star => star.getAttribute("fill")).join(","); + is(fullAttrs, "empty,empty,empty,empty,empty", "All stars are empty"); + let 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: 0 }, + }); + + await closeView(win); +}); 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..bc84ffaf89 --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js @@ -0,0 +1,668 @@ +/* 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", + new FileUtils.File( + PathUtils.join( + Services.dirsvc.get("CurWorkD", Ci.nsIFile).path, + ...`${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], + // Disable mixed-content upgrading as this test is expecting an HTTP load + ["security.mixed_content.upgrade_display_content", 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 mozFiveStar = card.querySelector("moz-five-star"); + if (expectations.rating) { + is(mozFiveStar.rating, expectations.rating, "Expected rating value"); + ok(mozFiveStar.offsetWidth, "Rating element is visible"); + } else { + is(mozFiveStar.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], + // Disable mixed-content upgrading as this test is expecting an HTTP load + ["security.mixed_content.upgrade_display_content", false], + ], + }); + + let win = await loadInitialView("extension"); + let messageBar = win.document.querySelector( + "moz-message-bar.discopane-notice" + ); + ok(messageBar, "Recommended notice should exist in extensions view"); + await switchToDiscoView(win); + messageBar = win.document.querySelector("moz-message-bar.discopane-notice"); + ok(messageBar, "Recommended notice should exist in disco view"); + + messageBar.closeButtonEl.click(); + messageBar = win.document.querySelector("moz-message-bar.discopane-notice"); + ok(!messageBar, "Recommended notice should not exist in disco view"); + await switchToNonDiscoView(win); + messageBar = win.document.querySelector("moz-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..ff95c88fe1 --- /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.sys.mjs 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..2631a164df --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_html_list_view.js @@ -0,0 +1,1063 @@ +/* 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"); + // We intentionally turn off this a11y check, because the following click + // is purposefully targeting a non-interactive element to clear the focused + // state with a mouse which can be done by assistive technology and keyboard + // by pressing `Esc`, this rule check shall be ignored by a11y_checks suite. + AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false }); + // Click outside the list to clear any focus. + EventUtils.synthesizeMouseAtCenter( + doc.querySelector(".header-name"), + {}, + win + ); + AccessibilityUtils.resetEnv(); + 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"); + // We intentionally turn off this a11y check, because the following click + // is purposefully targeting a disabled control to confirm the click event + // won't come through. It is not meant to be interactive and is not expected + // to be accessible, therefore the rule check shall be ignored by a11y_checks. + AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false }); + removeButton.click(); + AccessibilityUtils.resetEnv(); + 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"); + + // We intentionally turn off this a11y check, because the following click + // is purposefully targeting a non-interactive element to clear the focused + // state with a mouse which can be done by assistive technology and keyboard + // by pressing `Esc`, this rule check shall be ignored by a11y_checks suite. + AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false }); + // Ensure there's no focus on the list. + EventUtils.synthesizeMouseAtCenter(pageHeader, {}, win); + AccessibilityUtils.resetEnv(); + + 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", + /* capture = */ false, + 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); + // We intentionally turn off this a11y check, because the following click + // is purposefully targeting a non-interactive element to dismiss the opened + // menu with a mouse which can be done by assistive technology and keyboard + // by pressing `Esc`, this rule check shall be ignored by a11y_checks suite. + AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false }); + EventUtils.synthesizeMouseAtCenter(pageHeader, {}, win); + AccessibilityUtils.resetEnv(); + 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..c5bfa1022f --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_html_options_ui.js @@ -0,0 +1,651 @@ +/* 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" + ); + Assert.greater(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"); + Assert.greater(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; + Assert.lessOrEqual( + 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..e4d88bc19a --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_html_scroll_restoration.js @@ -0,0 +1,229 @@ +/* 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 around the content. + win.document.body.style.paddingBlock = "100vh"; + win.document.body.style.paddingInline = "100vw"; + win.document.body.style.width = "300vw"; + + 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); + const addonCard = getAddonCard(win, EXT_ID_EXTENSION); + // Ensure that we send a click on the control that is accessible (while a + // mouse user could also activate a card by clicking on the entire container): + const addonCardLink = addonCard.querySelector(".addon-name-link"); + addonCardLink.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"); + + let tabClosed = BrowserTestUtils.waitForTabClosing(gBrowser.selectedTab); + await closeView(win); + await tabClosed; +}); 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..78ffc5678c --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_html_updates.js @@ -0,0 +1,750 @@ +/* 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.querySelector(".addon-name-link"), + { 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"); + Assert.equal( + menuButton.classList.contains("more-options-button-badged"), + shown, + "The menu button is badged" + ); + let installButton = card.querySelector('panel-item[action="install-update"]'); + Assert.notEqual( + installButton.hidden, + shown, + `The install button is ${shown ? "hidden" : "shown"}` + ); + if (expanded) { + let updateCheckButton = card.querySelector('button[action="update-check"]'); + Assert.equal( + 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..de34cff82b --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_html_warning_messages.js @@ -0,0 +1,290 @@ +/* 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"); + Assert.deepEqual( + document.l10n.getAttributes(messageBar), + { id: `${text.id}2`, args: text.args }, + "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: "error", + }); +}); + +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..1d50da2833 --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_installtrigger_install.js @@ -0,0 +1,362 @@ +/* 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 +) { + // Clear collected events before each test, otherwise the test would fail + // intermittently when Glean is going to submit the events and clear them + // after reaching the max events length limit. + Services.fog.testResetFOG(); + + 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; + + let gleanEvents = AddonTestUtils.getAMGleanEvents("install", { + install_id: `${installId}`, + step: "site_blocked", + }); + ok(!!gleanEvents.length, "Found Glean events for the blocked install."); + Assert.deepEqual( + { source: gleanEvents[0].source }, + expectedTelemetryInfo, + `Got expected Glean telemetry on test case "${msg}"` + ); + + // 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" + ); + + 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_local_install.js b/toolkit/mozapps/extensions/test/browser/browser_local_install.js new file mode 100644 index 0000000000..5200b69e39 --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_local_install.js @@ -0,0 +1,245 @@ +/* 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_INCOMPATIBLE_ID = "incompatible-xpi@tests.mozilla.org"; +// NOTE: we are using an HTTP url on purpose here, the test case fails +// otherwise... We disable `AddonManager.checkUpdateSecurity` to allow +// retrieving updates from HTTP (which is restored in a +// `registerCleanupFunction()` or at the end of the task). +// +// eslint-disable-next-line @microsoft/sdl/no-insecure-url +const BASE_URL = "http://fake-updates.example.com"; + +const server = AddonTestUtils.createHttpServer({ + hosts: ["fake-updates.example.com"], +}); + +const UPDATE_ENTRY_COMPATIBLE = { + // NOTE: this version must be the exact same one associated than the + // initially incompatible XPI, otherwise it won't override the initial + // compatibility range. + // See the check in `AddonUpdateChecker.getCompatibilityUpdate` here: + // https://searchfox.org/mozilla-central/rev/4044c340/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs#489 + version: "4.0", + // An empty compatibility range will make this update to be overriding the + // incompatible range in the xpi and makes the xpi version to be considered + // compatible. + applications: { gecko: {} }, +}; + +const UPDATE_ENTRY_INCOMPATIBLE = { + ...UPDATE_ENTRY_COMPATIBLE, + // This update entry instead is including a compatibility range that would + // makes the xpi version being installed to be considered still incompatible. + applications: { + gecko: { + strict_min_version: "41", + strict_max_version: "41.*", + }, + }, +}; + +AddonTestUtils.registerJSON(server, "/updates-still-incompatible.json", { + addons: { + [XPI_INCOMPATIBLE_ID]: { + updates: [UPDATE_ENTRY_INCOMPATIBLE], + }, + }, +}); + +AddonTestUtils.registerJSON(server, "/updates-now-compatible.json", { + addons: { + [XPI_INCOMPATIBLE_ID]: { + updates: [UPDATE_ENTRY_COMPATIBLE], + }, + }, +}); + +add_task(async function test_local_install_blocklisted() { + let id = "amosigned-xpi@tests.mozilla.org"; + let version = "2.1"; + + await AddonTestUtils.loadBlocklistRawData({ + extensionsMLBF: [ + { + stash: { blocked: [`${id}:${version}`], unblocked: [] }, + stash_time: 0, + }, + ], + }); + let needsCleanupBlocklist = true; + const cleanupBlocklist = async () => { + if (!needsCleanupBlocklist) { + return; + } + await AddonTestUtils.loadBlocklistRawData({ + extensionsMLBF: [ + { + stash: { blocked: [], unblocked: [] }, + stash_time: 0, + }, + ], + }); + needsCleanupBlocklist = false; + }; + registerCleanupFunction(cleanupBlocklist); + + const xpiFilePath = getTestFilePath("../xpinstall/amosigned.xpi"); + const xpiFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + xpiFile.initWithPath(xpiFilePath); + ok(xpiFile.exists(), "Expect the xpi file to exist"); + const xpiFileURI = Services.io.newFileURI(xpiFile); + + let install = await AddonManager.getInstallForURL(xpiFileURI.spec, { + telemetryInfo: { source: "file-url" }, + }); + const promiseInstallFailed = BrowserUtils.promiseObserved( + "addon-install-failed", + subject => { + return subject.wrappedJSObject.installs[0] == install; + } + ); + + AddonManager.installAddonFromWebpage( + "application/x-xpinstall", + gBrowser.selectedBrowser, + Services.scriptSecurityManager.getSystemPrincipal(), + install + ); + + info("Wait for addon-install-failed to be notified"); + await promiseInstallFailed; + Assert.equal( + install.error, + AddonManager.ERROR_BLOCKLISTED, + "LocalInstall cancelled with the expected error" + ); + + await cleanupBlocklist(); +}); + +add_task(async function test_local_install_incompatible() { + const xpiFilePath = getTestFilePath("../xpinstall/incompatible.xpi"); + const xpiFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + xpiFile.initWithPath(xpiFilePath); + ok(xpiFile.exists(), "Expect the xpi file to exist"); + const xpiFileURI = Services.io.newFileURI(xpiFile); + + const installTestExtension = async ({ expectIncompatible }) => { + let install = await AddonManager.getInstallForURL(xpiFileURI.spec, { + telemetryInfo: { source: "file-url" }, + }); + const promiseInstallDone = expectIncompatible + ? BrowserUtils.promiseObserved( + "addon-install-failed", + subject => subject.wrappedJSObject.installs[0] == install + ) + : BrowserUtils.promiseObserved( + "webextension-permission-prompt", + subject => subject.wrappedJSObject.info.addon == install.addon + ); + + AddonManager.installAddonFromWebpage( + "application/x-xpinstall", + gBrowser.selectedBrowser, + Services.scriptSecurityManager.getSystemPrincipal(), + install + ); + + if (expectIncompatible) { + info("Wait for addon-install-failed to be notified"); + await promiseInstallDone; + Assert.equal( + install.error, + AddonManager.ERROR_INCOMPATIBLE, + "LocalInstall cancelled with the expected error" + ); + } else { + info("Wait for webextension-permission-prompt to be notified"); + await promiseInstallDone; + Assert.equal( + install.error, + 0, + "no error expected on the LocalInstall instance" + ); + Assert.equal( + install.state, + AddonManager.STATE_DOWNLOADED, + "Got the expected LocalInstall state" + ); + Assert.ok( + install.addon.isCompatible, + "updated Addon XPI is expected to be compatible" + ); + Assert.equal( + install.addon.version, + "4.0", + "Addon version expected to match the updated xpi file" + ); + // Cancel the installation, before exiting the test. + await install.cancel(); + } + }; + + info("Test incompatible xpi without a compatibility override"); + // Use a new tab to make sure the doorhanger will be gone when + // the test tab is being removed (same when repeating the + // test with expectIncompatible set to false). + await BrowserTestUtils.withNewTab("about:blank", async () => { + await installTestExtension({ expectIncompatible: true }); + }); + + // Add the prefs to ignore signature checks for this test (allowed on all + // channels while running in automation). + SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.update.url", `${BASE_URL}/updates.json`], + ["xpinstall.signatures.required", false], + ["extensions.ui.ignoreUnsigned", true], + ], + }); + AddonManager.checkUpdateSecurity = false; + registerCleanupFunction(() => { + AddonManager.checkUpdateSecurity = true; + }); + + info( + "Test incompatible xpi with a compatibility override that is still incompatible" + ); + // Add the prefs to provide a compatibility range override which is still + // incompatible. + SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.update.url", `${BASE_URL}/updates-still-incompatible.json`], + ], + }); + await BrowserTestUtils.withNewTab("about:blank", async () => { + await installTestExtension({ expectIncompatible: true }); + }); + SpecialPowers.popPrefEnv(); + + info( + "Test incompatible xpi with a compatibility override that makes it compatible" + ); + // Add the prefs to provide a compatibility range override which is + // compatible. + SpecialPowers.pushPrefEnv({ + set: [["extensions.update.url", `${BASE_URL}/updates-now-compatible.json`]], + }); + await BrowserTestUtils.withNewTab("about:blank", async () => { + await installTestExtension({ expectIncompatible: false }); + }); + SpecialPowers.popPrefEnv(); + + SpecialPowers.popPrefEnv(); + AddonManager.checkUpdateSecurity = true; +}); 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..aee47dd049 --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts.js @@ -0,0 +1,331 @@ +"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", {}); + Assert.notEqual(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) { + Assert.notEqual( + 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) { + Assert.notEqual( + 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) { + Assert.notEqual( + 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..259c10d730 --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts_remove.js @@ -0,0 +1,180 @@ +"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)}` + ); + Assert.notEqual(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"); + ok( + removeButton.hasAttribute("aria-label"), + "The remove button has an accessible name" + ); + 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"); + Assert.notEqual(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); + Assert.notEqual( + 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..a602d84999 --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_menu_button_accessibility.js @@ -0,0 +1,93 @@ +/* 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"); + // We intentionally turn off this a11y check, because the following click + // is purposefully targeting a non-interactive element to dismiss the + // opened menu with a mouse which can be done by assistive technology and + // keyboard by pressing `Esc` key, this rule check shall be ignored by + // a11y_checks suite. + AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false }); + EventUtils.synthesizeMouseAtCenter(spacer, {}, win); + AccessibilityUtils.resetEnv(); + }); + + 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.querySelector(".addon-name-link"), + {}, + 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..5007731927 --- /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) { + Assert.notEqual(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..c0eb7d139a --- /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( + `moz-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..912ce8d62f --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_shortcuts_duplicate_check.js @@ -0,0 +1,262 @@ +"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("moz-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 l10nAttrs = doc.l10n.getAttributes(warning); + await TestUtils.waitForCondition(() => warning.message !== ""); + Assert.notStrictEqual( + warning.message, + "", + "Warning message attribute is set" + ); + is( + l10nAttrs.id, + "shortcuts-duplicate-warning-message2", + "Warning message l10nId is correct" + ); + 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 l10nAttrs = doc.l10n.getAttributes(warning); + Assert.notStrictEqual( + warning.message, + "", + "Warning message attribute is set" + ); + is( + l10nAttrs.id, + "shortcuts-duplicate-warning-message2", + "Warning message l10nId is correct" + ); + 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.isHidden(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.isVisible(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.isHidden(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..f391edbf34 --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_sidebar_categories.js @@ -0,0 +1,166 @@ +/* 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).querySelector(".addon-name-link").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).querySelector(".addon-name-link").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(); + // We intentionally turn off this a11y check, because the following click + // is purposefully targeting a non-interactive padding of the container + // to confirm nothing happens, thus this rule check shall be ignored by + // a11y_checks suite. + AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false }); + EventUtils.synthesizeMouse(themeCategory, -5, -5, {}, win); + AccessibilityUtils.resetEnv(); + 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..e245e3a6e4 --- /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.sys.mjs. +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..b9ea0f6a93 --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_abuse_report.js @@ -0,0 +1,375 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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], + // Make sure the integrated abuse report panel is the one enabled + // while this test file runs (instead of the AMO hosted form). + // NOTE: behaviors expected when amoFormEnabled is true are tested + // in the separate browser_amo_abuse_report.js test file. + ["extensions.abuseReport.amoFormEnabled", false], + ], + }); +}); + +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..24d34c3f4d --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_install.js @@ -0,0 +1,652 @@ +/* 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. + Assert.greater( + 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"); + + Assert.greater( + 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"); + + Assert.greater( + 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"); + + Assert.greater( + 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 organization"), + "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", error: "ERROR_INCOMPATIBLE" }, + ]; + + 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) { + let id = "amosigned-xpi@tests.mozilla.org"; + let version = "2.1"; + + await AddonTestUtils.loadBlocklistRawData({ + extensionsMLBF: [ + { + stash: { blocked: [`${id}:${version}`], unblocked: [] }, + stash_time: 0, + }, + ], + }); + + let steps = [ + { action: "install", expectError: true }, + { event: "onDownloadStarted" }, + { event: "onDownloadProgress" }, + { event: "onDownloadEnded" }, + { + event: "onDownloadCancelled", + props: { state: "STATE_CANCELLED", error: "ERROR_BLOCKLISTED" }, + }, + ]; + + await testInstall( + browser, + { url: XPI_URL }, + steps, + "install of a blocked XPI fails" + ); + + let addons = await promiseAddonsByIDs([id]); + is(addons[0], null, "The addon was not installed"); + + // Clear the blocklist. + await AddonTestUtils.loadBlocklistRawData({ + extensionsMLBF: [ + { + stash: { blocked: [], unblocked: [] }, + stash_time: 0, + }, + ], + }); + }) +); + +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 Binary files differnew file mode 100644 index 0000000000..862d1dd10c --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/discovery/small-1x1.png diff --git a/toolkit/mozapps/extensions/test/browser/head.js b/toolkit/mozapps/extensions/test/browser/head.js new file mode 100644 index 0000000000..482429177c --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/head.js @@ -0,0 +1,1714 @@ +/* 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); + } + + Assert.notEqual( + 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); + + Assert.notEqual( + 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.importESModule( + "resource:///modules/ExtensionsUI.sys.mjs" + ); + 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) { + Assert.notEqual( + 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.querySelector(".addon-name-link"), + { 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..f3a683e8d5 --- /dev/null +++ b/toolkit/mozapps/extensions/test/browser/head_abuse_report.js @@ -0,0 +1,615 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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. + let localizedEls = Array.from( + containerEl.querySelectorAll("[data-l10n-id]") + ); + if (containerEl.getAttribute("data-l10n-id")) { + localizedEls.push(containerEl); + } + ok(localizedEls.length, "Got localized elements"); + for (let el of localizedEls) { + const l10nId = el.getAttribute("data-l10n-id"); + const l10nAttrs = el.getAttribute("data-l10n-attrs"); + if (!l10nAttrs) { + await TestUtils.waitForCondition( + () => el.textContent !== "", + `Element with Fluent id '${l10nId}' should not be empty` + ); + } else { + await TestUtils.waitForCondition( + () => el.message !== "", + `Message attribute of the element with Fluent id '${l10nId}' + should not be empty` + ); + } + } + }, + + // Assert that the report action visibility on the addon card + // for the given about:addons windows and extension id. + async assertReportActionVisibility(gManagerWindow, extId, expectShown) { + 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}`); + Assert.equal( + reportButton.hidden, + !expectShown, + `${extId} report action should be ${expectShown ? "shown" : "hidden"}` + ); + }, + + // Assert that the report action is hidden on the addon card + // for the given about:addons windows and extension id. + assertReportActionHidden(gManagerWindow, extId) { + return this.assertReportActionVisibility(gManagerWindow, extId, false); + }, + + // Assert that the report action is shown on the addon card + // for the given about:addons windows and extension id. + assertReportActionShown(gManagerWindow, extId) { + return this.assertReportActionVisibility(gManagerWindow, extId, true); + }, + + // 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..4cc6314d0e --- /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.toml", +] + +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/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> |