summaryrefslogtreecommitdiffstats
path: root/uriloader/exthandler/tests
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /uriloader/exthandler/tests
parentInitial commit. (diff)
downloadthunderbird-upstream.tar.xz
thunderbird-upstream.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'uriloader/exthandler/tests')
-rw-r--r--uriloader/exthandler/tests/HandlerServiceTestUtils.sys.mjs253
-rw-r--r--uriloader/exthandler/tests/WriteArgument.cpp20
-rw-r--r--uriloader/exthandler/tests/gtest/ExternalHelperAppServiceTest.cpp41
-rw-r--r--uriloader/exthandler/tests/gtest/moz.build11
-rw-r--r--uriloader/exthandler/tests/mochitest/FTPprotocolHandler.html16
-rw-r--r--uriloader/exthandler/tests/mochitest/HelperAppLauncherDialog_chromeScript.js104
-rw-r--r--uriloader/exthandler/tests/mochitest/blank.html11
-rw-r--r--uriloader/exthandler/tests/mochitest/browser.ini130
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_auto_close_window.js342
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_auto_close_window_nodialog.js308
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_bad_download_dir.js90
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_download_always_ask_preferred_app.js25
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_download_idn_blocklist.js47
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_download_open_with_internal_handler.js818
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_download_preferred_action.js296
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_download_privatebrowsing.js78
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_download_skips_dialog.js60
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_download_spam_permissions.js158
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_download_urlescape.js75
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_extension_correction.js222
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_filehandling_loop.js93
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_first_prompt_not_blocked_without_user_interaction.js70
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_ftp_protocol_handlers.js105
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_launched_app_save_directory.js114
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_local_files_no_save_without_asking.js61
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_local_files_open_doesnt_duplicate.js121
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_open_internal_choice_persistence.js310
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_pdf_save_as.js121
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog.js464
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_external.js199
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_permission.js1348
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox.js140
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox_csp.js38
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_protocolhandler_loop.js74
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_remember_download_option.js61
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_save_filenames.js823
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_shows_where_to_save_dialog.js303
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_txt_download_save_as.js168
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_web_handler_app_pinned_tab.js81
-rw-r--r--uriloader/exthandler/tests/mochitest/browser_web_protocol_handlers.js124
-rw-r--r--uriloader/exthandler/tests/mochitest/download.bin1
-rw-r--r--uriloader/exthandler/tests/mochitest/download.sjs42
-rw-r--r--uriloader/exthandler/tests/mochitest/download_page.html22
-rw-r--r--uriloader/exthandler/tests/mochitest/file_as.exe1
-rw-r--r--uriloader/exthandler/tests/mochitest/file_as.exe^headers^2
-rw-r--r--uriloader/exthandler/tests/mochitest/file_external_protocol_iframe.html1
-rw-r--r--uriloader/exthandler/tests/mochitest/file_green.webpbin0 -> 42 bytes
-rw-r--r--uriloader/exthandler/tests/mochitest/file_green.webp^headers^3
-rw-r--r--uriloader/exthandler/tests/mochitest/file_image_svgxml.svg3
-rw-r--r--uriloader/exthandler/tests/mochitest/file_image_svgxml.svg^headers^2
-rw-r--r--uriloader/exthandler/tests/mochitest/file_nested_protocol_request.html1
-rw-r--r--uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf0
-rw-r--r--uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf^headers^2
-rw-r--r--uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf0
-rw-r--r--uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf^headers^2
-rw-r--r--uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf0
-rw-r--r--uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf^headers^2
-rw-r--r--uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf0
-rw-r--r--uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf^headers^2
-rw-r--r--uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt0
-rw-r--r--uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt^headers^2
-rw-r--r--uriloader/exthandler/tests/mochitest/file_with@@funny_name.pngbin0 -> 1991 bytes
-rw-r--r--uriloader/exthandler/tests/mochitest/file_with@@funny_name.png^headers^2
-rw-r--r--uriloader/exthandler/tests/mochitest/file_with[funny_name.webmbin0 -> 512 bytes
-rw-r--r--uriloader/exthandler/tests/mochitest/file_with[funny_name.webm^headers^2
-rw-r--r--uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml4
-rw-r--r--uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml^headers^2
-rw-r--r--uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml4
-rw-r--r--uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml^headers^2
-rw-r--r--uriloader/exthandler/tests/mochitest/head.js535
-rw-r--r--uriloader/exthandler/tests/mochitest/invalidCharFileExtension.sjs18
-rw-r--r--uriloader/exthandler/tests/mochitest/mailto.html11
-rw-r--r--uriloader/exthandler/tests/mochitest/mime_type_download.sjs21
-rw-r--r--uriloader/exthandler/tests/mochitest/mochitest.ini17
-rw-r--r--uriloader/exthandler/tests/mochitest/protocolHandler.html16
-rw-r--r--uriloader/exthandler/tests/mochitest/protocol_custom_sandbox_helper.sjs30
-rw-r--r--uriloader/exthandler/tests/mochitest/redirect_helper.sjs30
-rw-r--r--uriloader/exthandler/tests/mochitest/save_filenames.html360
-rw-r--r--uriloader/exthandler/tests/mochitest/script_redirect.html5
-rw-r--r--uriloader/exthandler/tests/mochitest/test_invalidCharFileExtension.xhtml65
-rw-r--r--uriloader/exthandler/tests/mochitest/test_nullCharFile.xhtml67
-rw-r--r--uriloader/exthandler/tests/mochitest/test_spammy_page.html27
-rw-r--r--uriloader/exthandler/tests/mochitest/test_unknown_ext_protocol_handlers.html28
-rw-r--r--uriloader/exthandler/tests/mochitest/test_unsafeBidiChars.xhtml89
-rw-r--r--uriloader/exthandler/tests/mochitest/unsafeBidiFileName.sjs18
-rw-r--r--uriloader/exthandler/tests/moz.build35
-rw-r--r--uriloader/exthandler/tests/unit/handlers.json83
-rw-r--r--uriloader/exthandler/tests/unit/head.js82
-rw-r--r--uriloader/exthandler/tests/unit/mailcap2
-rw-r--r--uriloader/exthandler/tests/unit/test_badMIMEType.js29
-rw-r--r--uriloader/exthandler/tests/unit/test_defaults_handlerService.js159
-rw-r--r--uriloader/exthandler/tests/unit/test_downloads_improvements_migration.js244
-rw-r--r--uriloader/exthandler/tests/unit/test_filename_sanitize.js398
-rw-r--r--uriloader/exthandler/tests/unit/test_getFromTypeAndExtension.js17
-rw-r--r--uriloader/exthandler/tests/unit/test_getMIMEInfo_pdf.js30
-rw-r--r--uriloader/exthandler/tests/unit/test_getMIMEInfo_unknown_mime_type.js32
-rw-r--r--uriloader/exthandler/tests/unit/test_getTypeFromExtension_ext_to_type_mapping.js65
-rw-r--r--uriloader/exthandler/tests/unit/test_getTypeFromExtension_with_empty_Content_Type.js218
-rw-r--r--uriloader/exthandler/tests/unit/test_handlerService.js467
-rw-r--r--uriloader/exthandler/tests/unit/test_handlerService_store.js752
-rw-r--r--uriloader/exthandler/tests/unit/test_punycodeURIs.js126
-rw-r--r--uriloader/exthandler/tests/unit/xpcshell.ini38
102 files changed, 12141 insertions, 0 deletions
diff --git a/uriloader/exthandler/tests/HandlerServiceTestUtils.sys.mjs b/uriloader/exthandler/tests/HandlerServiceTestUtils.sys.mjs
new file mode 100644
index 0000000000..0e52cc2568
--- /dev/null
+++ b/uriloader/exthandler/tests/HandlerServiceTestUtils.sys.mjs
@@ -0,0 +1,253 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Shared functions for tests related to invoking external handler applications.
+ */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { Assert } from "resource://testing-common/Assert.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gExternalProtocolService",
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ "nsIExternalProtocolService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gMIMEService",
+ "@mozilla.org/mime;1",
+ "nsIMIMEService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gHandlerService",
+ "@mozilla.org/uriloader/handler-service;1",
+ "nsIHandlerService"
+);
+
+export var HandlerServiceTestUtils = {
+ /**
+ * Retrieves the names of all the MIME types and protocols configured in the
+ * handler service instance currently under testing.
+ *
+ * @return Array of strings like "example/type" or "example-scheme", sorted
+ * alphabetically regardless of category.
+ */
+ getAllHandlerInfoTypes() {
+ return Array.from(
+ lazy.gHandlerService.enumerate(),
+ info => info.type
+ ).sort();
+ },
+
+ /**
+ * Retrieves all the configured handlers for MIME types and protocols.
+ *
+ * @note The nsIHandlerInfo instances returned by the "enumerate" method
+ * cannot be used for testing because they incorporate information from
+ * the operating system and also from the default nsIHandlerService
+ * instance, independently from what instance is under testing.
+ *
+ * @return Array of nsIHandlerInfo instances sorted by their "type" property.
+ */
+ getAllHandlerInfos() {
+ return this.getAllHandlerInfoTypes().map(type => this.getHandlerInfo(type));
+ },
+
+ /**
+ * Retrieves an nsIHandlerInfo for the given MIME type or protocol, which
+ * incorporates information from the operating system and also from the
+ * handler service instance currently under testing.
+ *
+ * @note If the handler service instance currently under testing is not the
+ * default one and the requested type is a MIME type, the returned
+ * nsIHandlerInfo will include information from the default
+ * nsIHandlerService instance. This cannot be avoided easily because the
+ * getMIMEInfoFromOS method is not exposed to JavaScript.
+ *
+ * @param type
+ * MIME type or scheme name of the nsIHandlerInfo to retrieve.
+ *
+ * @return The populated nsIHandlerInfo instance.
+ */
+ getHandlerInfo(type) {
+ if (type.includes("/")) {
+ // We have to use the getFromTypeAndExtension method because we don't have
+ // access to getMIMEInfoFromOS. This means that we have to reset the data
+ // that may have been imported from the default nsIHandlerService instance
+ // and is not overwritten by fillHandlerInfo later.
+ let handlerInfo = lazy.gMIMEService.getFromTypeAndExtension(type, null);
+ if (AppConstants.platform == "android") {
+ // On Android, the first handler application is always the internal one.
+ while (handlerInfo.possibleApplicationHandlers.length > 1) {
+ handlerInfo.possibleApplicationHandlers.removeElementAt(1);
+ }
+ } else {
+ handlerInfo.possibleApplicationHandlers.clear();
+ }
+ handlerInfo.setFileExtensions("");
+ // Populate the object from the handler service instance under testing.
+ if (lazy.gHandlerService.exists(handlerInfo)) {
+ lazy.gHandlerService.fillHandlerInfo(handlerInfo, "");
+ }
+ return handlerInfo;
+ }
+
+ // Populate the protocol information from the handler service instance under
+ // testing, like the nsIExternalProtocolService::GetProtocolHandlerInfo
+ // method does on the default nsIHandlerService instance.
+ let osDefaultHandlerFound = {};
+ let handlerInfo =
+ lazy.gExternalProtocolService.getProtocolHandlerInfoFromOS(
+ type,
+ osDefaultHandlerFound
+ );
+ if (lazy.gHandlerService.exists(handlerInfo)) {
+ lazy.gHandlerService.fillHandlerInfo(handlerInfo, "");
+ } else {
+ lazy.gExternalProtocolService.setProtocolHandlerDefaults(
+ handlerInfo,
+ osDefaultHandlerFound.value
+ );
+ }
+ return handlerInfo;
+ },
+
+ /**
+ * Creates an nsIHandlerInfo for the given MIME type or protocol, initialized
+ * to the default values for the current platform.
+ *
+ * @note For this method to work, the specified MIME type or protocol must not
+ * be configured in the default handler service instance or the one
+ * under testing, and must not be registered in the operating system.
+ *
+ * @param type
+ * MIME type or scheme name of the nsIHandlerInfo to create.
+ *
+ * @return The blank nsIHandlerInfo instance.
+ */
+ getBlankHandlerInfo(type) {
+ let handlerInfo = this.getHandlerInfo(type);
+
+ let preferredAction, preferredApplicationHandler;
+ let alwaysAskBeforeHandling = true;
+
+ if (AppConstants.platform == "android") {
+ // On Android, the default preferredAction for MIME types is useHelperApp.
+ // For protocols, we always behave as if an operating system provided
+ // handler existed, and as such we initialize them to useSystemDefault.
+ // This is because the AndroidBridge is not available in xpcshell tests.
+ preferredAction = type.includes("/")
+ ? Ci.nsIHandlerInfo.useHelperApp
+ : Ci.nsIHandlerInfo.useSystemDefault;
+ // On Android, the default handler application is always the internal one.
+ preferredApplicationHandler = {
+ name: "Android chooser",
+ };
+ } else {
+ // On Desktop, the default preferredAction for MIME types is saveToDisk,
+ // while for protocols it is alwaysAsk. Since Bug 1735843, for new MIME
+ // types we default to not asking before handling unless a pref is set.
+ alwaysAskBeforeHandling = Services.prefs.getBoolPref(
+ "browser.download.always_ask_before_handling_new_types",
+ false
+ );
+
+ if (type.includes("/")) {
+ preferredAction = Ci.nsIHandlerInfo.saveToDisk;
+ } else {
+ preferredAction = Ci.nsIHandlerInfo.alwaysAsk;
+ // we'll always ask before handling protocols
+ alwaysAskBeforeHandling = true;
+ }
+ preferredApplicationHandler = null;
+ }
+
+ this.assertHandlerInfoMatches(handlerInfo, {
+ type,
+ preferredAction,
+ alwaysAskBeforeHandling,
+ preferredApplicationHandler,
+ });
+ return handlerInfo;
+ },
+
+ /**
+ * Checks whether an nsIHandlerInfo instance matches the provided object.
+ */
+ assertHandlerInfoMatches(handlerInfo, expected) {
+ let expectedInterface = expected.type.includes("/")
+ ? Ci.nsIMIMEInfo
+ : Ci.nsIHandlerInfo;
+ Assert.ok(handlerInfo instanceof expectedInterface);
+ Assert.equal(handlerInfo.type, expected.type);
+
+ if (!expected.preferredActionOSDependent) {
+ Assert.equal(handlerInfo.preferredAction, expected.preferredAction);
+ Assert.equal(
+ handlerInfo.alwaysAskBeforeHandling,
+ expected.alwaysAskBeforeHandling
+ );
+ }
+
+ if (expectedInterface == Ci.nsIMIMEInfo) {
+ let fileExtensionsEnumerator = handlerInfo.getFileExtensions();
+ for (let expectedFileExtension of expected.fileExtensions || []) {
+ Assert.equal(fileExtensionsEnumerator.getNext(), expectedFileExtension);
+ }
+ Assert.ok(!fileExtensionsEnumerator.hasMore());
+ }
+
+ if (expected.preferredApplicationHandler) {
+ this.assertHandlerAppMatches(
+ handlerInfo.preferredApplicationHandler,
+ expected.preferredApplicationHandler
+ );
+ } else {
+ Assert.equal(handlerInfo.preferredApplicationHandler, null);
+ }
+
+ let handlerAppsArrayEnumerator =
+ handlerInfo.possibleApplicationHandlers.enumerate();
+ if (AppConstants.platform == "android") {
+ // On Android, the first handler application is always the internal one.
+ this.assertHandlerAppMatches(handlerAppsArrayEnumerator.getNext(), {
+ name: "Android chooser",
+ });
+ }
+ for (let expectedHandlerApp of expected.possibleApplicationHandlers || []) {
+ this.assertHandlerAppMatches(
+ handlerAppsArrayEnumerator.getNext(),
+ expectedHandlerApp
+ );
+ }
+ Assert.ok(!handlerAppsArrayEnumerator.hasMoreElements());
+ },
+
+ /**
+ * Checks whether an nsIHandlerApp instance matches the provided object.
+ */
+ assertHandlerAppMatches(handlerApp, expected) {
+ Assert.ok(handlerApp instanceof Ci.nsIHandlerApp);
+ Assert.equal(handlerApp.name, expected.name);
+ if (expected.executable) {
+ Assert.ok(handlerApp instanceof Ci.nsILocalHandlerApp);
+ Assert.ok(expected.executable.equals(handlerApp.executable));
+ } else if (expected.uriTemplate) {
+ Assert.ok(handlerApp instanceof Ci.nsIWebHandlerApp);
+ Assert.equal(handlerApp.uriTemplate, expected.uriTemplate);
+ } else if (expected.service) {
+ Assert.ok(handlerApp instanceof Ci.nsIDBusHandlerApp);
+ Assert.equal(handlerApp.service, expected.service);
+ Assert.equal(handlerApp.method, expected.method);
+ Assert.equal(handlerApp.dBusInterface, expected.dBusInterface);
+ Assert.equal(handlerApp.objectPath, expected.objectPath);
+ }
+ },
+};
diff --git a/uriloader/exthandler/tests/WriteArgument.cpp b/uriloader/exthandler/tests/WriteArgument.cpp
new file mode 100644
index 0000000000..603965150e
--- /dev/null
+++ b/uriloader/exthandler/tests/WriteArgument.cpp
@@ -0,0 +1,20 @@
+#include <stdio.h>
+#include "prenv.h"
+
+int main(int argc, char* argv[]) {
+ if (argc != 2) return 1;
+
+ const char* value = PR_GetEnv("WRITE_ARGUMENT_FILE");
+
+ if (!value) return 2;
+
+ FILE* outfile = fopen(value, "w");
+ if (!outfile) return 3;
+
+ // We only need to write out the first argument (no newline).
+ fputs(argv[argc - 1], outfile);
+
+ fclose(outfile);
+
+ return 0;
+}
diff --git a/uriloader/exthandler/tests/gtest/ExternalHelperAppServiceTest.cpp b/uriloader/exthandler/tests/gtest/ExternalHelperAppServiceTest.cpp
new file mode 100644
index 0000000000..f6e900bf3f
--- /dev/null
+++ b/uriloader/exthandler/tests/gtest/ExternalHelperAppServiceTest.cpp
@@ -0,0 +1,41 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsExternalHelperAppService.h"
+#include "gtest/gtest.h"
+#include "nsNetUtil.h"
+
+using namespace mozilla;
+
+TEST(ExternalHelperAppService, EscapeURI)
+{
+ nsCString input("myproto://hello world");
+ nsCString expected("myproto://hello%20world");
+
+ nsCOMPtr<nsIIOService> ios(do_GetIOService());
+ nsCOMPtr<nsIURI> uri;
+ nsresult rv = ios->NewURI(input, nullptr, nullptr, getter_AddRefs(uri));
+ EXPECT_EQ(rv, NS_OK);
+
+ nsAutoCString spec;
+ rv = uri->GetSpec(spec);
+ EXPECT_EQ(rv, NS_OK);
+
+ // Constructing the URI does not escape the space character.
+ ASSERT_TRUE(spec.Equals(input));
+
+ // Created an escaped version of the URI.
+ nsCOMPtr<nsIURI> escapedURI;
+ rv = nsExternalHelperAppService::EscapeURI(uri, getter_AddRefs(escapedURI));
+ EXPECT_EQ(rv, NS_OK);
+
+ nsAutoCString escapedSpec;
+ rv = escapedURI->GetSpec(escapedSpec);
+ EXPECT_EQ(rv, NS_OK);
+
+ // Escaped URI should have an escaped spec.
+ ASSERT_TRUE(escapedSpec.Equals(expected));
+}
diff --git a/uriloader/exthandler/tests/gtest/moz.build b/uriloader/exthandler/tests/gtest/moz.build
new file mode 100644
index 0000000000..163afe927d
--- /dev/null
+++ b/uriloader/exthandler/tests/gtest/moz.build
@@ -0,0 +1,11 @@
+# -*- 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/.
+
+UNIFIED_SOURCES += [
+ "ExternalHelperAppServiceTest.cpp",
+]
+
+FINAL_LIBRARY = "xul-gtest"
diff --git a/uriloader/exthandler/tests/mochitest/FTPprotocolHandler.html b/uriloader/exthandler/tests/mochitest/FTPprotocolHandler.html
new file mode 100644
index 0000000000..d0e98abace
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/FTPprotocolHandler.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Protocol handler</title>
+ <meta content="text/html;charset=utf-8" http-equiv="Content-Type">
+ <meta content="utf-8" http-equiv="encoding">
+ </head>
+ <body>
+ <script type="text/javascript">
+ navigator.registerProtocolHandler("ftp",
+ "https://example.com/browser/uriloader/exthandler/tests/mochitest/blank.html?uri=%s",
+ "Test Protocol");
+ </script>
+ <a id="link" href="ftp://user:password@domain.com/path">ftp link</a>
+ </body>
+</html>
diff --git a/uriloader/exthandler/tests/mochitest/HelperAppLauncherDialog_chromeScript.js b/uriloader/exthandler/tests/mochitest/HelperAppLauncherDialog_chromeScript.js
new file mode 100644
index 0000000000..4f66c99dc6
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/HelperAppLauncherDialog_chromeScript.js
@@ -0,0 +1,104 @@
+/* eslint-env mozilla/chrome-script */
+
+const { ComponentUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ComponentUtils.sys.mjs"
+);
+
+const { Downloads } = ChromeUtils.importESModule(
+ "resource://gre/modules/Downloads.sys.mjs"
+);
+
+let gMIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+let gHandlerService = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+);
+
+const HELPERAPP_DIALOG_CONTRACT = "@mozilla.org/helperapplauncherdialog;1";
+const HELPERAPP_DIALOG_CID = Components.ID(
+ Cc[HELPERAPP_DIALOG_CONTRACT].number
+);
+
+let tmpDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+tmpDir.append("testsavedir" + Math.floor(Math.random() * 2 ** 32));
+// Create this dir if it doesn't exist (ignores existing dirs)
+try {
+ tmpDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o777, true);
+} catch (ex) {
+ if (ex.result != Cr.NS_ERROR_FILE_ALREADY_EXISTS) {
+ throw ex;
+ }
+}
+Services.prefs.setIntPref("browser.download.folderList", 2);
+Services.prefs.setCharPref("browser.download.dir", tmpDir.path);
+
+const FAKE_CID = Services.uuid.generateUUID();
+function HelperAppLauncherDialog() {}
+HelperAppLauncherDialog.prototype = {
+ show(aLauncher, aWindowContext, aReason) {
+ if (
+ Services.prefs.getBoolPref(
+ "browser.download.always_ask_before_handling_new_types"
+ )
+ ) {
+ let f = tmpDir.clone();
+ f.append(aLauncher.suggestedFileName);
+ aLauncher.saveDestinationAvailable(f);
+ sendAsyncMessage("suggestedFileName", aLauncher.suggestedFileName);
+ } else {
+ sendAsyncMessage("wrongAPICall", "show");
+ }
+ aLauncher.cancel(Cr.NS_BINDING_ABORTED);
+ },
+ promptForSaveToFileAsync(
+ appLauncher,
+ parent,
+ filename,
+ extension,
+ forceSave
+ ) {
+ if (
+ !Services.prefs.getBoolPref(
+ "browser.download.always_ask_before_handling_new_types"
+ )
+ ) {
+ let f = tmpDir.clone();
+ f.append(filename);
+ appLauncher.saveDestinationAvailable(f);
+ sendAsyncMessage("suggestedFileName", filename);
+ } else {
+ sendAsyncMessage("wrongAPICall", "promptForSaveToFileAsync");
+ }
+ appLauncher.cancel(Cr.NS_BINDING_ABORTED);
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncherDialog"]),
+};
+
+var registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+registrar.registerFactory(
+ FAKE_CID,
+ "",
+ HELPERAPP_DIALOG_CONTRACT,
+ ComponentUtils.generateSingletonFactory(HelperAppLauncherDialog)
+);
+
+addMessageListener("unregister", async function () {
+ registrar.registerFactory(
+ HELPERAPP_DIALOG_CID,
+ "",
+ HELPERAPP_DIALOG_CONTRACT,
+ null
+ );
+ let list = await Downloads.getList(Downloads.ALL);
+ let downloads = await list.getAll();
+ for (let dl of downloads) {
+ await dl.refresh();
+ if (dl.target.exists || dl.target.partFileExists) {
+ dump("Finalizing download.\n");
+ await dl.finalize(true).catch(console.error);
+ }
+ }
+ await list.removeFinished();
+ dump("Clearing " + tmpDir.path + "\n");
+ tmpDir.remove(true);
+ sendAsyncMessage("unregistered");
+});
diff --git a/uriloader/exthandler/tests/mochitest/blank.html b/uriloader/exthandler/tests/mochitest/blank.html
new file mode 100644
index 0000000000..5417098d72
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/blank.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title></title>
+</head>
+<body>
+
+</body>
+</html>
diff --git a/uriloader/exthandler/tests/mochitest/browser.ini b/uriloader/exthandler/tests/mochitest/browser.ini
new file mode 100644
index 0000000000..22bae61c78
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser.ini
@@ -0,0 +1,130 @@
+[DEFAULT]
+head = head.js
+support-files =
+ protocolHandler.html
+
+
+[browser_auto_close_window.js]
+support-files =
+ download_page.html
+ download.bin
+ download.sjs
+[browser_auto_close_window_nodialog.js]
+support-files =
+ download_page.html
+ download.bin
+ download.sjs
+[browser_bad_download_dir.js]
+run-if = os == 'linux'
+support-files = download.bin
+[browser_download_always_ask_preferred_app.js]
+[browser_download_idn_blocklist.js]
+support-files = download.bin
+[browser_download_open_with_internal_handler.js]
+support-files =
+ file_image_svgxml.svg
+ file_image_svgxml.svg^headers^
+ file_pdf_application_pdf.pdf
+ file_pdf_application_pdf.pdf^headers^
+ file_pdf_application_unknown.pdf
+ file_pdf_application_unknown.pdf^headers^
+ file_pdf_application_octet_stream.pdf
+ file_pdf_application_octet_stream.pdf^headers^
+ file_pdf_binary_octet_stream.pdf
+ file_pdf_binary_octet_stream.pdf^headers^
+ file_txt_attachment_test.txt
+ file_txt_attachment_test.txt^headers^
+ file_xml_attachment_binary_octet_stream.xml
+ file_xml_attachment_binary_octet_stream.xml^headers^
+ file_xml_attachment_test.xml
+ file_xml_attachment_test.xml^headers^
+ file_green.webp
+ file_green.webp^headers^
+[browser_download_preferred_action.js]
+support-files =
+ mime_type_download.sjs
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_download_privatebrowsing.js]
+[browser_download_skips_dialog.js]
+support-files =
+ file_green.webp
+ file_green.webp^headers^
+[browser_download_spam_permissions.js]
+support-files =
+ test_spammy_page.html
+[browser_download_urlescape.js]
+support-files =
+ file_with@@funny_name.png
+ file_with@@funny_name.png^headers^
+ file_with[funny_name.webm
+ file_with[funny_name.webm^headers^
+[browser_extension_correction.js]
+support-files =
+ file_as.exe
+ file_as.exe^headers^
+[browser_filehandling_loop.js]
+[browser_launched_app_save_directory.js]
+support-files =
+ file_pdf_application_pdf.pdf
+ file_pdf_application_pdf.pdf^headers^
+ file_green.webp
+ file_green.webp^headers^
+[browser_local_files_no_save_without_asking.js]
+support-files =
+ file_pdf_binary_octet_stream.pdf
+[browser_local_files_open_doesnt_duplicate.js]
+support-files =
+ file_pdf_binary_octet_stream.pdf
+[browser_shows_where_to_save_dialog.js]
+support-files =
+ file_green.webp
+ file_green.webp^headers^
+ file_pdf_application_pdf.pdf
+ file_pdf_application_pdf.pdf^headers^
+ file_txt_attachment_test.txt
+ file_txt_attachment_test.txt^headers^
+[browser_open_internal_choice_persistence.js]
+skip-if =
+ apple_silicon # bug 1752482
+support-files =
+ file_pdf_application_pdf.pdf
+ file_pdf_application_pdf.pdf^headers^
+[browser_pdf_save_as.js]
+[browser_protocol_ask_dialog.js]
+support-files =
+ file_nested_protocol_request.html
+[browser_protocol_custom_sandbox.js]
+support-files =
+ protocol_custom_sandbox_helper.sjs
+[browser_protocol_custom_sandbox_csp.js]
+support-files =
+ protocol_custom_sandbox_helper.sjs
+[browser_first_prompt_not_blocked_without_user_interaction.js]
+support-files =
+ file_external_protocol_iframe.html
+[browser_protocol_ask_dialog_external.js]
+support-files =
+ redirect_helper.sjs
+[browser_protocol_ask_dialog_permission.js]
+support-files =
+ redirect_helper.sjs
+ script_redirect.html
+[browser_protocolhandler_loop.js]
+[browser_remember_download_option.js]
+[browser_save_filenames.js]
+support-files =
+ save_filenames.html
+[browser_txt_download_save_as.js]
+support-files =
+ file_txt_attachment_test.txt
+ file_txt_attachment_test.txt^headers^
+ !/toolkit/content/tests/browser/common/mockTransfer.js
+[browser_web_handler_app_pinned_tab.js]
+support-files =
+ mailto.html
+[browser_web_protocol_handlers.js]
+[browser_ftp_protocol_handlers.js]
+support-files =
+ FTPprotocolHandler.html
+ blank.html
diff --git a/uriloader/exthandler/tests/mochitest/browser_auto_close_window.js b/uriloader/exthandler/tests/mochitest/browser_auto_close_window.js
new file mode 100644
index 0000000000..af60b59c4b
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_auto_close_window.js
@@ -0,0 +1,342 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { ComponentUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ComponentUtils.sys.mjs"
+);
+
+const ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+const PAGE_URL = ROOT + "download_page.html";
+const SJS_URL = ROOT + "download.sjs";
+
+const HELPERAPP_DIALOG_CONTRACT_ID = "@mozilla.org/helperapplauncherdialog;1";
+const HELPERAPP_DIALOG_CID = Components.ID(
+ Cc[HELPERAPP_DIALOG_CONTRACT_ID].number
+);
+const MOCK_HELPERAPP_DIALOG_CID = Components.ID(
+ "{2f372b6f-56c9-46d5-af0d-9f09bb69860c}"
+);
+
+let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+let curDialogResolve = null;
+
+function HelperAppLauncherDialog() {}
+
+HelperAppLauncherDialog.prototype = {
+ show(aLauncher, aWindowContext, aReason) {
+ ok(true, "Showing the helper app dialog");
+ curDialogResolve(aWindowContext);
+ executeSoon(() => {
+ aLauncher.cancel(Cr.NS_ERROR_ABORT);
+ });
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncherDialog"]),
+};
+
+function promiseHelperAppDialog() {
+ return new Promise(resolve => {
+ curDialogResolve = resolve;
+ });
+}
+
+let mockHelperAppService;
+
+add_setup(async function () {
+ // Replace the real helper app dialog with our own.
+ mockHelperAppService = ComponentUtils.generateSingletonFactory(
+ HelperAppLauncherDialog
+ );
+ registrar.registerFactory(
+ MOCK_HELPERAPP_DIALOG_CID,
+ "",
+ HELPERAPP_DIALOG_CONTRACT_ID,
+ mockHelperAppService
+ );
+
+ // Ensure we always prompt for these downloads.
+ const HandlerService = Cc[
+ "@mozilla.org/uriloader/handler-service;1"
+ ].getService(Ci.nsIHandlerService);
+
+ const MIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+ const mimeInfo = MIMEService.getFromTypeAndExtension(
+ "application/octet-stream",
+ "bin"
+ );
+ mimeInfo.alwaysAskBeforeHandling = true;
+ HandlerService.store(mimeInfo);
+
+ // On Mac, .bin is application/macbinary
+ let mimeInfoMac;
+ if (AppConstants.platform == "macosx") {
+ mimeInfoMac = MIMEService.getFromTypeAndExtension(
+ "application/macbinary",
+ "bin"
+ );
+ mimeInfoMac.alwaysAskBeforeHandling = true;
+ HandlerService.store(mimeInfoMac);
+ }
+
+ registerCleanupFunction(() => {
+ HandlerService.remove(mimeInfo);
+ if (mimeInfoMac) {
+ HandlerService.remove(mimeInfoMac);
+ }
+ });
+});
+
+add_task(async function simple_navigation() {
+ // Tests that simple navigation gives us the right windowContext (that is,
+ // the window that we're using).
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_URL },
+ async function (browser) {
+ let dialogAppeared = promiseHelperAppDialog();
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#regular_load",
+ {},
+ browser
+ );
+ let windowContext = await dialogAppeared;
+
+ is(windowContext, browser.ownerGlobal, "got the right windowContext");
+ }
+ );
+});
+
+add_task(async function accel_navigation() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_URL },
+ async function (browser) {
+ let dialogAppeared = promiseHelperAppDialog();
+ let tabOpened = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabOpen"
+ ).then(event => {
+ return [event.target, BrowserTestUtils.waitForTabClosing(event.target)];
+ });
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#regular_load",
+ { accelKey: true },
+ browser
+ );
+
+ let windowContext = await dialogAppeared;
+ is(windowContext, browser.ownerGlobal, "got the right windowContext");
+ let [tab, closingPromise] = await tabOpened;
+ await closingPromise;
+ is(tab.linkedBrowser, null, "tab was opened and closed");
+ }
+ );
+});
+
+// Given a browser pointing to download_page.html, clicks on the link that
+// opens with target="_blank" (i.e. a new tab) and ensures that we
+// automatically open and close that tab.
+async function testNewTab(browser) {
+ let dialogAppeared = promiseHelperAppDialog();
+ let tabOpened = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabOpen"
+ ).then(event => {
+ return [event.target, BrowserTestUtils.waitForTabClosing(event.target)];
+ });
+
+ await BrowserTestUtils.synthesizeMouseAtCenter("#target_blank", {}, browser);
+
+ let windowContext = await dialogAppeared;
+ is(windowContext, browser.ownerGlobal, "got the right windowContext");
+ let [tab, closingPromise] = await tabOpened;
+ await closingPromise;
+ is(tab.linkedBrowser, null, "tab was opened and closed");
+}
+
+add_task(async function target_blank() {
+ // Tests that a link with target=_blank opens a new tab and closes it,
+ // returning the window that we're using for navigation.
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_URL },
+ async function (browser) {
+ await testNewTab(browser);
+ }
+ );
+});
+
+add_task(async function target_blank_no_opener() {
+ // Tests that a link with target=_blank and no opener opens a new tab
+ // and closes it, returning the window that we're using for navigation.
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_URL },
+ async function (browser) {
+ let dialogAppeared = promiseHelperAppDialog();
+ let tabOpened = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabOpen"
+ ).then(event => {
+ return [event.target, BrowserTestUtils.waitForTabClosing(event.target)];
+ });
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#target_blank_no_opener",
+ {},
+ browser
+ );
+
+ let windowContext = await dialogAppeared;
+ is(windowContext, browser.ownerGlobal, "got the right windowContext");
+ let [tab, closingPromise] = await tabOpened;
+ await closingPromise;
+ is(tab.linkedBrowser, null, "tab was opened and closed");
+ }
+ );
+});
+
+add_task(async function open_in_new_tab_no_opener() {
+ // Tests that a link with target=_blank and no opener opens a new tab
+ // and closes it, returning the window that we're using for navigation.
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_URL },
+ async function (browser) {
+ let dialogAppeared = promiseHelperAppDialog();
+ let tabOpened = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabOpen"
+ ).then(event => {
+ return [event.target, BrowserTestUtils.waitForTabClosing(event.target)];
+ });
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#open_in_new_tab_no_opener",
+ {},
+ browser
+ );
+
+ let windowContext = await dialogAppeared;
+ is(windowContext, browser.ownerGlobal, "got the right windowContext");
+ let [tab, closingPromise] = await tabOpened;
+ await closingPromise;
+ is(tab.linkedBrowser, null, "tab was opened and closed");
+ }
+ );
+});
+
+add_task(async function new_window() {
+ // Tests that a link that forces us to open a new window (by specifying a
+ // width and a height in window.open) opens a new window for the load,
+ // realizes that we need to close that window and returns the *original*
+ // window as the window context.
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_URL },
+ async function (browser) {
+ let dialogAppeared = promiseHelperAppDialog();
+ let windowOpened = BrowserTestUtils.waitForNewWindow();
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#new_window",
+ {},
+ browser
+ );
+ let win = await windowOpened;
+ // Now allow request to complete:
+ fetch(SJS_URL + "?finish");
+
+ let windowContext = await dialogAppeared;
+ is(windowContext, browser.ownerGlobal, "got the right windowContext");
+
+ // The window should close on its own. If not, this test will time out.
+ await BrowserTestUtils.domWindowClosed(win);
+ ok(win.closed, "window was opened and closed");
+
+ is(
+ await fetch(SJS_URL + "?reset").then(r => r.text()),
+ "OK",
+ "Test reseted"
+ );
+ }
+ );
+});
+
+add_task(async function new_window_no_opener() {
+ // Tests that a link that forces us to open a new window (by specifying a
+ // width and a height in window.open) opens a new window for the load,
+ // realizes that we need to close that window and returns the *original*
+ // window as the window context.
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_URL },
+ async function (browser) {
+ let dialogAppeared = promiseHelperAppDialog();
+ let windowOpened = BrowserTestUtils.waitForNewWindow();
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#new_window_no_opener",
+ {},
+ browser
+ );
+ let win = await windowOpened;
+ // Now allow request to complete:
+ fetch(SJS_URL + "?finish");
+
+ await dialogAppeared;
+
+ // The window should close on its own. If not, this test will time out.
+ await BrowserTestUtils.domWindowClosed(win);
+ ok(win.closed, "window was opened and closed");
+
+ is(
+ await fetch(SJS_URL + "?reset").then(r => r.text()),
+ "OK",
+ "Test reseted"
+ );
+ }
+ );
+});
+
+add_task(async function nested_window_opens() {
+ // Tests that the window auto-closing feature works if the download is
+ // initiated by a window that, itself, has an opener (see bug 1373109).
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_URL },
+ async function (outerBrowser) {
+ let secondTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ `${PAGE_URL}?newwin`,
+ true
+ );
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "#open_in_new_tab",
+ {},
+ outerBrowser
+ );
+ let secondTab = await secondTabPromise;
+ let nestedBrowser = secondTab.linkedBrowser;
+
+ await SpecialPowers.spawn(nestedBrowser, [], function () {
+ ok(content.opener, "this window has an opener");
+ });
+
+ await testNewTab(nestedBrowser);
+
+ isnot(
+ secondTab.linkedBrowser,
+ null,
+ "the page that triggered the download is still open"
+ );
+ BrowserTestUtils.removeTab(secondTab);
+ }
+ );
+});
+
+add_task(async function cleanup() {
+ // Unregister our factory from XPCOM and restore the original CID.
+ registrar.unregisterFactory(MOCK_HELPERAPP_DIALOG_CID, mockHelperAppService);
+ registrar.registerFactory(
+ HELPERAPP_DIALOG_CID,
+ "",
+ HELPERAPP_DIALOG_CONTRACT_ID,
+ null
+ );
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_auto_close_window_nodialog.js b/uriloader/exthandler/tests/mochitest/browser_auto_close_window_nodialog.js
new file mode 100644
index 0000000000..4f1e8ab18e
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_auto_close_window_nodialog.js
@@ -0,0 +1,308 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { ComponentUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ComponentUtils.sys.mjs"
+);
+
+const ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+const PAGE_URL = ROOT + "download_page.html";
+const SJS_URL = ROOT + "download.sjs";
+
+const HELPERAPP_DIALOG_CONTRACT_ID = "@mozilla.org/helperapplauncherdialog;1";
+const HELPERAPP_DIALOG_CID = Components.ID(
+ Cc[HELPERAPP_DIALOG_CONTRACT_ID].number
+);
+const MOCK_HELPERAPP_DIALOG_CID = Components.ID(
+ "{2f372b6f-56c9-46d5-af0d-9f09bb69860c}"
+);
+
+let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+let curSaveResolve = null;
+
+function HelperAppLauncherDialog() {}
+
+HelperAppLauncherDialog.prototype = {
+ show(aLauncher, aWindowContext, aReason) {
+ ok(false, "Shouldn't be showing the helper app dialog");
+ executeSoon(() => {
+ aLauncher.cancel(Cr.NS_ERROR_ABORT);
+ });
+ },
+ promptForSaveToFileAsync(aLauncher, aWindowContext, aReason) {
+ ok(true, "Shouldn't be showing the helper app dialog");
+ curSaveResolve(aWindowContext);
+ executeSoon(() => {
+ aLauncher.cancel(Cr.NS_ERROR_ABORT);
+ });
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncherDialog"]),
+};
+
+function promiseSave() {
+ return new Promise(resolve => {
+ curSaveResolve = resolve;
+ });
+}
+
+let mockHelperAppService;
+
+add_setup(async function () {
+ // Replace the real helper app dialog with our own.
+ mockHelperAppService = ComponentUtils.generateSingletonFactory(
+ HelperAppLauncherDialog
+ );
+ registrar.registerFactory(
+ MOCK_HELPERAPP_DIALOG_CID,
+ "",
+ HELPERAPP_DIALOG_CONTRACT_ID,
+ mockHelperAppService
+ );
+
+ // Ensure we always prompt for these downloads.
+ const HandlerService = Cc[
+ "@mozilla.org/uriloader/handler-service;1"
+ ].getService(Ci.nsIHandlerService);
+
+ const MIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+ const mimeInfo = MIMEService.getFromTypeAndExtension(
+ "application/octet-stream",
+ "bin"
+ );
+ mimeInfo.alwaysAskBeforeHandling = false;
+ mimeInfo.preferredAction = Ci.nsIHandlerInfo.saveToDisk;
+ HandlerService.store(mimeInfo);
+
+ registerCleanupFunction(() => {
+ HandlerService.remove(mimeInfo);
+ });
+});
+
+add_task(async function simple_navigation() {
+ // Tests that simple navigation gives us the right windowContext (that is,
+ // the window that we're using).
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_URL },
+ async function (browser) {
+ let saveHappened = promiseSave();
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#regular_load",
+ {},
+ browser
+ );
+ let windowContext = await saveHappened;
+
+ is(windowContext, browser.ownerGlobal, "got the right windowContext");
+ }
+ );
+});
+
+// Given a browser pointing to download_page.html, clicks on the link that
+// opens with target="_blank" (i.e. a new tab) and ensures that we
+// automatically open and close that tab.
+async function testNewTab(browser) {
+ let saveHappened = promiseSave();
+ let tabOpened = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabOpen"
+ ).then(event => {
+ return [event.target, BrowserTestUtils.waitForTabClosing(event.target)];
+ });
+
+ await BrowserTestUtils.synthesizeMouseAtCenter("#target_blank", {}, browser);
+
+ let windowContext = await saveHappened;
+ is(windowContext, browser.ownerGlobal, "got the right windowContext");
+ let [tab, closingPromise] = await tabOpened;
+ await closingPromise;
+ is(tab.linkedBrowser, null, "tab was opened and closed");
+}
+
+add_task(async function target_blank() {
+ // Tests that a link with target=_blank opens a new tab and closes it,
+ // returning the window that we're using for navigation.
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_URL },
+ async function (browser) {
+ await testNewTab(browser);
+ }
+ );
+});
+
+add_task(async function target_blank_no_opener() {
+ // Tests that a link with target=_blank and no opener opens a new tab
+ // and closes it, returning the window that we're using for navigation.
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_URL },
+ async function (browser) {
+ let saveHappened = promiseSave();
+ let tabOpened = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabOpen"
+ ).then(event => {
+ return [event.target, BrowserTestUtils.waitForTabClosing(event.target)];
+ });
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#target_blank_no_opener",
+ {},
+ browser
+ );
+
+ let windowContext = await saveHappened;
+ is(windowContext, browser.ownerGlobal, "got the right windowContext");
+ let [tab, closingPromise] = await tabOpened;
+ await closingPromise;
+ is(tab.linkedBrowser, null, "tab was opened and closed");
+ }
+ );
+});
+
+add_task(async function open_in_new_tab_no_opener() {
+ // Tests that a link with target=_blank and no opener opens a new tab
+ // and closes it, returning the window that we're using for navigation.
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_URL },
+ async function (browser) {
+ let saveHappened = promiseSave();
+ let tabOpened = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabOpen"
+ ).then(event => {
+ return [event.target, BrowserTestUtils.waitForTabClosing(event.target)];
+ });
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#open_in_new_tab_no_opener",
+ {},
+ browser
+ );
+
+ let windowContext = await saveHappened;
+ is(windowContext, browser.ownerGlobal, "got the right windowContext");
+ let [tab, closingPromise] = await tabOpened;
+ await closingPromise;
+ is(tab.linkedBrowser, null, "tab was opened and closed");
+ }
+ );
+});
+
+add_task(async function new_window() {
+ // Tests that a link that forces us to open a new window (by specifying a
+ // width and a height in window.open) opens a new window for the load,
+ // realizes that we need to close that window and returns the *original*
+ // window as the window context.
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_URL },
+ async function (browser) {
+ let saveHappened = promiseSave();
+ let windowOpened = BrowserTestUtils.waitForNewWindow();
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#new_window",
+ {},
+ browser
+ );
+ let win = await windowOpened;
+ // Now allow request to complete:
+ fetch(SJS_URL + "?finish");
+
+ let windowContext = await saveHappened;
+ is(windowContext, browser.ownerGlobal, "got the right windowContext");
+
+ // The window should close on its own. If not, this test will time out.
+ await BrowserTestUtils.domWindowClosed(win);
+ ok(win.closed, "window was opened and closed");
+
+ is(
+ await fetch(SJS_URL + "?reset").then(r => r.text()),
+ "OK",
+ "Test reseted"
+ );
+ }
+ );
+});
+
+add_task(async function new_window_no_opener() {
+ // Tests that a link that forces us to open a new window (by specifying a
+ // width and a height in window.open) opens a new window for the load,
+ // realizes that we need to close that window and returns the *original*
+ // window as the window context.
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_URL },
+ async function (browser) {
+ let saveHappened = promiseSave();
+ let windowOpened = BrowserTestUtils.waitForNewWindow();
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#new_window_no_opener",
+ {},
+ browser
+ );
+ let win = await windowOpened;
+ // Now allow request to complete:
+ fetch(SJS_URL + "?finish");
+
+ await saveHappened;
+
+ // The window should close on its own. If not, this test will time out.
+ await BrowserTestUtils.domWindowClosed(win);
+ ok(win.closed, "window was opened and closed");
+
+ is(
+ await fetch(SJS_URL + "?reset").then(r => r.text()),
+ "OK",
+ "Test reseted"
+ );
+ }
+ );
+});
+
+add_task(async function nested_window_opens() {
+ // Tests that the window auto-closing feature works if the download is
+ // initiated by a window that, itself, has an opener (see bug 1373109).
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_URL },
+ async function (outerBrowser) {
+ let secondTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ `${PAGE_URL}?newwin`,
+ true
+ );
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "#open_in_new_tab",
+ {},
+ outerBrowser
+ );
+ let secondTab = await secondTabPromise;
+ let nestedBrowser = secondTab.linkedBrowser;
+
+ await SpecialPowers.spawn(nestedBrowser, [], function () {
+ ok(content.opener, "this window has an opener");
+ });
+
+ await testNewTab(nestedBrowser);
+
+ isnot(
+ secondTab.linkedBrowser,
+ null,
+ "the page that triggered the download is still open"
+ );
+ BrowserTestUtils.removeTab(secondTab);
+ }
+ );
+});
+
+add_task(async function cleanup() {
+ // Unregister our factory from XPCOM and restore the original CID.
+ registrar.unregisterFactory(MOCK_HELPERAPP_DIALOG_CID, mockHelperAppService);
+ registrar.registerFactory(
+ HELPERAPP_DIALOG_CID,
+ "",
+ HELPERAPP_DIALOG_CONTRACT_ID,
+ null
+ );
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_bad_download_dir.js b/uriloader/exthandler/tests/mochitest/browser_bad_download_dir.js
new file mode 100644
index 0000000000..7fe22e0661
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_bad_download_dir.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let { FileTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/FileTestUtils.sys.mjs"
+);
+
+const ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+const TEST_FILE = ROOT + "download.bin";
+
+add_task(async function test_check_download_dir() {
+ // Force XDG dir to somewhere that has no config files, causing lookups of the
+ // system download dir to fail:
+ let newXDGRoot = FileTestUtils.getTempFile("xdgstuff");
+ newXDGRoot.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ let oldXDG = Services.env.exists("XDG_CONFIG_HOME")
+ ? Services.env.get("XDG_CONFIG_HOME")
+ : "";
+ registerCleanupFunction(() => Services.env.set("XDG_CONFIG_HOME", oldXDG));
+ Services.env.set("XDG_CONFIG_HOME", newXDGRoot.path + "/");
+
+ let propBundle = Services.strings.createBundle(
+ "chrome://mozapps/locale/downloads/downloads.properties"
+ );
+ let dlRoot = PathUtils.join(
+ Services.dirsvc.get("Home", Ci.nsIFile).path,
+ propBundle.GetStringFromName("downloadsFolder")
+ );
+
+ // Check lookups fail:
+ Assert.throws(
+ () => Services.dirsvc.get("DfltDwnld", Ci.nsIFile),
+ /NS_ERROR_FAILURE/,
+ "Should throw when asking for downloads dir."
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Avoid opening dialogs
+ ["browser.download.always_ask_before_handling_new_types", false],
+ // Switch back to default OS downloads dir (changed in head.js):
+ ["browser.download.folderList", 1],
+ ],
+ });
+
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let downloadFinished = promiseDownloadFinished(publicList);
+ let tab = BrowserTestUtils.addTab(gBrowser, TEST_FILE);
+ let dl = await downloadFinished;
+ ok(dl.succeeded, "Download should succeed.");
+ Assert.stringContains(
+ dl.target.path,
+ dlRoot,
+ "Should store download under DL folder root."
+ );
+ let dlKids = await IOUtils.getChildren(dlRoot);
+ ok(
+ dlKids.includes(dl.target.path),
+ "Download should be a direct child of the DL folder."
+ );
+ await IOUtils.remove(dl.target.path);
+
+ BrowserTestUtils.removeTab(tab);
+
+ // Download a second file to make sure we're not continuously adding filenames
+ // onto the download folder path.
+ downloadFinished = promiseDownloadFinished(publicList);
+ tab = BrowserTestUtils.addTab(gBrowser, TEST_FILE);
+ dl = await downloadFinished;
+ Assert.stringContains(
+ dl.target.path,
+ dlRoot,
+ "Second download should store download under new DL folder root."
+ );
+ dlKids = await IOUtils.getChildren(dlRoot);
+ ok(
+ dlKids.includes(dl.target.path),
+ "Second download should be a direct child of the new DL folder."
+ );
+ BrowserTestUtils.removeTab(tab);
+ await IOUtils.remove(dl.target.path);
+
+ await publicList.removeFinished();
+ await IOUtils.remove(newXDGRoot.path);
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_download_always_ask_preferred_app.js b/uriloader/exthandler/tests/mochitest/browser_download_always_ask_preferred_app.js
new file mode 100644
index 0000000000..b4bfe5e51a
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_download_always_ask_preferred_app.js
@@ -0,0 +1,25 @@
+add_task(async function () {
+ // Create mocked objects for test
+ let launcher = createMockedObjects(false);
+ // Open helper app dialog with mocked launcher
+ let dlg = await openHelperAppDialog(launcher);
+ let doc = dlg.document;
+ let location = doc.getElementById("source");
+ let expectedValue = launcher.source.prePath;
+ if (location.value != expectedValue) {
+ info("Waiting for dialog to be populated.");
+ await BrowserTestUtils.waitForAttribute("value", location, expectedValue);
+ }
+ is(
+ doc.getElementById("mode").selectedItem.id,
+ "open",
+ "Should be opening the file."
+ );
+ ok(
+ !dlg.document.getElementById("openHandler").selectedItem.hidden,
+ "Should not have selected a hidden item."
+ );
+ let helperAppDialogHiddenPromise = BrowserTestUtils.windowClosed(dlg);
+ doc.getElementById("unknownContentType").cancelDialog();
+ await helperAppDialogHiddenPromise;
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_download_idn_blocklist.js b/uriloader/exthandler/tests/mochitest/browser_download_idn_blocklist.js
new file mode 100644
index 0000000000..0d49a898a0
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_download_idn_blocklist.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_HOST = "example.org";
+const TEST_FILE = "\u3002.bin";
+const TEST_URL = `http://${TEST_HOST}/${TEST_FILE}`;
+
+const { XPCShellContentUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/XPCShellContentUtils.sys.mjs"
+);
+XPCShellContentUtils.initMochitest(this);
+const server = XPCShellContentUtils.createHttpServer({
+ hosts: [TEST_HOST],
+});
+let file = getChromeDir(getResolvedURI(gTestPath));
+file.append("download.bin");
+server.registerFile(`/${encodeURIComponent(TEST_FILE)}`, file);
+
+/**
+ * Check that IDN blocklisted characters are not escaped in
+ * download file names.
+ */
+add_task(async function test_idn_blocklisted_char_not_escaped() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.always_ask_before_handling_new_types", false]],
+ });
+
+ info("Testing with " + TEST_URL);
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ registerCleanupFunction(async () => {
+ await publicList.removeFinished();
+ });
+ let downloadFinished = promiseDownloadFinished(publicList);
+ var tab = BrowserTestUtils.addTab(gBrowser, TEST_URL);
+ let dl = await downloadFinished;
+ ok(dl.succeeded, "Download should succeed.");
+ Assert.equal(
+ PathUtils.filename(dl.target.path),
+ TEST_FILE,
+ "Should not escape a download file name."
+ );
+ await IOUtils.remove(dl.target.path);
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_download_open_with_internal_handler.js b/uriloader/exthandler/tests/mochitest/browser_download_open_with_internal_handler.js
new file mode 100644
index 0000000000..e39d9ff04f
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_download_open_with_internal_handler.js
@@ -0,0 +1,818 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+requestLongerTimeout(2);
+
+const { Downloads } = ChromeUtils.importESModule(
+ "resource://gre/modules/Downloads.sys.mjs"
+);
+const { DownloadIntegration } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadIntegration.sys.mjs"
+);
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+const MimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+const HandlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+);
+
+let MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+function waitForAcceptButtonToGetEnabled(doc) {
+ let dialog = doc.querySelector("#unknownContentType");
+ let button = dialog.getButton("accept");
+ return TestUtils.waitForCondition(
+ () => !button.disabled,
+ "Wait for Accept button to get enabled"
+ );
+}
+
+async function waitForPdfJS(browser, url) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["pdfjs.eventBusDispatchToDOM", true]],
+ });
+ // Runs tests after all "load" event handlers have fired off
+ let loadPromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "documentloaded",
+ false,
+ null,
+ true
+ );
+ BrowserTestUtils.loadURIString(browser, url);
+ return loadPromise;
+}
+
+/**
+ * This test covers which choices are presented for downloaded files and how
+ * those choices are handled. Unless a pref is enabled
+ * (browser.download.always_ask_before_handling_new_types) the unknown content
+ * dialog will be skipped altogether by default when downloading.
+ * To retain coverage for the non-default scenario, each task sets `alwaysAskBeforeHandling`
+ * to true for the relevant mime-type and extensions.
+ */
+function alwaysAskForHandlingTypes(typeExtensions, ask = true) {
+ let mimeInfos = [];
+ for (let [type, ext] of Object.entries(typeExtensions)) {
+ const mimeInfo = MimeSvc.getFromTypeAndExtension(type, ext);
+ mimeInfo.alwaysAskBeforeHandling = ask;
+ if (!ask) {
+ mimeInfo.preferredAction = mimeInfo.handleInternally;
+ }
+ HandlerSvc.store(mimeInfo);
+ mimeInfos.push(mimeInfo);
+ }
+ return mimeInfos;
+}
+
+add_setup(async function () {
+ // Remove the security delay for the dialog during the test.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["security.dialog_enable_delay", 0],
+ ["browser.helperApps.showOpenOptionForPdfJS", true],
+ ["browser.helperApps.showOpenOptionForViewableInternally", true],
+ ],
+ });
+
+ // Restore handlers after the whole test has run
+ const registerRestoreHandler = function (type, ext) {
+ const mimeInfo = MimeSvc.getFromTypeAndExtension(type, ext);
+ const existed = HandlerSvc.exists(mimeInfo);
+
+ registerCleanupFunction(() => {
+ if (existed) {
+ HandlerSvc.store(mimeInfo);
+ } else {
+ HandlerSvc.remove(mimeInfo);
+ }
+ });
+ };
+ registerRestoreHandler("application/pdf", "pdf");
+ registerRestoreHandler("binary/octet-stream", "pdf");
+ registerRestoreHandler("application/unknown", "pdf");
+ registerRestoreHandler("image/webp", "webp");
+});
+
+/**
+ * Check that loading a PDF file with content-disposition: attachment
+ * shows an option to open with the internal handler, and that the
+ * internal option handler is not present when the download button
+ * is clicked from pdf.js.
+ */
+add_task(async function test_check_open_with_internal_handler() {
+ const mimeInfosToRestore = alwaysAskForHandlingTypes({
+ "application/pdf": "pdf",
+ "binary/octet-stream": "pdf",
+ });
+
+ for (let file of [
+ "file_pdf_application_pdf.pdf",
+ "file_pdf_binary_octet_stream.pdf",
+ ]) {
+ info("Testing with " + file);
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ registerCleanupFunction(async () => {
+ await publicList.removeFinished();
+ });
+ let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ let loadingTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + file,
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+ // Add an extra tab after the loading tab so we can test that
+ // pdf.js is opened in the adjacent tab and not at the end of
+ // the tab strip.
+ let extraTab = await BrowserTestUtils.addTab(gBrowser, "about:blank");
+ let dialogWindow = await dialogWindowPromise;
+ is(
+ dialogWindow.location.href,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ "Should have seen the unknown content dialogWindow."
+ );
+ let doc = dialogWindow.document;
+ let internalHandlerRadio = doc.querySelector("#handleInternally");
+
+ await waitForAcceptButtonToGetEnabled(doc);
+
+ ok(!internalHandlerRadio.hidden, "The option should be visible for PDF");
+ ok(internalHandlerRadio.selected, "The option should be selected");
+
+ let downloadFinishedPromise = promiseDownloadFinished(publicList);
+ let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+ let dialog = doc.querySelector("#unknownContentType");
+ let button = dialog.getButton("accept");
+ button.disabled = false;
+ dialog.acceptDialog();
+ info("waiting for new tab to open");
+ let newTab = await newTabPromise;
+
+ is(
+ newTab._tPos - 1,
+ loadingTab._tPos,
+ "pdf.js should be opened in an adjacent tab"
+ );
+
+ await ContentTask.spawn(newTab.linkedBrowser, null, async () => {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.readyState == "complete"
+ );
+ });
+
+ let publicDownloads = await publicList.getAll();
+ is(
+ publicDownloads.length,
+ 1,
+ "download should appear in publicDownloads list"
+ );
+
+ let download = await downloadFinishedPromise;
+
+ let subdialogPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ // Current tab has file: URI and TEST_PATH is http uri, so uri will be different
+ BrowserTestUtils.loadURIString(newTab.linkedBrowser, TEST_PATH + file);
+ let subDialogWindow = await subdialogPromise;
+ let subDoc = subDialogWindow.document;
+
+ // Prevent racing with initialization of the dialog and make sure that
+ // the final state of the dialog has the correct visibility of the internal-handler option.
+ await waitForAcceptButtonToGetEnabled(subDoc);
+ let subInternalHandlerRadio = subDoc.querySelector("#handleInternally");
+ ok(
+ !subInternalHandlerRadio.hidden,
+ "This option should be shown when the dialog is shown for another PDF"
+ );
+ // Cancel dialog
+ subDoc.querySelector("#unknownContentType").cancelDialog();
+
+ let filepickerPromise = new Promise(resolve => {
+ MockFilePicker.showCallback = function (fp) {
+ setTimeout(() => {
+ resolve(fp.defaultString);
+ }, 0);
+ return Ci.nsIFilePicker.returnCancel;
+ };
+ });
+
+ subdialogPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ await SpecialPowers.spawn(newTab.linkedBrowser, [], async () => {
+ let downloadButton;
+ await ContentTaskUtils.waitForCondition(() => {
+ downloadButton = content.document.querySelector("#download");
+ return !!downloadButton;
+ });
+ ok(downloadButton, "Download button should be present in pdf.js");
+ downloadButton.click();
+ });
+ info(
+ "Waiting for unknown content type dialog to appear from pdf.js download button click"
+ );
+ let filename = await filepickerPromise;
+ is(filename, file, "filename was set in filepicker");
+
+ // Remove the first file (can't do this sooner or the second load fails):
+ if (download?.target.exists) {
+ try {
+ info("removing " + download.target.path);
+ await IOUtils.remove(download.target.path);
+ } catch (ex) {
+ /* ignore */
+ }
+ }
+
+ BrowserTestUtils.removeTab(loadingTab);
+ BrowserTestUtils.removeTab(newTab);
+ BrowserTestUtils.removeTab(extraTab);
+
+ await publicList.removeFinished();
+ }
+ for (let mimeInfo of mimeInfosToRestore) {
+ HandlerSvc.remove(mimeInfo);
+ }
+});
+
+/**
+ * Test that choosing to open in an external application doesn't
+ * open the PDF into pdf.js
+ */
+add_task(async function test_check_open_with_external_application() {
+ const mimeInfosToRestore = alwaysAskForHandlingTypes({
+ "application/pdf": "pdf",
+ "binary/octet-stream": "pdf",
+ });
+
+ for (let file of [
+ "file_pdf_application_pdf.pdf",
+ "file_pdf_binary_octet_stream.pdf",
+ ]) {
+ info("Testing with " + file);
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ registerCleanupFunction(async () => {
+ await publicList.removeFinished();
+ });
+ let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ let loadingTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + file,
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+ let dialogWindow = await dialogWindowPromise;
+ is(
+ dialogWindow.location.href,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ "Should have seen the unknown content dialogWindow."
+ );
+
+ let oldLaunchFile = DownloadIntegration.launchFile;
+ let waitForLaunchFileCalled = new Promise(resolve => {
+ DownloadIntegration.launchFile = () => {
+ ok(true, "The file should be launched with an external application");
+ resolve();
+ };
+ });
+
+ let doc = dialogWindow.document;
+ await waitForAcceptButtonToGetEnabled(doc);
+ let dialog = doc.querySelector("#unknownContentType");
+ doc.querySelector("#open").click();
+ let button = dialog.getButton("accept");
+ button.disabled = false;
+ info("Accepting the dialog");
+ dialog.acceptDialog();
+ info("Waiting until DownloadIntegration.launchFile is called");
+ await waitForLaunchFileCalled;
+ DownloadIntegration.launchFile = oldLaunchFile;
+
+ let publicDownloads = await publicList.getAll();
+ is(
+ publicDownloads.length,
+ 1,
+ "download should appear in publicDownloads list"
+ );
+ let download = publicDownloads[0];
+ ok(
+ !download.launchWhenSucceeded,
+ "launchWhenSucceeded should be false after launchFile is called"
+ );
+
+ BrowserTestUtils.removeTab(loadingTab);
+ if (download?.target.exists) {
+ try {
+ info("removing " + download.target.path);
+ await IOUtils.remove(download.target.path);
+ } catch (ex) {
+ /* ignore */
+ }
+ }
+ await publicList.removeFinished();
+ }
+ for (let mimeInfo of mimeInfosToRestore) {
+ HandlerSvc.remove(mimeInfo);
+ }
+});
+
+/**
+ * Test that choosing to open a PDF with an external application works and
+ * then downloading the same file again and choosing Open with Firefox opens
+ * the download in Firefox.
+ */
+add_task(async function test_check_open_with_external_then_internal() {
+ // This test only runs on Windows because appPicker.xhtml is only used on Windows.
+ if (AppConstants.platform != "win") {
+ return;
+ }
+
+ // This test covers a bug that only occurs when the mimeInfo is set to Always Ask
+ const mimeInfo = MimeSvc.getFromTypeAndExtension("application/pdf", "pdf");
+ console.log(
+ "mimeInfo.preferredAction is currently:",
+ mimeInfo.preferredAction
+ );
+ mimeInfo.preferredAction = mimeInfo.alwaysAsk;
+ mimeInfo.alwaysAskBeforeHandling = true;
+ HandlerSvc.store(mimeInfo);
+
+ for (let [file, mimeType] of [
+ ["file_pdf_application_pdf.pdf", "application/pdf"],
+ ["file_pdf_binary_octet_stream.pdf", "binary/octet-stream"],
+ ["file_pdf_application_unknown.pdf", "application/unknown"],
+ ]) {
+ info("Testing with " + file);
+ let originalMimeInfo = MimeSvc.getFromTypeAndExtension(mimeType, "pdf");
+
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ registerCleanupFunction(async () => {
+ await publicList.removeFinished();
+ });
+ let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ // Open a new tab to the PDF file which will trigger the Unknown Content Type dialog
+ // and choose to open the PDF with an external application.
+ let loadingTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + file,
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+ let dialogWindow = await dialogWindowPromise;
+ is(
+ dialogWindow.location.href,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ "Should have seen the unknown content dialogWindow."
+ );
+
+ let oldLaunchFile = DownloadIntegration.launchFile;
+ let waitForLaunchFileCalled = new Promise(resolve => {
+ DownloadIntegration.launchFile = () => {
+ ok(true, "The file should be launched with an external application");
+ resolve();
+ };
+ });
+
+ let doc = dialogWindow.document;
+ await waitForAcceptButtonToGetEnabled(doc);
+ let dialog = doc.querySelector("#unknownContentType");
+ let openHandlerMenulist = doc.querySelector("#openHandler");
+ let originalDefaultHandler = openHandlerMenulist.label;
+ doc.querySelector("#open").click();
+ doc.querySelector("#openHandlerPopup").click();
+ let oldOpenDialog = dialogWindow.openDialog;
+ dialogWindow.openDialog = (location, unused2, unused3, params) => {
+ is(location, "chrome://global/content/appPicker.xhtml", "app picker");
+ let handlerApp = params.mimeInfo.possibleLocalHandlers.queryElementAt(
+ 0,
+ Ci.nsILocalHandlerApp
+ );
+ ok(handlerApp.executable, "handlerApp should be executable");
+ ok(handlerApp.executable.isFile(), "handlerApp should be a file");
+ params.handlerApp = handlerApp;
+ };
+ doc.querySelector("#choose").click();
+ dialogWindow.openDialog = oldOpenDialog;
+ await TestUtils.waitForCondition(
+ () => originalDefaultHandler != openHandlerMenulist.label,
+ "waiting for openHandler to get updated"
+ );
+ let newDefaultHandler = openHandlerMenulist.label;
+ info(`was ${originalDefaultHandler}, now ${newDefaultHandler}`);
+ let button = dialog.getButton("accept");
+ button.disabled = false;
+ info("Accepting the dialog");
+ dialog.acceptDialog();
+ info("Waiting until DownloadIntegration.launchFile is called");
+ await waitForLaunchFileCalled;
+ BrowserTestUtils.removeTab(loadingTab);
+
+ // Now, open a new tab to the PDF file which will trigger the Unknown Content Type dialog
+ // and choose to open the PDF internally. The previously used external application should be shown as
+ // the external option.
+ dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ loadingTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + file,
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+ dialogWindow = await dialogWindowPromise;
+ is(
+ dialogWindow.location.href,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ "Should have seen the unknown content dialogWindow."
+ );
+
+ DownloadIntegration.launchFile = () => {
+ ok(false, "The file should not be launched with an external application");
+ };
+
+ doc = dialogWindow.document;
+ await waitForAcceptButtonToGetEnabled(doc);
+ openHandlerMenulist = doc.querySelector("#openHandler");
+ is(openHandlerMenulist.label, newDefaultHandler, "'new' handler");
+ dialog = doc.querySelector("#unknownContentType");
+ doc.querySelector("#handleInternally").click();
+ info("Accepting the dialog");
+ button = dialog.getButton("accept");
+ button.disabled = false;
+ let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+ dialog.acceptDialog();
+
+ info("waiting for new tab to open");
+ let newTab = await newTabPromise;
+
+ await ContentTask.spawn(newTab.linkedBrowser, null, async () => {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.readyState == "complete"
+ );
+ });
+
+ is(
+ newTab.linkedBrowser.contentPrincipal.origin,
+ "resource://pdf.js",
+ "PDF should be opened with pdf.js"
+ );
+
+ BrowserTestUtils.removeTab(loadingTab);
+ BrowserTestUtils.removeTab(newTab);
+
+ // Now trigger the dialog again and select the system
+ // default option to reset the state for the next iteration of the test.
+ // Reset the state for the next iteration of the test.
+ HandlerSvc.store(originalMimeInfo);
+ DownloadIntegration.launchFile = oldLaunchFile;
+ let [download] = await publicList.getAll();
+ if (download?.target.exists) {
+ try {
+ info("removing " + download.target.path);
+ await IOUtils.remove(download.target.path);
+ } catch (ex) {
+ /* ignore */
+ }
+ }
+ await publicList.removeFinished();
+ }
+});
+
+/**
+ * Check that the "Open with internal handler" option is presented
+ * for other viewable internally types.
+ */
+add_task(
+ async function test_internal_handler_hidden_with_viewable_internally_type() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["image.webp.enabled", true]],
+ });
+
+ const mimeInfosToRestore = alwaysAskForHandlingTypes({
+ "binary/octet-stream": "xml",
+ "image/webp": "webp",
+ });
+
+ for (let [file, checkDefault] of [
+ // The default for binary/octet-stream is changed by the PDF tests above,
+ // this may change given bug 1659008, so I'm just ignoring the default for now.
+ ["file_xml_attachment_binary_octet_stream.xml", false],
+ ["file_green.webp", true],
+ ]) {
+ let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ let loadingTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + file,
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+ let dialogWindow = await dialogWindowPromise;
+ is(
+ dialogWindow.location.href,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ "Should have seen the unknown content dialogWindow."
+ );
+ let doc = dialogWindow.document;
+ let internalHandlerRadio = doc.querySelector("#handleInternally");
+
+ // Prevent racing with initialization of the dialog and make sure that
+ // the final state of the dialog has the correct visibility of the internal-handler option.
+ await waitForAcceptButtonToGetEnabled(doc);
+
+ let fileDesc = file.substring(file.lastIndexOf(".") + 1);
+
+ ok(
+ !internalHandlerRadio.hidden,
+ `The option should be visible for ${fileDesc}`
+ );
+ if (checkDefault) {
+ ok(
+ internalHandlerRadio.selected,
+ `The option should be selected for ${fileDesc}`
+ );
+ }
+
+ let dialog = doc.querySelector("#unknownContentType");
+ dialog.cancelDialog();
+ BrowserTestUtils.removeTab(loadingTab);
+ }
+ for (let mimeInfo of mimeInfosToRestore) {
+ HandlerSvc.remove(mimeInfo);
+ }
+ }
+);
+
+/**
+ * Check that the "Open with internal handler" option is not presented
+ * for non-PDF, non-viewable-internally types.
+ */
+add_task(async function test_internal_handler_hidden_with_other_type() {
+ const mimeInfosToRestore = alwaysAskForHandlingTypes({
+ "text/plain": "txt",
+ });
+
+ let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ let loadingTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + "file_txt_attachment_test.txt",
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+ let dialogWindow = await dialogWindowPromise;
+ is(
+ dialogWindow.location.href,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ "Should have seen the unknown content dialogWindow."
+ );
+ let doc = dialogWindow.document;
+
+ // Prevent racing with initialization of the dialog and make sure that
+ // the final state of the dialog has the correct visibility of the internal-handler option.
+ await waitForAcceptButtonToGetEnabled(doc);
+
+ let internalHandlerRadio = doc.querySelector("#handleInternally");
+ ok(
+ internalHandlerRadio.hidden,
+ "The option should be hidden for unknown file type"
+ );
+
+ let dialog = doc.querySelector("#unknownContentType");
+ dialog.cancelDialog();
+ BrowserTestUtils.removeTab(loadingTab);
+ for (let mimeInfo of mimeInfosToRestore) {
+ HandlerSvc.remove(mimeInfo);
+ }
+});
+
+/**
+ * Check that the "Open with internal handler" option is not presented
+ * when the feature is disabled for PDFs.
+ */
+add_task(async function test_internal_handler_hidden_with_pdf_pref_disabled() {
+ const mimeInfosToRestore = alwaysAskForHandlingTypes({
+ "application/pdf": "pdf",
+ "binary/octet-stream": "pdf",
+ });
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.helperApps.showOpenOptionForPdfJS", false]],
+ });
+ for (let file of [
+ "file_pdf_application_pdf.pdf",
+ "file_pdf_binary_octet_stream.pdf",
+ ]) {
+ let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ let loadingTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + file,
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+ let dialogWindow = await dialogWindowPromise;
+ is(
+ dialogWindow.location.href,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ "Should have seen the unknown content dialogWindow."
+ );
+ let doc = dialogWindow.document;
+
+ await waitForAcceptButtonToGetEnabled(doc);
+
+ let internalHandlerRadio = doc.querySelector("#handleInternally");
+ ok(
+ internalHandlerRadio.hidden,
+ "The option should be hidden for PDF when the pref is false"
+ );
+
+ let dialog = doc.querySelector("#unknownContentType");
+ dialog.cancelDialog();
+ BrowserTestUtils.removeTab(loadingTab);
+ }
+ for (let mimeInfo of mimeInfosToRestore) {
+ HandlerSvc.remove(mimeInfo);
+ }
+});
+
+/**
+ * Check that the "Open with internal handler" option is not presented
+ * for other viewable internally types when disabled.
+ */
+add_task(
+ async function test_internal_handler_hidden_with_viewable_internally_pref_disabled() {
+ const mimeInfosToRestore = alwaysAskForHandlingTypes({
+ "text/xml": "xml",
+ });
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.helperApps.showOpenOptionForViewableInternally", false]],
+ });
+ let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ let loadingTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + "file_xml_attachment_test.xml",
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+ let dialogWindow = await dialogWindowPromise;
+ is(
+ dialogWindow.location.href,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ "Should have seen the unknown content dialogWindow."
+ );
+ let doc = dialogWindow.document;
+
+ await waitForAcceptButtonToGetEnabled(doc);
+
+ let internalHandlerRadio = doc.querySelector("#handleInternally");
+ ok(
+ internalHandlerRadio.hidden,
+ "The option should be hidden for XML when the pref is false"
+ );
+
+ let dialog = doc.querySelector("#unknownContentType");
+ dialog.cancelDialog();
+ BrowserTestUtils.removeTab(loadingTab);
+ for (let mimeInfo of mimeInfosToRestore) {
+ HandlerSvc.remove(mimeInfo);
+ }
+ }
+);
+
+/*
+ * This test sets the action to internal. The files should open directly without asking.
+ */
+add_task(async function test_check_open_with_internal_handler_noask() {
+ const mimeInfosToRestore = alwaysAskForHandlingTypes(
+ {
+ "application/pdf": "pdf",
+ "binary/octet-stream": "pdf",
+ "application/octet-stream": "pdf",
+ },
+ false
+ );
+
+ // Build the matrix of tests to perform.
+ let matrix = {
+ alwaysOpenPDFInline: [false, true],
+ file: [
+ "file_pdf_application_pdf.pdf",
+ "file_pdf_binary_octet_stream.pdf",
+ "file_pdf_application_octet_stream.pdf",
+ ],
+ where: ["top", "popup", "frame"],
+ };
+ let tests = [{}];
+ for (let [key, values] of Object.entries(matrix)) {
+ tests = tests.flatMap(test =>
+ values.map(value => ({ [key]: value, ...test }))
+ );
+ }
+
+ for (let test of tests) {
+ info(`test case: ${JSON.stringify(test)}`);
+ let { alwaysOpenPDFInline, file, where } = test;
+
+ // These are the cases that can be opened inline. binary/octet-stream
+ // isn't handled by pdfjs.
+ let canHandleInline =
+ file == "file_pdf_application_pdf.pdf" ||
+ (file == "file_pdf_application_octet_stream.pdf" && where != "frame");
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.helperApps.showOpenOptionForPdfJS", true],
+ ["browser.helperApps.showOpenOptionForViewableInternally", true],
+ ["browser.download.open_pdf_attachments_inline", alwaysOpenPDFInline],
+ ],
+ });
+
+ async function doNavigate(browser) {
+ await SpecialPowers.spawn(
+ browser,
+ [TEST_PATH + file, where],
+ async (contentUrl, where_) => {
+ switch (where_) {
+ case "top":
+ content.location = contentUrl;
+ break;
+ case "popup":
+ content.open(contentUrl);
+ break;
+ case "frame":
+ let frame = content.document.createElement("iframe");
+ frame.setAttribute("src", contentUrl);
+ content.document.body.appendChild(frame);
+ break;
+ default:
+ ok(false, "Unknown where value");
+ break;
+ }
+ }
+ );
+ }
+
+ // If this is true, the pdf is opened directly without downloading it.
+ // Otherwise, it must first be downloaded and optionally displayed in
+ // a tab with a file url.
+ let openPDFDirectly = alwaysOpenPDFInline && canHandleInline;
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: TEST_PATH + "blank.html" },
+ async browser => {
+ let readyPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ null,
+ false,
+ !openPDFDirectly
+ );
+
+ await doNavigate(browser);
+
+ await readyPromise;
+
+ is(
+ gBrowser.selectedBrowser.currentURI.scheme,
+ openPDFDirectly ? "https" : "file",
+ "Loaded PDF uri has the correct scheme"
+ );
+
+ // intentionally don't bother checking session history without ship to
+ // keep complexity down.
+ if (Services.appinfo.sessionHistoryInParent) {
+ let shistory = browser.browsingContext.sessionHistory;
+ is(shistory.count, 1, "should a single shentry");
+ is(shistory.index, 0, "should be on the first entry");
+ let shentry = shistory.getEntryAtIndex(shistory.index);
+ is(shentry.URI.spec, TEST_PATH + "blank.html");
+ }
+
+ await SpecialPowers.spawn(
+ browser,
+ [TEST_PATH + "blank.html"],
+ async blankUrl => {
+ ok(
+ !docShell.isAttemptingToNavigate,
+ "should not still be attempting to navigate"
+ );
+ is(
+ content.location.href,
+ blankUrl,
+ "original browser hasn't navigated"
+ );
+ }
+ );
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+ );
+ }
+
+ for (let mimeInfo of mimeInfosToRestore) {
+ HandlerSvc.remove(mimeInfo);
+ }
+});
+
+add_task(async () => {
+ MockFilePicker.cleanup();
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_download_preferred_action.js b/uriloader/exthandler/tests/mochitest/browser_download_preferred_action.js
new file mode 100644
index 0000000000..5ebcc568fd
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_download_preferred_action.js
@@ -0,0 +1,296 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { DownloadIntegration } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadIntegration.sys.mjs"
+);
+const { FileTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/FileTestUtils.sys.mjs"
+);
+const gHandlerService = Cc[
+ "@mozilla.org/uriloader/handler-service;1"
+].getService(Ci.nsIHandlerService);
+const gMIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+const localHandlerAppFactory = Cc["@mozilla.org/uriloader/local-handler-app;1"];
+
+const ROOT_URL = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+const FILE_TYPES_MIME_SETTINGS = {};
+
+// File types to test
+const FILE_TYPES_TO_TEST = [
+ // application/ms-word files cannot render in the browser so
+ // handleInternally does not work for it
+ {
+ extension: "doc",
+ mimeType: "application/ms-word",
+ blockHandleInternally: true,
+ },
+ {
+ extension: "pdf",
+ mimeType: "application/pdf",
+ },
+ {
+ extension: "pdf",
+ mimeType: "application/unknown",
+ },
+ {
+ extension: "pdf",
+ mimeType: "binary/octet-stream",
+ },
+ // text/plain files automatically render in the browser unless
+ // the CD header explicitly tells the browser to download it
+ {
+ extension: "txt",
+ mimeType: "text/plain",
+ requireContentDispositionHeader: true,
+ },
+ {
+ extension: "xml",
+ mimeType: "binary/octet-stream",
+ },
+].map(file => {
+ return {
+ ...file,
+ url: `${ROOT_URL}mime_type_download.sjs?contentType=${file.mimeType}&extension=${file.extension}`,
+ };
+});
+
+// Preferred action types to apply to each downloaded file
+const PREFERRED_ACTIONS = [
+ "saveToDisk",
+ "alwaysAsk",
+ "useHelperApp",
+ "handleInternally",
+ "useSystemDefault",
+].map(property => {
+ let label = property.replace(/([A-Z])/g, " $1");
+ label = label.charAt(0).toUpperCase() + label.slice(1);
+ return {
+ id: Ci.nsIHandlerInfo[property],
+ label,
+ };
+});
+
+async function createDownloadTest(
+ downloadList,
+ localHandlerApp,
+ file,
+ action,
+ useContentDispositionHeader
+) {
+ // Skip handleInternally case for files that cannot be handled internally
+ if (
+ action.id === Ci.nsIHandlerInfo.handleInternally &&
+ file.blockHandleInternally
+ ) {
+ return;
+ }
+ let skipDownload =
+ action.id === Ci.nsIHandlerInfo.handleInternally &&
+ file.mimeType === "application/pdf";
+ // Types that require the CD header only display as handleInternally
+ // when the CD header is missing
+ if (file.requireContentDispositionHeader && !useContentDispositionHeader) {
+ if (action.id === Ci.nsIHandlerInfo.handleInternally) {
+ skipDownload = true;
+ } else {
+ return;
+ }
+ }
+ info(
+ `Testing download with mime-type ${file.mimeType} and extension ${
+ file.extension
+ }, preferred action "${action.label}," and ${
+ useContentDispositionHeader
+ ? "Content-Disposition: attachment"
+ : "no Content-Disposition"
+ } header.`
+ );
+ info("Preparing for download...");
+ // apply preferredAction settings
+ let mimeSettings = gMIMEService.getFromTypeAndExtension(
+ file.mimeType,
+ file.extension
+ );
+ mimeSettings.preferredAction = action.id;
+ mimeSettings.alwaysAskBeforeHandling =
+ action.id === Ci.nsIHandlerInfo.alwaysAsk;
+ if (action.id === Ci.nsIHandlerInfo.useHelperApp) {
+ mimeSettings.preferredApplicationHandler = localHandlerApp;
+ }
+ gHandlerService.store(mimeSettings);
+ // delayed check for files opened in a new tab, except for skipped downloads
+ let expectViewInBrowserTab =
+ action.id === Ci.nsIHandlerInfo.handleInternally && !skipDownload;
+ let viewInBrowserTabOpened = null;
+ if (expectViewInBrowserTab) {
+ viewInBrowserTabOpened = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ uri => uri.includes("file://"),
+ true
+ );
+ }
+ // delayed check for launched files
+ let expectLaunch =
+ action.id === Ci.nsIHandlerInfo.useSystemDefault ||
+ action.id === Ci.nsIHandlerInfo.useHelperApp;
+ let oldLaunchFile = DownloadIntegration.launchFile;
+ let fileLaunched = null;
+ if (expectLaunch) {
+ fileLaunched = PromiseUtils.defer();
+ DownloadIntegration.launchFile = () => {
+ ok(
+ expectLaunch,
+ `The file ${file.mimeType} should be launched with an external application.`
+ );
+ fileLaunched.resolve();
+ };
+ }
+ info(`Start download of ${file.url}`);
+ let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ let downloadFinishedPromise = skipDownload
+ ? null
+ : promiseDownloadFinished(downloadList);
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, file.url);
+ if (action.id === Ci.nsIHandlerInfo.alwaysAsk) {
+ info("Check Always Ask dialog.");
+ let dialogWindow = await dialogWindowPromise;
+ is(
+ dialogWindow.location.href,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ "Should show unknownContentType dialog for Always Ask preferred actions."
+ );
+ let doc = dialogWindow.document;
+ let dialog = doc.querySelector("#unknownContentType");
+ let acceptButton = dialog.getButton("accept");
+ acceptButton.disabled = false;
+ let saveItem = doc.querySelector("#save");
+ saveItem.disabled = false;
+ saveItem.click();
+ dialog.acceptDialog();
+ }
+ let download = null;
+ let downloadPath = null;
+ if (!skipDownload) {
+ info("Wait for download to finish...");
+ download = await downloadFinishedPromise;
+ downloadPath = download.target.path;
+ }
+ // check delayed assertions
+ if (expectLaunch) {
+ info("Wait for file to be launched in external application...");
+ await fileLaunched.promise;
+ }
+ if (expectViewInBrowserTab) {
+ info("Wait for file to be opened in new tab...");
+ let viewInBrowserTab = await viewInBrowserTabOpened;
+ ok(
+ viewInBrowserTab,
+ `The file ${file.mimeType} should be opened in a new tab.`
+ );
+ BrowserTestUtils.removeTab(viewInBrowserTab);
+ }
+ info("Checking for saved file...");
+ let saveFound = downloadPath && (await IOUtils.exists(downloadPath));
+ info("Cleaning up...");
+ if (saveFound) {
+ try {
+ info(`Deleting file ${downloadPath}...`);
+ await IOUtils.remove(downloadPath);
+ } catch (ex) {
+ info(`Error: ${ex}`);
+ }
+ }
+ info("Removing download from list...");
+ await downloadList.removeFinished();
+ info("Clearing settings...");
+ DownloadIntegration.launchFile = oldLaunchFile;
+ info("Asserting results...");
+ if (download) {
+ ok(download.succeeded, "Download should complete successfully");
+ ok(
+ !download._launchedFromPanel,
+ "Download should never be launched from panel"
+ );
+ }
+ if (skipDownload) {
+ ok(!saveFound, "Download should not be saved to disk");
+ } else {
+ ok(saveFound, "Download should be saved to disk");
+ }
+}
+
+add_task(async function test_download_preferred_action() {
+ // Prepare tests
+ for (const index in FILE_TYPES_TO_TEST) {
+ let file = FILE_TYPES_TO_TEST[index];
+ let originalMimeSettings = gMIMEService.getFromTypeAndExtension(
+ file.mimeType,
+ file.extension
+ );
+ if (gHandlerService.exists(originalMimeSettings)) {
+ FILE_TYPES_MIME_SETTINGS[index] = originalMimeSettings;
+ }
+ }
+ let downloadList = await Downloads.getList(Downloads.PUBLIC);
+ let oldLaunchFile = DownloadIntegration.launchFile;
+ registerCleanupFunction(async function () {
+ await removeAllDownloads();
+ DownloadIntegration.launchFile = oldLaunchFile;
+ Services.prefs.clearUserPref(
+ "browser.download.always_ask_before_handling_new_types"
+ );
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:home");
+ for (const index in FILE_TYPES_TO_TEST) {
+ let file = FILE_TYPES_TO_TEST[index];
+ let mimeSettings = gMIMEService.getFromTypeAndExtension(
+ file.mimeType,
+ file.extension
+ );
+ if (FILE_TYPES_MIME_SETTINGS[index]) {
+ gHandlerService.store(FILE_TYPES_MIME_SETTINGS[index]);
+ } else {
+ gHandlerService.remove(mimeSettings);
+ }
+ }
+ });
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.always_ask_before_handling_new_types", false]],
+ });
+ let launcherPath = FileTestUtils.getTempFile("app-launcher").path;
+ let localHandlerApp = localHandlerAppFactory.createInstance(
+ Ci.nsILocalHandlerApp
+ );
+ localHandlerApp.executable = new FileUtils.File(launcherPath);
+ localHandlerApp.executable.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o755);
+ // run tests
+ for (const file of FILE_TYPES_TO_TEST) {
+ // The CD header specifies the download file extension on download
+ let fileNoHeader = file;
+ let fileWithHeader = structuredClone(file);
+ fileWithHeader.url += "&withHeader";
+ for (const action of PREFERRED_ACTIONS) {
+ // Clone file objects to prevent side-effects between iterations
+ await createDownloadTest(
+ downloadList,
+ localHandlerApp,
+ structuredClone(fileWithHeader),
+ action,
+ true
+ );
+ await createDownloadTest(
+ downloadList,
+ localHandlerApp,
+ structuredClone(fileNoHeader),
+ action,
+ false
+ );
+ }
+ }
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_download_privatebrowsing.js b/uriloader/exthandler/tests/mochitest/browser_download_privatebrowsing.js
new file mode 100644
index 0000000000..8ded5e6401
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_download_privatebrowsing.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests that downloads started from a private window by clicking on a link end
+ * up in the global list of private downloads (see bug 1367581).
+ */
+
+"use strict";
+
+const { Downloads } = ChromeUtils.importESModule(
+ "resource://gre/modules/Downloads.sys.mjs"
+);
+const { DownloadPaths } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadPaths.sys.mjs"
+);
+const { FileTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/FileTestUtils.sys.mjs"
+);
+const { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+add_task(async function test_setup() {
+ // Save downloads to disk without showing the dialog.
+ let cid = MockRegistrar.register("@mozilla.org/helperapplauncherdialog;1", {
+ QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncherDialog"]),
+ show(launcher) {
+ launcher.promptForSaveDestination();
+ },
+ promptForSaveToFileAsync(launcher) {
+ // The dialog should create the empty placeholder file.
+ let file = FileTestUtils.getTempFile();
+ file.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ launcher.saveDestinationAvailable(file);
+ },
+ });
+ registerCleanupFunction(() => {
+ MockRegistrar.unregister(cid);
+ });
+});
+
+add_task(async function test_download_privatebrowsing() {
+ let privateList = await Downloads.getList(Downloads.PRIVATE);
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+
+ let win = await BrowserTestUtils.openNewBrowserWindow({ private: true });
+ try {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ `data:text/html,<a download href="data:text/plain,">download</a>`
+ );
+
+ let promiseNextPrivateDownload = new Promise(resolve => {
+ privateList.addView({
+ onDownloadAdded(download) {
+ privateList.removeView(this);
+ resolve(download);
+ },
+ });
+ });
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ content.document.querySelector("a").click();
+ });
+
+ // Wait for the download to finish so the file can be safely deleted later.
+ let download = await promiseNextPrivateDownload;
+ await download.whenSucceeded();
+
+ // Clean up after checking that there are no new public downloads either.
+ let publicDownloads = await publicList.getAll();
+ Assert.equal(publicDownloads.length, 0);
+ await privateList.removeFinished();
+ } finally {
+ await BrowserTestUtils.closeWindow(win);
+ }
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_download_skips_dialog.js b/uriloader/exthandler/tests/mochitest/browser_download_skips_dialog.js
new file mode 100644
index 0000000000..8cc9d68a07
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_download_skips_dialog.js
@@ -0,0 +1,60 @@
+const { DownloadIntegration } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadIntegration.sys.mjs"
+);
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+// New file is being downloaded and no dialogs are shown in the way.
+add_task(async function skipDialogAndDownloadFile() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.always_ask_before_handling_new_types", false],
+ ["browser.download.useDownloadDir", true],
+ ["image.webp.enabled", true],
+ ],
+ });
+
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ registerCleanupFunction(async () => {
+ await publicList.removeFinished();
+ });
+ let downloadFinishedPromise = promiseDownloadFinished(publicList);
+
+ let initialTabsCount = gBrowser.tabs.length;
+
+ let loadingTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + "file_green.webp",
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+
+ // We just open the file to be downloaded... and wait for it to be downloaded!
+ // We see no dialogs to be accepted in the process.
+ let download = await downloadFinishedPromise;
+ await BrowserTestUtils.waitForCondition(
+ () => gBrowser.tabs.length == initialTabsCount + 2
+ );
+
+ gBrowser.removeCurrentTab();
+ BrowserTestUtils.removeTab(loadingTab);
+
+ Assert.ok(
+ await IOUtils.exists(download.target.path),
+ "The file should have been downloaded."
+ );
+
+ try {
+ info("removing " + download.target.path);
+ if (Services.appinfo.OS === "WINNT") {
+ // We need to make the file writable to delete it on Windows.
+ await IOUtils.setPermissions(download.target.path, 0o600);
+ }
+ await IOUtils.remove(download.target.path);
+ } catch (ex) {
+ info("The file " + download.target.path + " is not removed, " + ex);
+ }
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_download_spam_permissions.js b/uriloader/exthandler/tests/mochitest/browser_download_spam_permissions.js
new file mode 100644
index 0000000000..0f80af5ac5
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_download_spam_permissions.js
@@ -0,0 +1,158 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const TEST_URI = "https://example.com";
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ TEST_URI
+);
+
+const AUTOMATIC_DOWNLOAD_TOPIC = "blocked-automatic-download";
+
+var MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+registerCleanupFunction(() => MockFilePicker.cleanup());
+
+let gTempDownloadDir;
+
+add_setup(async function () {
+ // Create temp directory
+ let time = new Date().getTime();
+ let tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tempDir.append(time);
+ gTempDownloadDir = tempDir;
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setComplexValue("browser.download.dir", Ci.nsIFile, tempDir);
+
+ PermissionTestUtils.add(
+ TEST_URI,
+ "automatic-download",
+ Services.perms.UNKNOWN_ACTION
+ );
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // We disable browser.download.always_ask_before_handling_new_types here
+ // since the test expects the download to be saved directly to disk and
+ // not prompted by the UnknownContentType window.
+ ["browser.download.always_ask_before_handling_new_types", false],
+ ["browser.download.enable_spam_prevention", true],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+ await IOUtils.remove(tempDir.path, { recursive: true });
+ });
+});
+
+add_task(async function check_download_spam_permissions() {
+ const INITIAL_TABS_COUNT = gBrowser.tabs.length;
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let downloadFinishedPromise = promiseDownloadFinished(
+ publicList,
+ true /* stop the download from openning */
+ );
+ let blockedDownloadsCount = 0;
+ let blockedDownloadsURI = "";
+ let automaticDownloadObserver = {
+ observe: function automatic_download_observe(aSubject, aTopic, aData) {
+ if (aTopic === AUTOMATIC_DOWNLOAD_TOPIC) {
+ blockedDownloadsCount++;
+ blockedDownloadsURI = aData;
+ }
+ },
+ };
+ Services.obs.addObserver(automaticDownloadObserver, AUTOMATIC_DOWNLOAD_TOPIC);
+
+ let newTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_PATH + "test_spammy_page.html"
+ );
+ registerCleanupFunction(async () => {
+ DownloadIntegration.downloadSpamProtection.removeDownloadSpamForWindow(
+ TEST_URI,
+ window
+ );
+ DownloadsPanel.hidePanel();
+ await publicList.removeFinished();
+ BrowserTestUtils.removeTab(newTab);
+ Services.obs.removeObserver(
+ automaticDownloadObserver,
+ AUTOMATIC_DOWNLOAD_TOPIC
+ );
+ });
+
+ let download = await downloadFinishedPromise;
+ await TestUtils.waitForCondition(
+ () => gBrowser.tabs.length == INITIAL_TABS_COUNT + 1
+ );
+ is(
+ PermissionTestUtils.testPermission(TEST_URI, "automatic-download"),
+ Services.perms.PROMPT_ACTION,
+ "The permission to prompt the user should be stored."
+ );
+
+ ok(
+ await IOUtils.exists(download.target.path),
+ "One file should be downloaded"
+ );
+
+ let aCopyFilePath = download.target.path.replace(".pdf", "(1).pdf");
+ is(
+ await IOUtils.exists(aCopyFilePath),
+ false,
+ "An other file should be blocked"
+ );
+
+ info("Will wait for blockedDownloadsCount to be >= 99");
+ await TestUtils.waitForCondition(() => blockedDownloadsCount >= 99);
+ is(blockedDownloadsCount, 99, "Browser should block 99 downloads");
+ is(
+ blockedDownloadsURI,
+ TEST_URI,
+ "The test URI should have blocked automatic downloads"
+ );
+
+ await savelink();
+});
+
+// Check to ensure that a link saved manually is not blocked.
+async function savelink() {
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let menu = document.getElementById("contentAreaContextMenu");
+ let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ BrowserTestUtils.synthesizeMouse(
+ "#image",
+ 5,
+ 5,
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ await popupShown;
+
+ await new Promise(resolve => {
+ MockFilePicker.showCallback = function (fp) {
+ resolve();
+ let file = gTempDownloadDir.clone();
+ file.append("file_with__funny_name.png");
+ MockFilePicker.setFiles([file]);
+ return Ci.nsIFilePicker.returnOK;
+ };
+ let menuitem = menu.querySelector("#context-savelink");
+ menu.activateItem(menuitem);
+ });
+
+ await promiseDownloadFinished(
+ publicList,
+ true // stop the download from openning
+ );
+}
diff --git a/uriloader/exthandler/tests/mochitest/browser_download_urlescape.js b/uriloader/exthandler/tests/mochitest/browser_download_urlescape.js
new file mode 100644
index 0000000000..a2d10f69aa
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_download_urlescape.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+var MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+registerCleanupFunction(() => MockFilePicker.cleanup());
+
+/**
+ * Check downloading files URL-escapes content-disposition
+ * information when necessary.
+ */
+add_task(async function test_check_filename_urlescape() {
+ let pendingPromise;
+ let pendingTest = "";
+ let expectedFileName = "";
+ MockFilePicker.showCallback = function (fp) {
+ info(`${pendingTest} - Filepicker shown, checking filename`);
+ is(
+ fp.defaultString,
+ expectedFileName,
+ `${pendingTest} - Should have escaped filename`
+ );
+ ok(
+ pendingPromise,
+ `${pendingTest} - Should have expected this picker open.`
+ );
+ if (pendingPromise) {
+ pendingPromise.resolve();
+ }
+ return Ci.nsIFilePicker.returnCancel;
+ };
+ function runTestFor(fileName, selector) {
+ return BrowserTestUtils.withNewTab(TEST_PATH + fileName, async browser => {
+ expectedFileName = fileName;
+ let tabLabel = gBrowser.getTabForBrowser(browser).getAttribute("label");
+ ok(
+ tabLabel.startsWith(fileName),
+ `"${tabLabel}" should have been escaped.`
+ );
+
+ pendingTest = "save browser";
+ pendingPromise = PromiseUtils.defer();
+ // First try to save the browser
+ saveBrowser(browser);
+ await pendingPromise.promise;
+
+ // Next, try the context menu:
+ pendingTest = "save from context menu";
+ pendingPromise = PromiseUtils.defer();
+ let menu = document.getElementById("contentAreaContextMenu");
+ let menuShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ BrowserTestUtils.synthesizeMouse(
+ selector,
+ 5,
+ 5,
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+ await menuShown;
+ gContextMenu.saveMedia();
+ menu.hidePopup();
+ await pendingPromise.promise;
+ pendingPromise = null;
+ });
+ }
+ await runTestFor("file_with@@funny_name.png", "img");
+ await runTestFor("file_with[funny_name.webm", "video");
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_extension_correction.js b/uriloader/exthandler/tests/mochitest/browser_extension_correction.js
new file mode 100644
index 0000000000..9d67bf7213
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_extension_correction.js
@@ -0,0 +1,222 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+let gPathsToRemove = [];
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.useDownloadDir", true]],
+ });
+ registerCleanupFunction(async () => {
+ for (let path of gPathsToRemove) {
+ // IOUtils.remove ignores non-existing files out of the box.
+ await IOUtils.remove(path);
+ }
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ await publicList.removeFinished();
+ });
+});
+
+async function testLinkWithoutExtension(type, shouldHaveExtension) {
+ info("Checking " + type);
+
+ let task = function () {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [type], mimetype => {
+ let link = content.document.createElement("a");
+ link.textContent = "Click me";
+ link.href = "data:" + mimetype + ",hello";
+ link.download = "somefile";
+ content.document.body.appendChild(link);
+ link.click();
+ });
+ };
+
+ await checkDownloadWithExtensionState(task, {
+ type,
+ shouldHaveExtension,
+ alwaysViewPDFInline: false,
+ });
+
+ if (type == "application/pdf") {
+ // For PDF, try again with the always open inline preference set
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.open_pdf_attachments_inline", true]],
+ });
+
+ await checkDownloadWithExtensionState(task, {
+ type,
+ shouldHaveExtension,
+ alwaysViewPDFInline: true,
+ });
+
+ await SpecialPowers.popPrefEnv();
+ }
+}
+
+async function checkDownloadWithExtensionState(
+ task,
+ { type, shouldHaveExtension, expectedName = null, alwaysViewPDFInline }
+) {
+ const shouldExpectDialog = Services.prefs.getBoolPref(
+ "browser.download.always_ask_before_handling_new_types",
+ false
+ );
+
+ let winPromise;
+ if (shouldExpectDialog) {
+ winPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ }
+
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let shouldCheckFilename = shouldHaveExtension || !shouldExpectDialog;
+
+ let downloadFinishedPromise = shouldCheckFilename
+ ? promiseDownloadFinished(publicList)
+ : null;
+
+ // PDF should load using the internal viewer without downloading it.
+ let waitForLoad;
+ if (
+ (!shouldExpectDialog || alwaysViewPDFInline) &&
+ type == "application/pdf"
+ ) {
+ waitForLoad = BrowserTestUtils.waitForNewTab(gBrowser);
+ }
+
+ await task();
+ await waitForLoad;
+
+ let win;
+ if (shouldExpectDialog) {
+ info("Waiting for dialog.");
+ win = await winPromise;
+ }
+
+ expectedName ??= shouldHaveExtension
+ ? "somefile." + getMIMEInfoForType(type).primaryExtension
+ : "somefile";
+
+ let closedPromise = true;
+ if (shouldExpectDialog) {
+ let actualName = win.document.getElementById("location").value;
+ closedPromise = BrowserTestUtils.windowClosed(win);
+
+ if (shouldHaveExtension) {
+ is(actualName, expectedName, `${type} should get an extension`);
+ } else {
+ is(actualName, expectedName, `${type} should not get an extension`);
+ }
+ }
+
+ if (shouldExpectDialog && shouldHaveExtension) {
+ // Then pick "save" in the dialog, if we have a dialog.
+ let dialog = win.document.getElementById("unknownContentType");
+ win.document.getElementById("save").click();
+ let button = dialog.getButton("accept");
+ button.disabled = false;
+ dialog.acceptDialog();
+ }
+
+ if (!shouldExpectDialog && type == "application/pdf") {
+ if (alwaysViewPDFInline) {
+ is(
+ gURLBar.inputField.value,
+ "data:application/pdf,hello",
+ "url is correct for " + type
+ );
+ } else {
+ ok(
+ gURLBar.inputField.value.startsWith("file://") &&
+ gURLBar.inputField.value.endsWith("somefile.pdf"),
+ "url is correct for " + type
+ );
+ }
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+
+ if (shouldExpectDialog || !alwaysViewPDFInline || type != "application/pdf") {
+ // Wait for the download if it exists (may produce null).
+ let download = await downloadFinishedPromise;
+ if (download) {
+ // Check the download's extension is correct.
+ is(
+ PathUtils.filename(download.target.path),
+ expectedName,
+ `Downloaded file should match ${expectedName}`
+ );
+ gPathsToRemove.push(download.target.path);
+ let pathToRemove = download.target.path;
+ // Avoid one file interfering with subsequent files.
+ await publicList.removeFinished();
+ await IOUtils.remove(pathToRemove);
+ } else if (win) {
+ // We just cancel out for files that would end up without a path, as we'd
+ // prompt for a filename.
+ win.close();
+ }
+ }
+
+ return closedPromise;
+}
+
+/**
+ * Check that for document types, images, videos and audio files,
+ * we enforce a useful extension.
+ */
+add_task(async function test_enforce_useful_extension() {
+ await BrowserTestUtils.withNewTab("data:text/html,", async browser => {
+ await testLinkWithoutExtension("image/png", true);
+ await testLinkWithoutExtension("audio/ogg", true);
+ await testLinkWithoutExtension("video/webm", true);
+ await testLinkWithoutExtension("application/pdf", true);
+
+ await testLinkWithoutExtension("application/x-nonsense", false);
+ await testLinkWithoutExtension("application/octet-stream", false);
+ await testLinkWithoutExtension("binary/octet-stream", false);
+ await testLinkWithoutExtension("application/x-msdownload", false);
+ });
+});
+
+/**
+ * Check that we still use URL extension info when we don't have anything else,
+ * despite bogus local info.
+ */
+add_task(async function test_broken_saved_handlerinfo_and_useless_mimetypes() {
+ let bogusType = getMIMEInfoForType("binary/octet-stream");
+ registerCleanupFunction(() => {
+ handlerSvc.remove(bogusType);
+ });
+ bogusType.setFileExtensions(["jpg"]);
+ let handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+ );
+ handlerSvc.store(bogusType);
+ let tabToClean = null;
+ let task = function () {
+ return BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + "file_as.exe?foo=bar",
+ waitForLoad: false,
+ waitForStateStop: true,
+ }).then(tab => {
+ return (tabToClean = tab);
+ });
+ };
+ await checkDownloadWithExtensionState(task, {
+ type: "binary/octet-stream",
+ shouldHaveExtension: true,
+ expectedName: "file_as.exe",
+ });
+ // Downloads should really close their tabs...
+ if (tabToClean?.isConnected) {
+ BrowserTestUtils.removeTab(tabToClean);
+ }
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_filehandling_loop.js b/uriloader/exthandler/tests/mochitest/browser_filehandling_loop.js
new file mode 100644
index 0000000000..0aabb222d9
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_filehandling_loop.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * If the user has set Firefox itself as a helper app,
+ * we should force prompting what to do, rather than ending up
+ * in an infinite loop.
+ * In an ideal world, we'd also test the case where we are the OS
+ * default handler app, but that would require test infrastructure
+ * to make ourselves the OS default (or at least fool ourselves into
+ * believing we are) which we don't have...
+ */
+add_task(async function test_helperapp() {
+ // Set up the test infrastructure:
+ const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+ const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+ );
+ let handlerInfo = mimeSvc.getFromTypeAndExtension("application/x-foo", "foo");
+ registerCleanupFunction(() => {
+ handlerSvc.remove(handlerInfo);
+ });
+ // Say we want to use a specific app:
+ handlerInfo.preferredAction = Ci.nsIHandlerInfo.useHelperApp;
+ handlerInfo.alwaysAskBeforeHandling = false;
+
+ // Say it's us:
+ let selfFile = Services.dirsvc.get("XREExeF", Ci.nsIFile);
+ // Make sure it's the .app
+ if (AppConstants.platform == "macosx") {
+ while (
+ !selfFile.leafName.endsWith(".app") &&
+ !selfFile.leafName.endsWith(".app/")
+ ) {
+ selfFile = selfFile.parent;
+ }
+ }
+ let selfHandlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ selfHandlerApp.executable = selfFile;
+ handlerInfo.possibleApplicationHandlers.appendElement(selfHandlerApp);
+ handlerInfo.preferredApplicationHandler = selfHandlerApp;
+ handlerSvc.store(handlerInfo);
+
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ // Now, do some safety stubbing. If we do end up recursing we spawn
+ // infinite tabs. We definitely don't want that. Avoid it by stubbing
+ // our external URL handling bits:
+ let oldAddTab = gBrowser.addTab;
+ registerCleanupFunction(() => (gBrowser.addTab = oldAddTab));
+ let wrongThingHappenedPromise = new Promise(resolve => {
+ gBrowser.addTab = function (aURI) {
+ ok(false, "Tried to open unexpected URL in a tab: " + aURI);
+ resolve(null);
+ // Pass a dummy object to avoid upsetting BrowserContentHandler -
+ // if it thinks opening the tab failed, it tries to open a window instead,
+ // which we can't prevent as easily, and at which point we still end up
+ // with runaway tabs.
+ return {};
+ };
+ });
+
+ let askedUserPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+
+ info("Clicking a link that should open the unknown content type dialog");
+ await SpecialPowers.spawn(browser, [], () => {
+ let link = content.document.createElement("a");
+ link.download = "foo.foo";
+ link.textContent = "Foo file";
+ link.href = "data:application/x-foo,hello";
+ content.document.body.append(link);
+ link.click();
+ });
+ let dialog = await Promise.race([
+ wrongThingHappenedPromise,
+ askedUserPromise,
+ ]);
+ ok(dialog, "Should have gotten a dialog");
+ Assert.stringContains(
+ dialog.document.location.href,
+ "unknownContentType",
+ "Should have opened correct dialog."
+ );
+
+ let closePromise = BrowserTestUtils.windowClosed(dialog);
+ dialog.close();
+ await closePromise;
+ askedUserPromise = null;
+ });
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_first_prompt_not_blocked_without_user_interaction.js b/uriloader/exthandler/tests/mochitest/browser_first_prompt_not_blocked_without_user_interaction.js
new file mode 100644
index 0000000000..db92946d44
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_first_prompt_not_blocked_without_user_interaction.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+add_task(setupMailHandler);
+
+add_task(async function test_open_without_user_interaction() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.disable_open_during_load", true],
+ ["dom.block_external_protocol_in_iframes", true],
+ ["dom.delay.block_external_protocol_in_iframes.enabled", false],
+ ],
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ let dialogWindowPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ true
+ );
+
+ BrowserTestUtils.loadURIString(
+ tab.linkedBrowser,
+ TEST_PATH + "file_external_protocol_iframe.html"
+ );
+
+ let dialog = await dialogWindowPromise;
+ ok(dialog, "Should show the dialog even without user interaction");
+
+ let dialogClosedPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ false
+ );
+
+ // Adding another iframe without user interaction should be blocked.
+ let blockedWarning = new Promise(resolve => {
+ Services.console.registerListener(function onMessage(msg) {
+ let { message, logLevel } = msg;
+ if (logLevel != Ci.nsIConsoleMessage.warn) {
+ return;
+ }
+ if (!message.includes("Iframe with external protocol was blocked")) {
+ return;
+ }
+ Services.console.unregisterListener(onMessage);
+ resolve();
+ });
+ });
+
+ info("Adding another frame without user interaction");
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ let frame = content.document.createElement("iframe");
+ frame.src = "mailto:foo@baz.com";
+ content.document.body.appendChild(frame);
+ });
+
+ await blockedWarning;
+
+ info("Removing tab to close the dialog.");
+ gBrowser.removeTab(tab);
+ await dialogClosedPromise;
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_ftp_protocol_handlers.js b/uriloader/exthandler/tests/mochitest/browser_ftp_protocol_handlers.js
new file mode 100644
index 0000000000..2234034555
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_ftp_protocol_handlers.js
@@ -0,0 +1,105 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let testURL =
+ "https://example.com/browser/" +
+ "uriloader/exthandler/tests/mochitest/FTPprotocolHandler.html";
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["security.external_protocol_requires_permission", false]],
+ });
+
+ // Load a page registering a protocol handler.
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURIString(browser, testURL);
+ await BrowserTestUtils.browserLoaded(browser, false, testURL);
+
+ // Register the protocol handler by clicking the notificationbar button.
+ let notificationValue = "Protocol Registration: ftp";
+ let getNotification = () =>
+ gBrowser.getNotificationBox().getNotificationWithValue(notificationValue);
+ await BrowserTestUtils.waitForCondition(getNotification);
+ let notification = getNotification();
+ let button = notification.buttonContainer.querySelector("button");
+ ok(button, "got registration button");
+ button.click();
+
+ // Set the new handler as default.
+ const protoSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService);
+ let protoInfo = protoSvc.getProtocolHandlerInfo("ftp");
+ ok(!protoInfo.preferredApplicationHandler, "no preferred handler is set");
+ let handlers = protoInfo.possibleApplicationHandlers;
+ is(1, handlers.length, "only one handler registered for ftp");
+ let handler = handlers.queryElementAt(0, Ci.nsIHandlerApp);
+ ok(handler instanceof Ci.nsIWebHandlerApp, "the handler is a web handler");
+ is(
+ handler.uriTemplate,
+ "https://example.com/browser/uriloader/exthandler/tests/mochitest/blank.html?uri=%s",
+ "correct url template"
+ );
+ protoInfo.preferredAction = protoInfo.useHelperApp;
+ protoInfo.preferredApplicationHandler = handler;
+ protoInfo.alwaysAskBeforeHandling = false;
+ const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+ );
+ handlerSvc.store(protoInfo);
+
+ const expectedURL =
+ "https://example.com/browser/uriloader/exthandler/tests/mochitest/blank.html?uri=ftp%3A%2F%2Fdomain.com%2Fpath";
+
+ // Middle-click a testprotocol link and check the new tab is correct
+ let link = "#link";
+
+ let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, expectedURL);
+ await BrowserTestUtils.synthesizeMouseAtCenter(link, { button: 1 }, browser);
+ let tab = await promiseTabOpened;
+ gBrowser.selectedTab = tab;
+ is(
+ gURLBar.value,
+ expectedURL,
+ "the expected URL is displayed in the location bar"
+ );
+ BrowserTestUtils.removeTab(tab);
+
+ // Shift-click the testprotocol link and check the new window.
+ let newWindowPromise = BrowserTestUtils.waitForNewWindow({
+ url: expectedURL,
+ });
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ link,
+ { shiftKey: true },
+ browser
+ );
+ let win = await newWindowPromise;
+ await BrowserTestUtils.waitForCondition(
+ () => win.gBrowser.currentURI.spec == expectedURL
+ );
+ is(
+ win.gURLBar.value,
+ expectedURL,
+ "the expected URL is displayed in the location bar"
+ );
+ await BrowserTestUtils.closeWindow(win);
+
+ // Click the testprotocol link and check the url in the current tab.
+ let loadPromise = BrowserTestUtils.browserLoaded(browser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(link, {}, browser);
+ await loadPromise;
+ await BrowserTestUtils.waitForCondition(() => gURLBar.value != testURL);
+ is(
+ gURLBar.value,
+ expectedURL,
+ "the expected URL is displayed in the location bar"
+ );
+
+ // Cleanup.
+ protoInfo.preferredApplicationHandler = null;
+ handlers.removeElementAt(0);
+ handlerSvc.store(protoInfo);
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_launched_app_save_directory.js b/uriloader/exthandler/tests/mochitest/browser_launched_app_save_directory.js
new file mode 100644
index 0000000000..993a4f162b
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_launched_app_save_directory.js
@@ -0,0 +1,114 @@
+const { DownloadIntegration } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadIntegration.sys.mjs"
+);
+
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.always_ask_before_handling_new_types", false],
+ ["image.webp.enabled", true],
+ ],
+ });
+ const allowDirectoriesVal = DownloadIntegration.allowDirectories;
+ DownloadIntegration.allowDirectories = true;
+ registerCleanupFunction(() => {
+ DownloadIntegration.allowDirectories = allowDirectoriesVal;
+ Services.prefs.clearUserPref("browser.download.dir");
+ Services.prefs.clearUserPref("browser.download.folderList");
+ });
+});
+
+async function aDownloadLaunchedWithAppIsSavedInFolder(downloadDir) {
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ registerCleanupFunction(async () => {
+ await publicList.removeFinished();
+ });
+
+ let downloadFinishedPromise = promiseDownloadFinished(publicList);
+ let initialTabsCount = gBrowser.tabs.length;
+
+ let loadingTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + "file_green.webp",
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+
+ let download = await downloadFinishedPromise;
+ await BrowserTestUtils.waitForCondition(
+ () => gBrowser.tabs.length == initialTabsCount + 2
+ );
+
+ gBrowser.removeCurrentTab();
+ BrowserTestUtils.removeTab(loadingTab);
+
+ ok(
+ download.target.path.startsWith(downloadDir),
+ "Download should be placed in default download directory: " +
+ downloadDir +
+ ", and it's located in " +
+ download.target.path
+ );
+
+ Assert.ok(
+ await IOUtils.exists(download.target.path),
+ "The file should not have been deleted."
+ );
+
+ try {
+ info("removing " + download.target.path);
+ if (Services.appinfo.OS === "WINNT") {
+ // We need to make the file writable to delete it on Windows.
+ await IOUtils.setPermissions(download.target.path, 0o600);
+ }
+ await IOUtils.remove(download.target.path);
+ } catch (ex) {
+ info("The file " + download.target.path + " is not removed, " + ex);
+ }
+}
+
+add_task(async function aDownloadLaunchedWithAppIsSavedInCustomDir() {
+ //Test the temp dir.
+ let time = new Date().getTime();
+ let tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tempDir.append(time);
+ Services.prefs.setComplexValue("browser.download.dir", Ci.nsIFile, tempDir);
+ let downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory();
+ Assert.notEqual(downloadDir, "");
+ Assert.equal(downloadDir, tempDir.path);
+ Assert.ok(await IOUtils.exists(downloadDir));
+ registerCleanupFunction(async () => {
+ await IOUtils.remove(tempDir.path, { recursive: true });
+ });
+ await aDownloadLaunchedWithAppIsSavedInFolder(downloadDir);
+});
+
+add_task(async function aDownloadLaunchedWithAppIsSavedInDownloadsDir() {
+ // Test the system downloads directory.
+ Services.prefs.setIntPref("browser.download.folderList", 1);
+ let systemDir = await DownloadIntegration.getSystemDownloadsDirectory();
+ let downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory();
+ Assert.notEqual(downloadDir, "");
+ Assert.equal(downloadDir, systemDir);
+
+ await aDownloadLaunchedWithAppIsSavedInFolder(downloadDir);
+});
+
+add_task(async function aDownloadLaunchedWithAppIsSavedInDesktopDir() {
+ // Test the desktop directory.
+ Services.prefs.setIntPref("browser.download.folderList", 0);
+ let downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory();
+ Assert.notEqual(downloadDir, "");
+ Assert.equal(downloadDir, Services.dirsvc.get("Desk", Ci.nsIFile).path);
+
+ await aDownloadLaunchedWithAppIsSavedInFolder(downloadDir);
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_local_files_no_save_without_asking.js b/uriloader/exthandler/tests/mochitest/browser_local_files_no_save_without_asking.js
new file mode 100644
index 0000000000..cfa4788a43
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_local_files_no_save_without_asking.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that loading a local PDF file
+ * prompts the user when pdfjs.disabled is set to true,
+ * and alwaysAsk is false;
+ */
+add_task(
+ async function test_check_browser_local_files_no_save_without_asking() {
+ // Get a ref to the pdf we want to open.
+ let file = getChromeDir(getResolvedURI(gTestPath));
+ file.append("file_pdf_binary_octet_stream.pdf");
+
+ await SpecialPowers.pushPrefEnv({ set: [["pdfjs.disabled", true]] });
+
+ const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+ const handlerSvc = Cc[
+ "@mozilla.org/uriloader/handler-service;1"
+ ].getService(Ci.nsIHandlerService);
+ const mimeInfo = mimeSvc.getFromTypeAndExtension("application/pdf", "pdf");
+ // This test covers a bug that only occurs when the mimeInfo is set to Always Ask = false
+ // Here we check if we ask the user what to do for local files, if the file is set to save to disk automatically;
+ // that is, we check that we prompt the user despite the user's preference.
+ mimeInfo.preferredAction = mimeInfo.saveToDisk;
+ mimeInfo.alwaysAskBeforeHandling = false;
+ handlerSvc.store(mimeInfo);
+
+ info("Testing with " + file.path);
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ registerCleanupFunction(async () => {
+ await publicList.removeFinished();
+ });
+
+ let publicDownloads = await publicList.getAll();
+ is(
+ publicDownloads.length,
+ 0,
+ "download should not appear in publicDownloads list"
+ );
+
+ let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ var loadingTab = BrowserTestUtils.addTab(gBrowser, file.path);
+
+ let dialogWindow = await dialogWindowPromise;
+
+ is(
+ dialogWindow.location.href,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ "Should have seen the unknown content dialogWindow."
+ );
+
+ let doc = dialogWindow.document;
+
+ let dialog = doc.querySelector("#unknownContentType");
+ dialog.cancelDialog();
+ BrowserTestUtils.removeTab(loadingTab);
+ }
+);
diff --git a/uriloader/exthandler/tests/mochitest/browser_local_files_open_doesnt_duplicate.js b/uriloader/exthandler/tests/mochitest/browser_local_files_open_doesnt_duplicate.js
new file mode 100644
index 0000000000..d8e7c87c10
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_local_files_open_doesnt_duplicate.js
@@ -0,0 +1,121 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+);
+let mimeInfo = mimeSvc.getFromTypeAndExtension("application/pdf", "pdf");
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({ set: [["pdfjs.disabled", true]] });
+
+ let oldAsk = mimeInfo.alwaysAskBeforeHandling;
+ let oldPreferredAction = mimeInfo.preferredAction;
+ let oldPreferredApp = mimeInfo.preferredApplicationHandler;
+ registerCleanupFunction(() => {
+ mimeInfo.preferredApplicationHandler = oldPreferredApp;
+ mimeInfo.preferredAction = oldPreferredAction;
+ mimeInfo.alwaysAskBeforeHandling = oldAsk;
+ handlerSvc.store(mimeInfo);
+ });
+
+ if (!mimeInfo.preferredApplicationHandler) {
+ let handlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ handlerApp.executable = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ handlerApp.executable.append("foopydoo.exe");
+ mimeInfo.possibleApplicationHandlers.appendElement(handlerApp);
+ mimeInfo.preferredApplicationHandler = handlerApp;
+ }
+});
+
+add_task(async function open_from_dialog() {
+ // Force PDFs to prompt:
+ mimeInfo.preferredAction = mimeInfo.useHelperApp;
+ mimeInfo.alwaysAskBeforeHandling = true;
+ handlerSvc.store(mimeInfo);
+
+ let openingPromise = TestUtils.topicObserved(
+ "test-only-opening-downloaded-file",
+ (subject, data) => {
+ subject.QueryInterface(Ci.nsISupportsPRBool);
+ // Block opening the file:
+ subject.data = false;
+ return true;
+ }
+ );
+
+ let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ let openedFile = getChromeDir(getResolvedURI(gTestPath));
+ openedFile.append("file_pdf_binary_octet_stream.pdf");
+ let expectedPath = openedFile.isSymlink()
+ ? openedFile.target
+ : openedFile.path;
+ let loadingTab = BrowserTestUtils.addTab(gBrowser, expectedPath);
+
+ let dialogWindow = await dialogWindowPromise;
+ is(
+ dialogWindow.location.href,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ "Should have seen the unknown content dialogWindow."
+ );
+
+ let doc = dialogWindow.document;
+
+ // Select the 'open' entry.
+ doc.querySelector("#open").click();
+ let dialog = doc.querySelector("#unknownContentType");
+ dialog.getButton("accept").removeAttribute("disabled");
+ dialog.acceptDialog();
+ let [, openedPath] = await openingPromise;
+ is(
+ openedPath,
+ expectedPath,
+ "Should have opened file directly (not created a copy)."
+ );
+ if (openedPath != expectedPath) {
+ await IOUtils.setPermissions(openedPath, 0o666);
+ await IOUtils.remove(openedPath);
+ }
+ BrowserTestUtils.removeTab(loadingTab);
+});
+
+add_task(async function open_directly() {
+ // Force PDFs to open immediately:
+ mimeInfo.preferredAction = mimeInfo.useHelperApp;
+ mimeInfo.alwaysAskBeforeHandling = false;
+ handlerSvc.store(mimeInfo);
+
+ let openingPromise = TestUtils.topicObserved(
+ "test-only-opening-downloaded-file",
+ (subject, data) => {
+ subject.QueryInterface(Ci.nsISupportsPRBool);
+ // Block opening the file:
+ subject.data = false;
+ return true;
+ }
+ );
+
+ let openedFile = getChromeDir(getResolvedURI(gTestPath));
+ openedFile.append("file_pdf_binary_octet_stream.pdf");
+ let expectedPath = openedFile.isSymlink()
+ ? openedFile.target
+ : openedFile.path;
+ let loadingTab = BrowserTestUtils.addTab(gBrowser, expectedPath);
+
+ let [, openedPath] = await openingPromise;
+ is(
+ openedPath,
+ expectedPath,
+ "Should have opened file directly (not created a copy)."
+ );
+ if (openedPath != expectedPath) {
+ await IOUtils.setPermissions(openedPath, 0o666);
+ await IOUtils.remove(openedPath);
+ }
+ BrowserTestUtils.removeTab(loadingTab);
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_open_internal_choice_persistence.js b/uriloader/exthandler/tests/mochitest/browser_open_internal_choice_persistence.js
new file mode 100644
index 0000000000..cbbf5ddedf
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_open_internal_choice_persistence.js
@@ -0,0 +1,310 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { Downloads } = ChromeUtils.importESModule(
+ "resource://gre/modules/Downloads.sys.mjs"
+);
+const { DownloadIntegration } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadIntegration.sys.mjs"
+);
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+const {
+ handleInternally,
+ saveToDisk,
+ useSystemDefault,
+ alwaysAsk,
+ useHelperApp,
+} = Ci.nsIHandlerInfo;
+
+function waitForAcceptButtonToGetEnabled(doc) {
+ let dialog = doc.querySelector("#unknownContentType");
+ let button = dialog.getButton("accept");
+ return TestUtils.waitForCondition(
+ () => !button.disabled,
+ "Wait for Accept button to get enabled"
+ );
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Remove the security delay for the dialog during the test.
+ ["security.dialog_enable_delay", 0],
+ ["browser.helperApps.showOpenOptionForViewableInternally", true],
+ // Make sure we don't open a file picker dialog somehow.
+ ["browser.download.useDownloadDir", true],
+ ],
+ });
+
+ // Restore handlers after the whole test has run
+ const registerRestoreHandler = function (type, ext) {
+ const mimeInfo = gMimeSvc.getFromTypeAndExtension(type, ext);
+ const existed = gHandlerSvc.exists(mimeInfo);
+ registerCleanupFunction(() => {
+ if (existed) {
+ gHandlerSvc.store(mimeInfo);
+ } else {
+ gHandlerSvc.remove(mimeInfo);
+ }
+ });
+ };
+ registerRestoreHandler("image/svg+xml", "svg");
+});
+
+function ensureMIMEState({ preferredAction, alwaysAskBeforeHandling }) {
+ const mimeInfo = gMimeSvc.getFromTypeAndExtension("image/svg+xml", "svg");
+ mimeInfo.preferredAction = preferredAction;
+ mimeInfo.alwaysAskBeforeHandling = alwaysAskBeforeHandling;
+ gHandlerSvc.store(mimeInfo);
+}
+
+function waitDelay(delay) {
+ return new Promise((resolve, reject) => {
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ window.setTimeout(resolve, delay);
+ });
+}
+
+function promisePanelOpened() {
+ if (DownloadsPanel.panel && DownloadsPanel.panel.state == "open") {
+ return Promise.resolve();
+ }
+ return BrowserTestUtils.waitForEvent(DownloadsPanel.panel, "popupshown");
+}
+
+const kTestCasesPrefEnabled = [
+ {
+ description:
+ "Pref enabled - internal handling as default should not change prefs",
+ preDialogState: {
+ preferredAction: handleInternally,
+ alwaysAskBeforeHandling: false,
+ },
+ expectTab: true,
+ expectLaunch: false,
+ expectedPreferredAction: handleInternally,
+ expectedAlwaysAskBeforeHandling: false,
+ expectUCT: false,
+ },
+ {
+ description:
+ "Pref enabled - external handling as default should not change prefs",
+ preDialogState: {
+ preferredAction: useSystemDefault,
+ alwaysAskBeforeHandling: false,
+ },
+ expectTab: false,
+ expectLaunch: true,
+ expectedPreferredAction: useSystemDefault,
+ expectedAlwaysAskBeforeHandling: false,
+ expectUCT: false,
+ },
+ {
+ description: "Pref enabled - saveToDisk as default should not change prefs",
+ preDialogState: {
+ preferredAction: saveToDisk,
+ alwaysAskBeforeHandling: false,
+ },
+ expectTab: false,
+ expectLaunch: false,
+ expectedPreferredAction: saveToDisk,
+ expectedAlwaysAskBeforeHandling: false,
+ expectUCT: false,
+ },
+ {
+ description:
+ "Pref enabled - choose internal + alwaysAsk default + checkbox should update persisted default",
+ preDialogState: {
+ preferredAction: alwaysAsk,
+ alwaysAskBeforeHandling: false,
+ },
+ dialogActions(doc) {
+ let handleItem = doc.querySelector("#handleInternally");
+ handleItem.click();
+ ok(handleItem.selected, "The 'open' option should now be selected");
+ let checkbox = doc.querySelector("#rememberChoice");
+ checkbox.checked = true;
+ checkbox.doCommand();
+ },
+ // new tab will not launch in test environment when alwaysAsk is preferredAction
+ expectTab: false,
+ expectLaunch: false,
+ expectedPreferredAction: handleInternally,
+ expectedAlwaysAskBeforeHandling: false,
+ expectUCT: true,
+ },
+ {
+ description:
+ "Pref enabled - saveToDisk with alwaysAsk default should update persisted default",
+ preDialogState: {
+ preferredAction: alwaysAsk,
+ alwaysAskBeforeHandling: false,
+ },
+ dialogActions(doc) {
+ let saveItem = doc.querySelector("#save");
+ saveItem.click();
+ ok(saveItem.selected, "The 'save' option should now be selected");
+ },
+ expectTab: false,
+ expectLaunch: false,
+ expectedPreferredAction: saveToDisk,
+ expectedAlwaysAskBeforeHandling: false,
+ expectUCT: true,
+ },
+];
+
+add_task(
+ async function test_check_saving_handler_choices_with_always_ask_before_handling_new_types_pref_enabled() {
+ SpecialPowers.pushPrefEnv({
+ set: [["browser.download.always_ask_before_handling_new_types", false]],
+ });
+
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ registerCleanupFunction(async () => {
+ await publicList.removeFinished();
+ });
+ let file = "file_image_svgxml.svg";
+
+ for (let testCase of kTestCasesPrefEnabled) {
+ info("Testing with " + file + "; " + testCase.description);
+ ensureMIMEState(testCase.preDialogState);
+ const { expectTab, expectLaunch, description, expectUCT } = testCase;
+
+ let oldLaunchFile = DownloadIntegration.launchFile;
+ let fileLaunched = PromiseUtils.defer();
+ DownloadIntegration.launchFile = () => {
+ ok(
+ expectLaunch,
+ `The file should ${
+ expectLaunch ? "" : "not "
+ }be launched with an external application - ${description}`
+ );
+ fileLaunched.resolve();
+ };
+
+ info("Load window and tabs");
+ let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ let loadingTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + file,
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+
+ // See if UCT window appears in loaded tab.
+ let dialogWindow = await Promise.race([
+ waitDelay(1000),
+ dialogWindowPromise,
+ ]);
+
+ is(
+ !!dialogWindow,
+ expectUCT,
+ `UCT window should${expectUCT ? "" : " not"} have appeared`
+ );
+
+ let download;
+
+ if (dialogWindow) {
+ is(
+ dialogWindow.location.href,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ "Unknown content dialogWindow should be loaded correctly."
+ );
+ let doc = dialogWindow.document;
+ let internalHandlerRadio = doc.querySelector("#handleInternally");
+
+ info("Waiting for accept button to get enabled");
+ await waitForAcceptButtonToGetEnabled(doc);
+
+ ok(
+ !internalHandlerRadio.hidden,
+ "The option should be visible for SVG"
+ );
+
+ info("Running UCT dialog options before downloading file");
+ await testCase.dialogActions(doc);
+
+ let dialog = doc.querySelector("#unknownContentType");
+ dialog.acceptDialog();
+
+ info("Waiting for downloads to finish");
+ let downloadFinishedPromise = promiseDownloadFinished(publicList);
+ download = await downloadFinishedPromise;
+ } else {
+ let downloadPanelPromise = promisePanelOpened();
+ await downloadPanelPromise;
+ is(
+ DownloadsPanel.isPanelShowing,
+ true,
+ "DownloadsPanel should be open"
+ );
+
+ info("Skipping UCT dialog options");
+ info("Waiting for downloads to finish");
+ // Unlike when the UCT window opens, the download immediately starts.
+ let downloadList = await publicList;
+ [download] = downloadList._downloads;
+ }
+
+ if (expectLaunch) {
+ info("Waiting for launch to finish");
+ await fileLaunched.promise;
+ }
+ DownloadIntegration.launchFile = oldLaunchFile;
+
+ is(
+ download.contentType,
+ "image/svg+xml",
+ "File contentType should be correct"
+ );
+ is(
+ download.source.url,
+ `${TEST_PATH + file}`,
+ "File name should be correct."
+ );
+ is(
+ (await publicList.getAll()).length,
+ 1,
+ "download should appear in public list"
+ );
+
+ // Check mime info:
+ const mimeInfo = gMimeSvc.getFromTypeAndExtension("image/svg+xml", "svg");
+ gHandlerSvc.fillHandlerInfo(mimeInfo, "");
+ is(
+ mimeInfo.preferredAction,
+ testCase.expectedPreferredAction,
+ "preferredAction - " + description
+ );
+ is(
+ mimeInfo.alwaysAskBeforeHandling,
+ testCase.expectedAlwaysAskBeforeHandling,
+ "alwaysAskBeforeHandling - " + description
+ );
+
+ info("Cleaning up");
+ BrowserTestUtils.removeTab(loadingTab);
+ // By default, if internal is default with pref enabled, we view the svg file in
+ // in a new tab. Close this tab in order for the test case to pass.
+ if (expectTab && testCase.preferredAction !== alwaysAsk) {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+ await publicList.removeFinished();
+ if (download?.target.exists) {
+ try {
+ await IOUtils.remove(download.target.path);
+ } catch (ex) {
+ /* ignore */
+ }
+ }
+ }
+ }
+);
diff --git a/uriloader/exthandler/tests/mochitest/browser_pdf_save_as.js b/uriloader/exthandler/tests/mochitest/browser_pdf_save_as.js
new file mode 100644
index 0000000000..a08fe342cc
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_pdf_save_as.js
@@ -0,0 +1,121 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const HandlerService = Cc[
+ "@mozilla.org/uriloader/handler-service;1"
+].getService(Ci.nsIHandlerService);
+const MIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+const { saveToDisk, alwaysAsk, handleInternally, useSystemDefault } =
+ Ci.nsIHandlerInfo;
+const MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+async function testPdfFilePicker(mimeInfo) {
+ await BrowserTestUtils.withNewTab(
+ `data:text/html,<a id="test-link" href="${TEST_PATH}/file_pdf_application_pdf.pdf">Test PDF Link</a>`,
+ async browser => {
+ let menu = document.getElementById("contentAreaContextMenu");
+ ok(menu, "Context menu exists on the page");
+
+ let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "a#test-link",
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+ await popupShown;
+ info("Context menu popup was successfully displayed");
+
+ let filePickerPromise = new Promise(resolve => {
+ MockFilePicker.showCallback = fp => {
+ ok(true, "filepicker should be visible");
+ ok(
+ fp.defaultExtension === "pdf",
+ "Default extension in filepicker should be pdf"
+ );
+ ok(
+ fp.defaultString === "file_pdf_application_pdf.pdf",
+ "Default string name in filepicker should have the correct pdf file name"
+ );
+ setTimeout(resolve, 0);
+ return Ci.nsIFilePicker.returnCancel;
+ };
+ });
+
+ let menuitem = menu.querySelector("#context-savelink");
+ menu.activateItem(menuitem);
+ await filePickerPromise;
+ }
+ );
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.always_ask_before_handling_new_types", false],
+ ["browser.download.useDownloadDir", false],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ await publicList.removeFinished();
+
+ if (DownloadsPanel.isVisible) {
+ info("Closing downloads panel");
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ DownloadsPanel.panel,
+ "popuphidden"
+ );
+ DownloadsPanel.hidePanel();
+ await hiddenPromise;
+ }
+
+ let mimeInfo = MIMEService.getFromTypeAndExtension(
+ "application/pdf",
+ "pdf"
+ );
+ let existed = HandlerService.exists(mimeInfo);
+ if (existed) {
+ HandlerService.store(mimeInfo);
+ } else {
+ HandlerService.remove(mimeInfo);
+ }
+
+ // We only want to run MockFilerPicker.cleanup after the entire test is run.
+ // Otherwise, we cannot use MockFilePicker for each preferredAction.
+ MockFilePicker.cleanup();
+ });
+});
+
+/**
+ * Tests that selecting the context menu item `Save Link As…` on a PDF link
+ * opens the file picker when always_ask_before_handling_new_types is disabled,
+ * regardless of preferredAction.
+ */
+add_task(async function test_pdf_save_as_link() {
+ let mimeInfo;
+
+ for (let preferredAction of [
+ saveToDisk,
+ alwaysAsk,
+ handleInternally,
+ useSystemDefault,
+ ]) {
+ mimeInfo = MIMEService.getFromTypeAndExtension("application/pdf", "pdf");
+ mimeInfo.alwaysAskBeforeHandling = preferredAction === alwaysAsk;
+ mimeInfo.preferredAction = preferredAction;
+ HandlerService.store(mimeInfo);
+
+ info(`Testing filepicker for preferredAction ${preferredAction}`);
+ await testPdfFilePicker(mimeInfo);
+ }
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog.js b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog.js
new file mode 100644
index 0000000000..74fa5004a5
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog.js
@@ -0,0 +1,464 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+requestLongerTimeout(2);
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+const CONTENT_HANDLING_URL =
+ "chrome://mozapps/content/handling/appChooser.xhtml";
+
+add_task(setupMailHandler);
+
+/**
+ * Check that if we open the protocol handler dialog from a subframe, we close
+ * it when closing the tab.
+ */
+add_task(async function test_closed_by_tab_closure() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_PATH + "file_nested_protocol_request.html"
+ );
+
+ // Wait for the window and then click the link.
+ let dialogWindowPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ true
+ );
+
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "a:link",
+ {},
+ tab.linkedBrowser.browsingContext.children[0]
+ );
+
+ let dialog = await dialogWindowPromise;
+
+ is(
+ dialog._frame.contentDocument.location.href,
+ CONTENT_HANDLING_URL,
+ "Dialog URL is as expected"
+ );
+ let dialogClosedPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ false
+ );
+
+ info("Removing tab to close the dialog.");
+ gBrowser.removeTab(tab);
+ await dialogClosedPromise;
+ ok(!dialog._frame.contentWindow, "The dialog should have been closed.");
+});
+
+/**
+ * Check that if we open the protocol handler dialog from a subframe, we close
+ * it when navigating the tab to a non-same-origin URL.
+ */
+add_task(async function test_closed_by_tab_navigation() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_PATH + "file_nested_protocol_request.html"
+ );
+
+ // Wait for the window and then click the link.
+ let dialogWindowPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ true
+ );
+
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "a:link",
+ {},
+ tab.linkedBrowser.browsingContext.children[0]
+ );
+ let dialog = await dialogWindowPromise;
+
+ is(
+ dialog._frame.contentDocument.location.href,
+ CONTENT_HANDLING_URL,
+ "Dialog URL is as expected"
+ );
+ let dialogClosedPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ false
+ );
+ info(
+ "Set up unload handler to ensure we don't break when the window global gets cleared"
+ );
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ content.addEventListener("unload", function () {});
+ });
+
+ info("Navigating tab to a different but same origin page.");
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, TEST_PATH);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, TEST_PATH);
+ ok(dialog._frame.contentWindow, "Dialog should stay open.");
+
+ // The use of weak references in various parts of the code means that we're
+ // susceptible to dropping crucial bits of our implementation on the floor,
+ // if they get GC'd, and then the test hangs.
+ // Do a bunch of GC/CC runs so that if we ever break, it's deterministic.
+ let numCycles = 3;
+ for (let i = 0; i < numCycles; i++) {
+ Cu.forceGC();
+ Cu.forceCC();
+ }
+
+ info("Now navigate to a cross-origin page.");
+ const CROSS_ORIGIN_TEST_PATH = TEST_PATH.replace(".com", ".org");
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, CROSS_ORIGIN_TEST_PATH);
+ let loadPromise = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ CROSS_ORIGIN_TEST_PATH
+ );
+ await dialogClosedPromise;
+ ok(!dialog._frame.contentWindow, "The dialog should have been closed.");
+
+ // Avoid errors from aborted loads by waiting for it to finish.
+ await loadPromise;
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Check that we cannot open more than one of these dialogs.
+ */
+add_task(async function test_multiple_dialogs() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_PATH + "file_nested_protocol_request.html"
+ );
+
+ // Wait for the window and then click the link.
+ let dialogWindowPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ true
+ );
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "a:link",
+ {},
+ tab.linkedBrowser.browsingContext.children[0]
+ );
+ let dialog = await dialogWindowPromise;
+
+ is(
+ dialog._frame.contentDocument.location.href,
+ CONTENT_HANDLING_URL,
+ "Dialog URL is as expected"
+ );
+
+ // Navigate the parent frame:
+ await ContentTask.spawn(tab.linkedBrowser, [], () =>
+ content.eval("location.href = 'mailto:help@example.com'")
+ );
+
+ // Wait for a few ticks:
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 100));
+ // Check we only have one dialog
+
+ let tabDialogBox = gBrowser.getTabDialogBox(tab.linkedBrowser);
+ let dialogs = tabDialogBox
+ .getTabDialogManager()
+ ._dialogs.filter(d => d._openedURL == CONTENT_HANDLING_URL);
+
+ is(dialogs.length, 1, "Should only have 1 dialog open");
+
+ // Close the dialog:
+ let dialogClosedPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ false
+ );
+ dialog.close();
+ dialog = await dialogClosedPromise;
+
+ ok(!dialog._openedURL, "The dialog should have been closed.");
+
+ // Then reopen the dialog again, to make sure we don't keep blocking:
+ dialogWindowPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ true
+ );
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "a:link",
+ {},
+ tab.linkedBrowser.browsingContext.children[0]
+ );
+ dialog = await dialogWindowPromise;
+
+ is(
+ dialog._frame.contentDocument.location.href,
+ CONTENT_HANDLING_URL,
+ "Second dialog URL is as expected"
+ );
+
+ dialogClosedPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ false
+ );
+ info("Removing tab to close the dialog.");
+ gBrowser.removeTab(tab);
+ await dialogClosedPromise;
+ ok(!dialog._frame.contentWindow, "The dialog should have been closed again.");
+});
+
+/**
+ * Check that navigating invisible frames to external-proto URLs
+ * is handled correctly.
+ */
+add_task(async function invisible_iframes() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com/"
+ );
+
+ // Ensure we notice the dialog opening:
+ let dialogWindowPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ true
+ );
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ let frame = content.document.createElement("iframe");
+ frame.style.display = "none";
+ frame.src = "mailto:help@example.com";
+ content.document.body.append(frame);
+ });
+ let dialog = await dialogWindowPromise;
+
+ is(
+ dialog._frame.contentDocument.location.href,
+ CONTENT_HANDLING_URL,
+ "Dialog opens as expected for invisible iframe"
+ );
+ // Close the dialog:
+ let dialogClosedPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ false
+ );
+ dialog.close();
+ await dialogClosedPromise;
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Check that nested iframes are handled correctly.
+ */
+add_task(async function nested_iframes() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com/"
+ );
+
+ // Ensure we notice the dialog opening:
+ let dialogWindowPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ true
+ );
+ let innerLoaded = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ true,
+ "https://example.org/"
+ );
+ info("Constructing top frame");
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ let frame = content.document.createElement("iframe");
+ frame.src = "https://example.org/"; // cross-origin frame.
+ content.document.body.prepend(frame);
+
+ content.eval(
+ `window.addEventListener("message", e => e.source.location = "mailto:help@example.com");`
+ );
+ });
+
+ await innerLoaded;
+ let parentBC = tab.linkedBrowser.browsingContext;
+
+ info("Creating innermost frame");
+ await SpecialPowers.spawn(parentBC.children[0], [], async function () {
+ let innerFrame = content.document.createElement("iframe");
+ let frameLoaded = ContentTaskUtils.waitForEvent(innerFrame, "load", true);
+ content.document.body.prepend(innerFrame);
+ await frameLoaded;
+ });
+
+ info("Posting event from innermost frame");
+ await SpecialPowers.spawn(
+ parentBC.children[0].children[0],
+ [],
+ async function () {
+ // Top browsing context needs reference to the innermost, which is cross origin.
+ content.eval("top.postMessage('hello', '*')");
+ }
+ );
+
+ let dialog = await dialogWindowPromise;
+
+ is(
+ dialog._frame.contentDocument.location.href,
+ CONTENT_HANDLING_URL,
+ "Dialog opens as expected for deeply nested cross-origin iframe"
+ );
+ // Close the dialog:
+ let dialogClosedPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ false
+ );
+ dialog.close();
+ await dialogClosedPromise;
+ gBrowser.removeTab(tab);
+});
+
+add_task(async function test_oop_iframe() {
+ const URI = `data:text/html,<div id="root"><iframe src="http://example.com/document-builder.sjs?html=<a href='mailto:help@example.com'>Mail it</a>"></iframe></div>`;
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URI);
+
+ // Wait for the window and then click the link.
+ let dialogWindowPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ true
+ );
+
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "a:link",
+ {},
+ tab.linkedBrowser.browsingContext.children[0]
+ );
+
+ let dialog = await dialogWindowPromise;
+
+ is(
+ dialog._frame.contentDocument.location.href,
+ CONTENT_HANDLING_URL,
+ "Dialog URL is as expected"
+ );
+ let dialogClosedPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ false
+ );
+
+ info("Removing tab to close the dialog.");
+ gBrowser.removeTab(tab);
+ await dialogClosedPromise;
+ ok(!dialog._frame.contentWindow, "The dialog should have been closed.");
+});
+
+/**
+ * Check that a cross-origin iframe can navigate the top frame
+ * to an external protocol.
+ */
+add_task(async function xorigin_iframe_can_navigate_top() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com/"
+ );
+
+ // Ensure we notice the dialog opening:
+ let dialogWindowPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ true
+ );
+ let innerLoaded = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ true,
+ "https://example.org/"
+ );
+ info("Constructing frame");
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ let frame = content.document.createElement("iframe");
+ frame.src = "https://example.org/"; // cross-origin frame.
+ content.document.body.prepend(frame);
+ });
+ await innerLoaded;
+
+ info("Navigating top bc from frame");
+ let parentBC = tab.linkedBrowser.browsingContext;
+ await SpecialPowers.spawn(parentBC.children[0], [], async function () {
+ content.eval("window.top.location.href = 'mailto:example@example.com';");
+ });
+
+ let dialog = await dialogWindowPromise;
+
+ is(
+ dialog._frame.contentDocument.location.href,
+ CONTENT_HANDLING_URL,
+ "Dialog opens as expected for navigating the top frame from an x-origin frame."
+ );
+ // Close the dialog:
+ let dialogClosedPromise = waitForProtocolAppChooserDialog(
+ tab.linkedBrowser,
+ false
+ );
+ dialog.close();
+ await dialogClosedPromise;
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Check that when navigating to an external protocol from an iframe in a
+ * background tab, we show the dialog in the correct tab.
+ */
+add_task(async function iframe_background_tab() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com/"
+ );
+
+ let innerLoaded = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ true,
+ "https://example.org/"
+ );
+ info("Constructing frame");
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ let frame = content.document.createElement("iframe");
+ frame.src = "https://example.org/";
+ content.document.body.prepend(frame);
+ });
+ await innerLoaded;
+
+ info("Switching to new tab");
+ let newTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.net/"
+ );
+
+ // Wait for the chooser dialog to open in the background tab. It should not
+ // open in the foreground tab which is unrelated to the external protocol
+ // navigation.
+ let dialogWindowPromise = waitForProtocolAppChooserDialog(gBrowser, true);
+
+ info("Navigating to external proto from frame in background tab");
+ let parentBC = tab.linkedBrowser.browsingContext;
+ await SpecialPowers.spawn(parentBC.children[0], [], async function () {
+ content.eval("location.href = 'mailto:example@example.com';");
+ });
+
+ // Wait for dialog to open in one of the tabs.
+ let dialog = await dialogWindowPromise;
+
+ is(
+ gBrowser.getTabDialogBox(tab.linkedBrowser)._tabDialogManager._topDialog,
+ dialog,
+ "Dialog opened in the background tab"
+ );
+
+ is(
+ dialog._frame.contentDocument.location.href,
+ CONTENT_HANDLING_URL,
+ "Opened dialog is appChooser dialog."
+ );
+
+ // Close the dialog:
+ let dialogClosedPromise = waitForProtocolAppChooserDialog(gBrowser, false);
+ dialog.close();
+ await dialogClosedPromise;
+
+ gBrowser.removeTab(tab);
+ gBrowser.removeTab(newTab);
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_external.js b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_external.js
new file mode 100644
index 0000000000..591f1afbc5
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_external.js
@@ -0,0 +1,199 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let gHandlerService = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+);
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+/**
+ * Creates dummy protocol handler
+ */
+function initTestHandlers() {
+ let handlerInfoThatAsks =
+ HandlerServiceTestUtils.getBlankHandlerInfo("local-app-test");
+
+ let appHandler = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ // This is a dir and not executable, but that's enough for here.
+ appHandler.executable = Services.dirsvc.get("XCurProcD", Ci.nsIFile);
+ handlerInfoThatAsks.possibleApplicationHandlers.appendElement(appHandler);
+ handlerInfoThatAsks.preferredApplicationHandler = appHandler;
+ handlerInfoThatAsks.preferredAction = handlerInfoThatAsks.useHelperApp;
+ handlerInfoThatAsks.alwaysAskBeforeHandling = false;
+ gHandlerService.store(handlerInfoThatAsks);
+
+ let webHandlerInfo =
+ HandlerServiceTestUtils.getBlankHandlerInfo("web+somesite");
+ let webHandler = Cc[
+ "@mozilla.org/uriloader/web-handler-app;1"
+ ].createInstance(Ci.nsIWebHandlerApp);
+ webHandler.name = "Somesite";
+ webHandler.uriTemplate = "https://example.com/handle_url?u=%s";
+ webHandlerInfo.possibleApplicationHandlers.appendElement(webHandler);
+ webHandlerInfo.preferredApplicationHandler = webHandler;
+ webHandlerInfo.preferredAction = webHandlerInfo.useHelperApp;
+ webHandlerInfo.alwaysAskBeforeHandling = false;
+ gHandlerService.store(webHandlerInfo);
+
+ registerCleanupFunction(() => {
+ gHandlerService.remove(webHandlerInfo);
+ gHandlerService.remove(handlerInfoThatAsks);
+ });
+}
+
+function makeCmdLineHelper(url) {
+ return Cu.createCommandLine(
+ ["-url", url],
+ null,
+ Ci.nsICommandLine.STATE_REMOTE_EXPLICIT
+ );
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["network.protocol-handler.prompt-from-external", true]],
+ });
+ initTestHandlers();
+});
+
+/**
+ * Check that if we get a direct request from another app / the OS to open a
+ * link, we always prompt, even if we think we know what the correct answer
+ * is. This is to avoid infinite loops in such situations where the OS and
+ * Firefox have conflicting ideas about the default handler, or where our
+ * checks with the OS don't work (Linux and/or Snap, at time of this comment).
+ */
+add_task(async function test_external_asks_anyway() {
+ let cmdLineHandler = Cc["@mozilla.org/browser/final-clh;1"].getService(
+ Ci.nsICommandLineHandler
+ );
+ let chooserDialogOpenPromise = waitForProtocolAppChooserDialog(
+ gBrowser,
+ true
+ );
+ let fakeCmdLine = makeCmdLineHelper("local-app-test:dummy");
+ cmdLineHandler.handle(fakeCmdLine);
+ let dialog = await chooserDialogOpenPromise;
+ ok(dialog, "Should have prompted.");
+
+ let dialogClosedPromise = waitForProtocolAppChooserDialog(
+ gBrowser.selectedBrowser,
+ false
+ );
+ let dialogEl = dialog._frame.contentDocument.querySelector("dialog");
+ dialogEl.cancelDialog();
+ await dialogClosedPromise;
+ // We will have opened a tab; close it.
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+/**
+ * Like the previous test, but avoid asking for web and extension handlers,
+ * as we can open those ourselves without looping.
+ */
+add_task(async function test_web_app_doesnt_ask() {
+ // Listen for a dialog open and fail the test if it does:
+ let dialogOpenListener = () => ok(false, "Shouldn't have opened a dialog!");
+ document.documentElement.addEventListener("dialogopen", dialogOpenListener);
+ registerCleanupFunction(() =>
+ document.documentElement.removeEventListener(
+ "dialogopen",
+ dialogOpenListener
+ )
+ );
+
+ // Set up a promise for a tab to open with the right URL:
+ const kURL = "web+somesite:dummy";
+ const kLoadedURL =
+ "https://example.com/handle_url?u=" + encodeURIComponent(kURL);
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, kLoadedURL);
+
+ // Load the URL:
+ let cmdLineHandler = Cc["@mozilla.org/browser/final-clh;1"].getService(
+ Ci.nsICommandLineHandler
+ );
+ let fakeCmdLine = makeCmdLineHelper(kURL);
+ cmdLineHandler.handle(fakeCmdLine);
+
+ // Check that the tab loaded. If instead the dialog opened, the dialogopen handler
+ // will fail the test.
+ let tab = await tabPromise;
+ is(
+ tab.linkedBrowser.currentURI.spec,
+ kLoadedURL,
+ "Should have opened the right URL."
+ );
+ BrowserTestUtils.removeTab(tab);
+
+ // We do this both here and in cleanup so it's easy to add tasks to this test,
+ // and so we clean up correctly if the test aborts before we get here.
+ document.documentElement.removeEventListener(
+ "dialogopen",
+ dialogOpenListener
+ );
+});
+
+add_task(async function external_https_redirect_doesnt_ask() {
+ Services.perms.addFromPrincipal(
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://example.com"
+ ),
+ "open-protocol-handler^local-app-test",
+ Services.perms.ALLOW_ACTION
+ );
+ // Listen for a dialog open and fail the test if it does:
+ let dialogOpenListener = () => ok(false, "Shouldn't have opened a dialog!");
+ document.documentElement.addEventListener("dialogopen", dialogOpenListener);
+ registerCleanupFunction(() => {
+ document.documentElement.removeEventListener(
+ "dialogopen",
+ dialogOpenListener
+ );
+ Services.perms.removeAll();
+ });
+
+ let initialTab = gBrowser.selectedTab;
+
+ gHandlerService.wrappedJSObject.mockProtocolHandler("local-app-test");
+ registerCleanupFunction(() =>
+ gHandlerService.wrappedJSObject.mockProtocolHandler()
+ );
+
+ // Set up a promise for an app to have launched with the right URI:
+ let loadPromise = TestUtils.topicObserved("mocked-protocol-handler");
+
+ // Load the URL:
+ const kURL = "local-app-test:redirect";
+ let cmdLineHandler = Cc["@mozilla.org/browser/final-clh;1"].getService(
+ Ci.nsICommandLineHandler
+ );
+ let fakeCmdLine = makeCmdLineHelper(
+ TEST_PATH + "redirect_helper.sjs?uri=" + encodeURIComponent(kURL)
+ );
+ cmdLineHandler.handle(fakeCmdLine);
+
+ // Check that the mock app was launched. If the dialog showed instead,
+ // the test will fail.
+ let [uri] = await loadPromise;
+ is(uri.spec, "local-app-test:redirect", "Should have seen correct URI.");
+ // We might have opened a blank tab, see bug 1718104 and friends.
+ if (gBrowser.selectedTab != initialTab) {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+
+ // We do this both here and in cleanup so it's easy to add tasks to this test,
+ // and so we clean up correctly if the test aborts before we get here.
+ document.documentElement.removeEventListener(
+ "dialogopen",
+ dialogOpenListener
+ );
+ gHandlerService.wrappedJSObject.mockProtocolHandler();
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_permission.js b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_permission.js
new file mode 100644
index 0000000000..7d51a9c59a
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_permission.js
@@ -0,0 +1,1348 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+
+let gHandlerService = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+);
+
+const ROOT_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ ""
+);
+
+// Testing multiple protocol / origin combinations takes long on debug.
+requestLongerTimeout(7);
+
+const DIALOG_URL_APP_CHOOSER =
+ "chrome://mozapps/content/handling/appChooser.xhtml";
+const DIALOG_URL_PERMISSION =
+ "chrome://mozapps/content/handling/permissionDialog.xhtml";
+
+const PROTOCOL_HANDLER_OPEN_PERM_KEY = "open-protocol-handler";
+const PERMISSION_KEY_DELIMITER = "^";
+
+const TEST_PROTOS = ["foo", "bar"];
+
+let testDir = getChromeDir(getResolvedURI(gTestPath));
+
+const ORIGIN1 = "https://example.com";
+const ORIGIN2 = "https://example.org";
+const ORIGIN3 = Services.io.newFileURI(testDir).spec;
+const PRINCIPAL1 =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(ORIGIN1);
+const PRINCIPAL2 =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(ORIGIN2);
+const PRINCIPAL3 =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(ORIGIN3);
+
+const NULL_PRINCIPAL_SCHEME = Services.scriptSecurityManager
+ .createNullPrincipal({})
+ .scheme.toLowerCase();
+
+/**
+ * Get the open protocol handler permission key for a given protocol scheme.
+ * @param {string} aProtocolScheme - Scheme of protocol to construct permission
+ * key with.
+ */
+function getSkipProtoDialogPermissionKey(aProtocolScheme) {
+ return (
+ PROTOCOL_HANDLER_OPEN_PERM_KEY + PERMISSION_KEY_DELIMITER + aProtocolScheme
+ );
+}
+
+/**
+ * Creates dummy web protocol handlers used for testing.
+ */
+function initTestHandlers() {
+ TEST_PROTOS.forEach(scheme => {
+ let webHandler = Cc[
+ "@mozilla.org/uriloader/web-handler-app;1"
+ ].createInstance(Ci.nsIWebHandlerApp);
+ webHandler.name = scheme + "Handler";
+ webHandler.uriTemplate = ORIGIN1 + "/?url=%s";
+
+ let handlerInfo = HandlerServiceTestUtils.getBlankHandlerInfo(scheme);
+ handlerInfo.possibleApplicationHandlers.appendElement(webHandler);
+ handlerInfo.preferredApplicationHandler = webHandler;
+ gHandlerService.store(handlerInfo);
+ });
+}
+
+/**
+ * Update whether the protocol handler dialog is shown for our test protocol +
+ * handler.
+ * @param {string} scheme - Scheme of the protocol to change the ask state for.
+ * @param {boolean} ask - true => show dialog, false => skip dialog.
+ */
+function updateAlwaysAsk(scheme, ask) {
+ let handlerInfo = HandlerServiceTestUtils.getHandlerInfo(scheme);
+ handlerInfo.alwaysAskBeforeHandling = ask;
+ gHandlerService.store(handlerInfo);
+}
+
+/**
+ * Test whether the protocol handler dialog is set to show for our
+ * test protocol + handler.
+ * @param {string} scheme - Scheme of the protocol to test the ask state for.
+ * @param {boolean} ask - true => show dialog, false => skip dialog.
+ */
+function testAlwaysAsk(scheme, ask) {
+ is(
+ HandlerServiceTestUtils.getHandlerInfo(scheme).alwaysAskBeforeHandling,
+ ask,
+ "Should have correct alwaysAsk state."
+ );
+}
+
+/**
+ * Triggers the load via a server redirect.
+ * @param {string} serverRedirect - The redirect type.
+ */
+function useServerRedirect(serverRedirect) {
+ return async (browser, scheme) => {
+ let uri = `${scheme}://test`;
+
+ let innerParams = new URLSearchParams();
+ innerParams.set("uri", uri);
+ innerParams.set("redirectType", serverRedirect);
+ let params = new URLSearchParams();
+ params.set(
+ "uri",
+ "https://example.com/" +
+ ROOT_PATH +
+ "redirect_helper.sjs?" +
+ innerParams.toString()
+ );
+ uri =
+ "https://example.org/" +
+ ROOT_PATH +
+ "redirect_helper.sjs?" +
+ params.toString();
+ BrowserTestUtils.loadURIString(browser, uri);
+ };
+}
+
+/**
+ * Triggers the load with a specific principal or the browser's current
+ * principal.
+ * @param {nsIPrincipal} [principal] - Principal to use to trigger the load.
+ */
+function useTriggeringPrincipal(principal = undefined) {
+ return async (browser, scheme) => {
+ let uri = `${scheme}://test`;
+ let triggeringPrincipal = principal ?? browser.contentPrincipal;
+
+ info("Loading uri: " + uri);
+ browser.loadURI(Services.io.newURI(uri), { triggeringPrincipal });
+ };
+}
+
+/**
+ * Navigates to a test URL with the given protocol scheme and waits for the
+ * result.
+ * @param {MozBrowser} browser - Browser to navigate.
+ * @param {string} scheme - Scheme of the test url. e.g. irc
+ * @param {Object} [options] - Test options.
+ * @param {Object} [options.permDialogOptions] - Test options for the permission
+ * dialog. If defined, we expect this dialog to be shown.
+ * @param {Object} [options.chooserDialogOptions] - Test options for the chooser
+ * dialog. If defined, we expect this dialog to be shown.
+ * @param {Function} [options.triggerLoad] - An async callback function to
+ * trigger the load. Will be passed the browser and scheme to use.
+ * @param {nsIPrincipal} [options.triggeringPrincipal] - Principal to trigger
+ * the load with. Defaults to the browsers content principal.
+ * @returns {Promise} - A promise which resolves once the test is complete.
+ */
+async function testOpenProto(
+ browser,
+ scheme,
+ {
+ permDialogOptions,
+ chooserDialogOptions,
+ triggerLoad = useTriggeringPrincipal(),
+ } = {}
+) {
+ let permDialogOpenPromise;
+ let chooserDialogOpenPromise;
+
+ if (permDialogOptions) {
+ info("Should see permission dialog");
+ permDialogOpenPromise = waitForProtocolPermissionDialog(browser, true);
+ }
+
+ if (chooserDialogOptions) {
+ info("Should see chooser dialog");
+ chooserDialogOpenPromise = waitForProtocolAppChooserDialog(browser, true);
+ }
+ await triggerLoad(browser, scheme);
+ let webHandlerLoadedPromise;
+
+ let webHandlerShouldOpen =
+ (!permDialogOptions && !chooserDialogOptions) ||
+ ((permDialogOptions?.actionConfirm || permDialogOptions?.actionChangeApp) &&
+ chooserDialogOptions?.actionConfirm);
+
+ // Register web handler load listener if we expect to trigger it.
+ if (webHandlerShouldOpen) {
+ webHandlerLoadedPromise = waitForHandlerURL(browser, scheme);
+ }
+
+ if (permDialogOpenPromise) {
+ let dialog = await permDialogOpenPromise;
+ let dialogEl = getDialogElementFromSubDialog(dialog);
+ let dialogType = getDialogType(dialog);
+
+ let {
+ hasCheckbox,
+ checkboxOrigin,
+ hasChangeApp,
+ chooserIsNext,
+ actionCheckbox,
+ actionConfirm,
+ actionChangeApp,
+ checkContents,
+ } = permDialogOptions;
+
+ if (actionChangeApp) {
+ actionConfirm = false;
+ }
+
+ let descriptionEl = dialogEl.querySelector("#description");
+ ok(
+ descriptionEl && BrowserTestUtils.is_visible(descriptionEl),
+ "Has a visible description element."
+ );
+
+ ok(
+ !descriptionEl.innerHTML.toLowerCase().includes(NULL_PRINCIPAL_SCHEME),
+ "Description does not include NullPrincipal scheme."
+ );
+
+ await testCheckbox(dialogEl, dialogType, {
+ hasCheckbox,
+ actionCheckbox,
+ checkboxOrigin,
+ });
+
+ // Check the button label depending on whether we would show the chooser
+ // dialog next or directly open the handler.
+ let acceptBtnLabel = dialogEl.getButton("accept")?.label;
+
+ if (chooserIsNext) {
+ is(
+ acceptBtnLabel,
+ "Choose Application",
+ "Accept button has choose app label"
+ );
+ } else {
+ is(acceptBtnLabel, "Open Link", "Accept button has open link label");
+ }
+
+ let changeAppLink = dialogEl.ownerDocument.getElementById("change-app");
+ if (typeof hasChangeApp == "boolean") {
+ ok(changeAppLink, "Permission dialog should have changeApp link label");
+ is(
+ !changeAppLink.hidden,
+ hasChangeApp,
+ "Permission dialog change app link label"
+ );
+ }
+
+ if (checkContents) {
+ checkContents(dialogEl);
+ }
+
+ if (actionChangeApp) {
+ let dialogClosedPromise = waitForProtocolPermissionDialog(browser, false);
+ changeAppLink.click();
+ await dialogClosedPromise;
+ } else {
+ await closeDialog(browser, dialog, actionConfirm, scheme);
+ }
+ }
+
+ if (chooserDialogOpenPromise) {
+ let dialog = await chooserDialogOpenPromise;
+ let dialogEl = getDialogElementFromSubDialog(dialog);
+ let dialogType = getDialogType(dialog);
+
+ let { hasCheckbox, actionCheckbox, actionConfirm } = chooserDialogOptions;
+
+ await testCheckbox(dialogEl, dialogType, {
+ hasCheckbox,
+ actionCheckbox,
+ });
+
+ await closeDialog(browser, dialog, actionConfirm, scheme);
+ }
+
+ if (webHandlerShouldOpen) {
+ info("Waiting for web handler to open");
+ await webHandlerLoadedPromise;
+ } else {
+ info("Web handler open canceled");
+ }
+}
+
+/**
+ * Inspects the checkbox state and interacts with it.
+ * @param {dialog} dialogEl
+ * @param {string} dialogType - String identifier of dialog type.
+ * Either "permission" or "chooser".
+ * @param {Object} options - Test Options.
+ * @param {boolean} [options.hasCheckbox] - Whether the dialog is expected to
+ * have a visible checkbox.
+ * @param {boolean} [options.hasCheckboxState] - The check state of the checkbox
+ * to test for. true = checked, false = unchecked.
+ * @param {boolean} [options.actionCheckbox] - The state to set on the checkbox.
+ * true = checked, false = unchecked.
+ */
+async function testCheckbox(
+ dialogEl,
+ dialogType,
+ { hasCheckbox, hasCheckboxState = false, actionCheckbox, checkboxOrigin }
+) {
+ let checkbox = dialogEl.ownerDocument.getElementById("remember");
+ if (typeof hasCheckbox == "boolean") {
+ is(
+ checkbox && BrowserTestUtils.is_visible(checkbox),
+ hasCheckbox,
+ "Dialog checkbox has correct visibility."
+ );
+
+ let checkboxLabel = dialogEl.ownerDocument.getElementById("remember-label");
+ is(
+ checkbox && BrowserTestUtils.is_visible(checkboxLabel),
+ hasCheckbox,
+ "Dialog checkbox label has correct visibility."
+ );
+ if (hasCheckbox) {
+ ok(
+ !checkboxLabel.innerHTML.toLowerCase().includes(NULL_PRINCIPAL_SCHEME),
+ "Dialog checkbox label does not include NullPrincipal scheme."
+ );
+ }
+ }
+
+ if (typeof hasCheckboxState == "boolean") {
+ is(checkbox.checked, hasCheckboxState, "Dialog checkbox has correct state");
+ }
+
+ if (checkboxOrigin) {
+ let doc = dialogEl.ownerDocument;
+ let hostFromLabel = doc.l10n.getAttributes(
+ doc.getElementById("remember-label")
+ ).args.host;
+ is(hostFromLabel, checkboxOrigin, "Checkbox should be for correct domain.");
+ }
+
+ if (typeof actionCheckbox == "boolean") {
+ checkbox.click();
+ }
+}
+
+/**
+ * Get the dialog element which is a child of the SubDialogs browser frame.
+ * @param {SubDialog} subDialog - Dialog to get the dialog element for.
+ */
+function getDialogElementFromSubDialog(subDialog) {
+ let dialogEl = subDialog._frame.contentDocument.querySelector("dialog");
+ ok(dialogEl, "SubDialog should have dialog element");
+ return dialogEl;
+}
+
+/**
+ * Wait for the test handler to be opened.
+ * @param {MozBrowser} browser - The browser the load should occur in.
+ * @param {string} scheme - Scheme which triggered the handler to open.
+ */
+function waitForHandlerURL(browser, scheme) {
+ return BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ url => url == `${ORIGIN1}/?url=${scheme}%3A%2F%2Ftest`
+ );
+}
+
+/**
+ * Test for open-protocol-handler permission.
+ * @param {nsIPrincipal} principal - The principal to test the permission on.
+ * @param {string} scheme - Scheme to generate permission key.
+ * @param {boolean} hasPerm - Whether we expect the princial to set the
+ * permission (true), or not (false).
+ */
+function testPermission(principal, scheme, hasPerm) {
+ let permKey = getSkipProtoDialogPermissionKey(scheme);
+ let result = Services.perms.testPermissionFromPrincipal(principal, permKey);
+ let message = `${permKey} ${hasPerm ? "is" : "is not"} set for ${
+ principal.origin
+ }.`;
+ is(result == Services.perms.ALLOW_ACTION, hasPerm, message);
+}
+
+/**
+ * Get the checkbox element of the dialog used to remember the handler choice or
+ * store the permission.
+ * @param {SubDialog} dialog - Protocol handler dialog embedded in a SubDialog.
+ * @param {string} dialogType - Type of the dialog which holds the checkbox.
+ * @returns {HTMLInputElement} - Checkbox of the dialog.
+ */
+function getDialogCheckbox(dialog, dialogType) {
+ let id;
+ if (dialogType == "permission") {
+ id = "remember-permission";
+ } else {
+ id = "remember";
+ }
+ return dialog._frame.contentDocument.getElementById(id);
+}
+
+function getDialogType(dialog) {
+ let url = dialog._frame.currentURI.spec;
+
+ if (url === DIALOG_URL_PERMISSION) {
+ return "permission";
+ }
+ if (url === DIALOG_URL_APP_CHOOSER) {
+ return "chooser";
+ }
+ throw new Error("Dialog with unexpected url");
+}
+
+/**
+ * Exit a protocol handler SubDialog and wait for it to be fully closed.
+ * @param {MozBrowser} browser - Browser element of the tab where the dialog is
+ * shown.
+ * @param {SubDialog} dialog - SubDialog object which holds the protocol handler
+ * @param {boolean} confirm - Whether to confirm (true) or cancel (false) the
+ * dialog.
+ * @param {string} scheme - The scheme of the protocol the dialog is opened for.
+ * dialog.
+ */
+async function closeDialog(browser, dialog, confirm, scheme) {
+ let dialogClosedPromise = waitForSubDialog(browser, dialog._openedURL, false);
+ let dialogEl = getDialogElementFromSubDialog(dialog);
+
+ if (confirm) {
+ if (getDialogType(dialog) == "chooser") {
+ // Select our test protocol handler
+ let listItem = dialogEl.ownerDocument.querySelector(
+ `richlistitem[name="${scheme}Handler"]`
+ );
+ listItem.click();
+ }
+
+ dialogEl.setAttribute("buttondisabledaccept", false);
+ dialogEl.acceptDialog();
+ } else {
+ dialogEl.cancelDialog();
+ }
+
+ return dialogClosedPromise;
+}
+
+registerCleanupFunction(function () {
+ // Clean up test handlers
+ TEST_PROTOS.forEach(scheme => {
+ let handlerInfo = HandlerServiceTestUtils.getHandlerInfo(scheme);
+ gHandlerService.remove(handlerInfo);
+ });
+
+ // Clear permissions
+ Services.perms.removeAll();
+});
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["security.external_protocol_requires_permission", true]],
+ });
+ initTestHandlers();
+});
+
+/**
+ * Tests that when "remember" is unchecked, we only allow the protocol to be
+ * opened once and don't store any permission.
+ */
+add_task(async function test_permission_allow_once() {
+ for (let scheme of TEST_PROTOS) {
+ await BrowserTestUtils.withNewTab(ORIGIN1, async browser => {
+ await testOpenProto(browser, scheme, {
+ permDialogOptions: {
+ hasCheckbox: true,
+ hasChangeApp: false,
+ chooserIsNext: true,
+ actionConfirm: true,
+ },
+ chooserDialogOptions: { hasCheckbox: true, actionConfirm: true },
+ });
+ });
+
+ // No permission should be set
+ testPermission(PRINCIPAL1, scheme, false);
+ testPermission(PRINCIPAL2, scheme, false);
+
+ // No preferred app should be set
+ testAlwaysAsk(scheme, true);
+
+ // If we open again we should see the permission dialog
+ await BrowserTestUtils.withNewTab(ORIGIN1, async browser => {
+ await testOpenProto(browser, scheme, {
+ permDialogOptions: {
+ hasCheckbox: true,
+ hasChangeApp: false,
+ chooserIsNext: true,
+ actionConfirm: false,
+ },
+ });
+ });
+ }
+});
+
+/**
+ * Tests that when checking the "remember" checkbox, the protocol permission
+ * is set correctly and allows the caller to skip the permission dialog in
+ * subsequent calls.
+ */
+add_task(async function test_permission_allow_persist() {
+ for (let [origin, principal] of [
+ [ORIGIN1, PRINCIPAL1],
+ [ORIGIN3, PRINCIPAL3],
+ ]) {
+ for (let scheme of TEST_PROTOS) {
+ info("Testing with origin " + origin);
+ info("testing with principal of origin " + principal.origin);
+ info("testing with protocol " + scheme);
+
+ // Set a permission for an unrelated protocol.
+ // We should still see the permission dialog.
+ Services.perms.addFromPrincipal(
+ principal,
+ getSkipProtoDialogPermissionKey("foobar"),
+ Services.perms.ALLOW_ACTION
+ );
+
+ await BrowserTestUtils.withNewTab(origin, async browser => {
+ await testOpenProto(browser, scheme, {
+ permDialogOptions: {
+ hasCheckbox: true,
+ hasChangeApp: false,
+ chooserIsNext: true,
+ actionCheckbox: true,
+ actionConfirm: true,
+ },
+ chooserDialogOptions: { hasCheckbox: true, actionConfirm: true },
+ });
+ });
+
+ // Permission should be set
+ testPermission(principal, scheme, true);
+ testPermission(PRINCIPAL2, scheme, false);
+
+ // No preferred app should be set
+ testAlwaysAsk(scheme, true);
+
+ // If we open again with the origin where we granted permission, we should
+ // directly get the chooser dialog.
+ await BrowserTestUtils.withNewTab(origin, async browser => {
+ await testOpenProto(browser, scheme, {
+ chooserDialogOptions: {
+ hasCheckbox: true,
+ actionConfirm: false,
+ },
+ });
+ });
+
+ // If we open with the other origin, we should see the permission dialog
+ await BrowserTestUtils.withNewTab(ORIGIN2, async browser => {
+ await testOpenProto(browser, scheme, {
+ permDialogOptions: {
+ hasCheckbox: true,
+ hasChangeApp: false,
+ chooserIsNext: true,
+ actionConfirm: false,
+ },
+ });
+ });
+
+ // Cleanup permissions
+ Services.perms.removeAll();
+ }
+ }
+});
+
+/**
+ * Tests that if a preferred protocol handler is set, the permission dialog
+ * shows the application name and a link which leads to the app chooser.
+ */
+add_task(async function test_permission_application_set() {
+ let scheme = TEST_PROTOS[0];
+ updateAlwaysAsk(scheme, false);
+ await BrowserTestUtils.withNewTab(ORIGIN1, async browser => {
+ await testOpenProto(browser, scheme, {
+ permDialogOptions: {
+ hasCheckbox: true,
+ hasChangeApp: true,
+ chooserIsNext: false,
+ actionChangeApp: true,
+ },
+ chooserDialogOptions: { hasCheckbox: true, actionConfirm: true },
+ });
+ });
+
+ // Cleanup
+ updateAlwaysAsk(scheme, true);
+});
+
+/**
+ * Tests that we correctly handle system principals. They should always
+ * skip the permission dialog.
+ */
+add_task(async function test_permission_system_principal() {
+ let scheme = TEST_PROTOS[0];
+ await BrowserTestUtils.withNewTab(ORIGIN1, async browser => {
+ await testOpenProto(browser, scheme, {
+ chooserDialogOptions: { hasCheckbox: true, actionConfirm: false },
+ triggerLoad: useTriggeringPrincipal(
+ Services.scriptSecurityManager.getSystemPrincipal()
+ ),
+ });
+ });
+});
+
+/**
+ * Tests that we don't show the permission dialog if the permission is disabled
+ * by pref.
+ */
+add_task(async function test_permission_disabled() {
+ let scheme = TEST_PROTOS[0];
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["security.external_protocol_requires_permission", false]],
+ });
+
+ await BrowserTestUtils.withNewTab(ORIGIN1, async browser => {
+ await testOpenProto(browser, scheme, {
+ chooserDialogOptions: { hasCheckbox: true, actionConfirm: true },
+ });
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
+
+/**
+ * Tests that we directly open the handler if permission and handler are set.
+ */
+add_task(async function test_app_and_permission_set() {
+ let scheme = TEST_PROTOS[1];
+
+ updateAlwaysAsk(scheme, false);
+ Services.perms.addFromPrincipal(
+ PRINCIPAL2,
+ getSkipProtoDialogPermissionKey(scheme),
+ Services.perms.ALLOW_ACTION
+ );
+
+ await BrowserTestUtils.withNewTab(ORIGIN2, async browser => {
+ await testOpenProto(browser, scheme);
+ });
+
+ // Cleanup
+ Services.perms.removeAll();
+ updateAlwaysAsk(scheme, true);
+});
+
+/**
+ * Tests that the alwaysAsk state is not updated if the user cancels the dialog
+ */
+add_task(async function test_change_app_checkbox_cancel() {
+ let scheme = TEST_PROTOS[0];
+
+ await BrowserTestUtils.withNewTab(ORIGIN1, async browser => {
+ await testOpenProto(browser, scheme, {
+ permDialogOptions: {
+ hasCheckbox: true,
+ chooserIsNext: true,
+ hasChangeApp: false,
+ actionConfirm: true,
+ },
+ chooserDialogOptions: {
+ hasCheckbox: true,
+ actionCheckbox: true, // Activate checkbox
+ actionConfirm: false, // Cancel dialog
+ },
+ });
+ });
+
+ // Should not have applied value from checkbox
+ testAlwaysAsk(scheme, true);
+});
+
+/**
+ * Tests that the external protocol dialogs behave correctly when a null
+ * principal is passed.
+ */
+add_task(async function test_null_principal() {
+ let scheme = TEST_PROTOS[0];
+
+ await BrowserTestUtils.withNewTab(ORIGIN1, async browser => {
+ await testOpenProto(browser, scheme, {
+ triggerLoad: () => {
+ let uri = `${scheme}://test`;
+ ContentTask.spawn(browser, { uri }, args => {
+ let frame = content.document.createElement("iframe");
+ frame.src = `data:text/html,<script>location.href="${args.uri}"</script>`;
+ content.document.body.appendChild(frame);
+ });
+ },
+ permDialogOptions: {
+ hasCheckbox: false,
+ chooserIsNext: true,
+ hasChangeApp: false,
+ actionConfirm: true,
+ },
+ chooserDialogOptions: {
+ hasCheckbox: true,
+ actionConfirm: false, // Cancel dialog
+ },
+ });
+ });
+});
+
+/**
+ * Tests that the external protocol dialogs behave correctly when no principal
+ * is passed.
+ */
+add_task(async function test_no_principal() {
+ let scheme = TEST_PROTOS[1];
+
+ await BrowserTestUtils.withNewTab(ORIGIN1, async browser => {
+ await testOpenProto(browser, scheme, {
+ triggerLoad: () => {
+ let uri = `${scheme}://test`;
+
+ let contentDispatchChooser = Cc[
+ "@mozilla.org/content-dispatch-chooser;1"
+ ].createInstance(Ci.nsIContentDispatchChooser);
+
+ let handler = HandlerServiceTestUtils.getHandlerInfo(scheme);
+
+ contentDispatchChooser.handleURI(
+ handler,
+ Services.io.newURI(uri),
+ null,
+ browser.browsingContext
+ );
+ },
+ permDialogOptions: {
+ hasCheckbox: false,
+ chooserIsNext: true,
+ hasChangeApp: false,
+ actionConfirm: true,
+ },
+ chooserDialogOptions: {
+ hasCheckbox: true,
+ actionConfirm: false, // Cancel dialog
+ },
+ });
+ });
+});
+
+/**
+ * Tests that if a URI scheme has a non-standard protocol, an OS default exists,
+ * and the user hasn't selected an alternative only the permission dialog is shown.
+ */
+add_task(async function test_non_standard_protocol() {
+ let scheme = null;
+ // TODO add a scheme for Windows 10 or greater once support is added (see bug 1764599).
+ if (AppConstants.platform == "macosx") {
+ scheme = "itunes";
+ } else {
+ info(
+ "Skipping this test since there isn't a suitable default protocol on this platform"
+ );
+ return;
+ }
+
+ await BrowserTestUtils.withNewTab(ORIGIN1, async browser => {
+ await testOpenProto(browser, scheme, {
+ permDialogOptions: {
+ hasCheckbox: true,
+ hasChangeApp: true,
+ chooserIsNext: false,
+ actionChangeApp: false,
+ },
+ });
+ });
+});
+
+/**
+ * Tests that we show the permission dialog for extension content scripts.
+ */
+add_task(async function test_extension_content_script_permission() {
+ let scheme = TEST_PROTOS[0];
+ await BrowserTestUtils.withNewTab(ORIGIN1, async browser => {
+ let testExtension;
+
+ await testOpenProto(browser, scheme, {
+ triggerLoad: async () => {
+ let uri = `${scheme}://test`;
+
+ const EXTENSION_DATA = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: [browser.currentURI.spec],
+ js: ["navigate.js"],
+ },
+ ],
+ browser_specific_settings: {
+ gecko: { id: "allowed@mochi.test" },
+ },
+ },
+ files: {
+ "navigate.js": `window.location.href = "${uri}";`,
+ },
+ useAddonManager: "permanent",
+ };
+
+ testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
+ await testExtension.startup();
+ },
+ permDialogOptions: {
+ hasCheckbox: true,
+ chooserIsNext: true,
+ hasChangeApp: false,
+ actionCheckbox: true,
+ actionConfirm: true,
+ checkContents: dialogEl => {
+ let description = dialogEl.querySelector("#description");
+ let { id, args } =
+ description.ownerDocument.l10n.getAttributes(description);
+ is(
+ id,
+ "permission-dialog-description-extension",
+ "Should be using the correct string."
+ );
+ is(
+ args.extension,
+ "Generated extension",
+ "Should have the correct extension name."
+ );
+ },
+ },
+ chooserDialogOptions: {
+ hasCheckbox: true,
+ actionConfirm: false, // Cancel dialog
+ },
+ });
+
+ let extensionPrincipal =
+ Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(`moz-extension://${testExtension.uuid}/`),
+ {}
+ );
+ let extensionPrivatePrincipal =
+ Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(`moz-extension://${testExtension.uuid}/`),
+ { privateBrowsingId: 1 }
+ );
+
+ let key = getSkipProtoDialogPermissionKey(scheme);
+ is(
+ Services.perms.testPermissionFromPrincipal(extensionPrincipal, key),
+ Services.perms.ALLOW_ACTION,
+ "Should have permanently allowed the extension"
+ );
+ is(
+ Services.perms.testPermissionFromPrincipal(
+ extensionPrivatePrincipal,
+ key
+ ),
+ Services.perms.UNKNOWN_ACTION,
+ "Should not have changed the private principal permission"
+ );
+ is(
+ Services.perms.testPermissionFromPrincipal(PRINCIPAL1, key),
+ Services.perms.UNKNOWN_ACTION,
+ "Should not have allowed the page"
+ );
+
+ await testExtension.unload();
+
+ is(
+ Services.perms.testPermissionFromPrincipal(extensionPrincipal, key),
+ Services.perms.UNKNOWN_ACTION,
+ "Should have cleared the extension's normal principal permission"
+ );
+ is(
+ Services.perms.testPermissionFromPrincipal(
+ extensionPrivatePrincipal,
+ key
+ ),
+ Services.perms.UNKNOWN_ACTION,
+ "Should have cleared the private browsing principal"
+ );
+ });
+});
+
+/**
+ * Tests that we show the permission dialog for extension content scripts.
+ */
+add_task(async function test_extension_private_content_script_permission() {
+ let scheme = TEST_PROTOS[0];
+ let win = await BrowserTestUtils.openNewBrowserWindow({ private: true });
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser: win.gBrowser, url: ORIGIN1 },
+ async browser => {
+ let testExtension;
+
+ await testOpenProto(browser, scheme, {
+ triggerLoad: async () => {
+ let uri = `${scheme}://test`;
+
+ const EXTENSION_DATA = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: [browser.currentURI.spec],
+ js: ["navigate.js"],
+ },
+ ],
+ browser_specific_settings: {
+ gecko: { id: "allowed@mochi.test" },
+ },
+ },
+ files: {
+ "navigate.js": `window.location.href = "${uri}";`,
+ },
+ useAddonManager: "permanent",
+ };
+
+ testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
+ await testExtension.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();
+ },
+ permDialogOptions: {
+ hasCheckbox: true,
+ chooserIsNext: true,
+ hasChangeApp: false,
+ actionCheckbox: true,
+ actionConfirm: true,
+ checkContents: dialogEl => {
+ let description = dialogEl.querySelector("#description");
+ let { id, args } =
+ description.ownerDocument.l10n.getAttributes(description);
+ is(
+ id,
+ "permission-dialog-description-extension",
+ "Should be using the correct string."
+ );
+ is(
+ args.extension,
+ "Generated extension",
+ "Should have the correct extension name."
+ );
+ },
+ },
+ chooserDialogOptions: {
+ hasCheckbox: true,
+ actionConfirm: false, // Cancel dialog
+ },
+ });
+
+ let extensionPrincipal =
+ Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(`moz-extension://${testExtension.uuid}/`),
+ {}
+ );
+ let extensionPrivatePrincipal =
+ Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(`moz-extension://${testExtension.uuid}/`),
+ { privateBrowsingId: 1 }
+ );
+
+ let key = getSkipProtoDialogPermissionKey(scheme);
+ is(
+ Services.perms.testPermissionFromPrincipal(extensionPrincipal, key),
+ Services.perms.UNKNOWN_ACTION,
+ "Should not have changed the extension's normal principal permission"
+ );
+ is(
+ Services.perms.testPermissionFromPrincipal(
+ extensionPrivatePrincipal,
+ key
+ ),
+ Services.perms.ALLOW_ACTION,
+ "Should have allowed the private browsing principal"
+ );
+ is(
+ Services.perms.testPermissionFromPrincipal(PRINCIPAL1, key),
+ Services.perms.UNKNOWN_ACTION,
+ "Should not have allowed the page"
+ );
+
+ await testExtension.unload();
+
+ is(
+ Services.perms.testPermissionFromPrincipal(extensionPrincipal, key),
+ Services.perms.UNKNOWN_ACTION,
+ "Should have cleared the extension's normal principal permission"
+ );
+ is(
+ Services.perms.testPermissionFromPrincipal(
+ extensionPrivatePrincipal,
+ key
+ ),
+ Services.perms.UNKNOWN_ACTION,
+ "Should have cleared the private browsing principal"
+ );
+ }
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests that we do not show the permission dialog for extension content scripts
+ * when the page already has permission.
+ */
+add_task(async function test_extension_allowed_content() {
+ let scheme = TEST_PROTOS[0];
+ await BrowserTestUtils.withNewTab(ORIGIN1, async browser => {
+ let testExtension;
+
+ let key = getSkipProtoDialogPermissionKey(scheme);
+ Services.perms.addFromPrincipal(
+ PRINCIPAL1,
+ key,
+ Services.perms.ALLOW_ACTION,
+ Services.perms.EXPIRE_NEVER
+ );
+
+ await testOpenProto(browser, scheme, {
+ triggerLoad: async () => {
+ let uri = `${scheme}://test`;
+
+ const EXTENSION_DATA = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: [browser.currentURI.spec],
+ js: ["navigate.js"],
+ },
+ ],
+ },
+ files: {
+ "navigate.js": `window.location.href = "${uri}";`,
+ },
+ };
+
+ testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
+ await testExtension.startup();
+ },
+ chooserDialogOptions: {
+ hasCheckbox: true,
+ actionConfirm: false, // Cancel dialog
+ },
+ });
+
+ let extensionPrincipal =
+ Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(`moz-extension://${testExtension.uuid}/`),
+ {}
+ );
+
+ is(
+ Services.perms.testPermissionFromPrincipal(extensionPrincipal, key),
+ Services.perms.UNKNOWN_ACTION,
+ "Should not have permanently allowed the extension"
+ );
+
+ await testExtension.unload();
+ Services.perms.removeFromPrincipal(PRINCIPAL1, key);
+ });
+});
+
+/**
+ * Tests that we do not show the permission dialog for extension content scripts
+ * when the extension already has permission.
+ */
+add_task(async function test_extension_allowed_extension() {
+ let scheme = TEST_PROTOS[0];
+ await BrowserTestUtils.withNewTab(ORIGIN1, async browser => {
+ let testExtension;
+
+ let key = getSkipProtoDialogPermissionKey(scheme);
+
+ await testOpenProto(browser, scheme, {
+ triggerLoad: async () => {
+ const EXTENSION_DATA = {
+ manifest: {
+ permissions: [`${ORIGIN1}/*`],
+ },
+ background() {
+ browser.test.onMessage.addListener(async (msg, uri) => {
+ switch (msg) {
+ case "engage":
+ browser.tabs.executeScript({
+ code: `window.location.href = "${uri}";`,
+ });
+ break;
+ default:
+ browser.test.fail(`Unexpected message received: ${msg}`);
+ }
+ });
+ },
+ };
+
+ testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
+ await testExtension.startup();
+
+ let extensionPrincipal =
+ Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(`moz-extension://${testExtension.uuid}/`),
+ {}
+ );
+ Services.perms.addFromPrincipal(
+ extensionPrincipal,
+ key,
+ Services.perms.ALLOW_ACTION,
+ Services.perms.EXPIRE_NEVER
+ );
+
+ testExtension.sendMessage("engage", `${scheme}://test`);
+ },
+ chooserDialogOptions: {
+ hasCheckbox: true,
+ actionConfirm: false, // Cancel dialog
+ },
+ });
+
+ await testExtension.unload();
+ Services.perms.removeFromPrincipal(PRINCIPAL1, key);
+ });
+});
+
+/**
+ * Tests that we show the permission dialog for extensions directly opening a
+ * protocol.
+ */
+add_task(async function test_extension_principal() {
+ let scheme = TEST_PROTOS[0];
+ await BrowserTestUtils.withNewTab(ORIGIN1, async browser => {
+ let testExtension;
+
+ await testOpenProto(browser, scheme, {
+ triggerLoad: async () => {
+ const EXTENSION_DATA = {
+ background() {
+ browser.test.onMessage.addListener(async (msg, url) => {
+ switch (msg) {
+ case "engage":
+ browser.tabs.update({
+ url,
+ });
+ break;
+ default:
+ browser.test.fail(`Unexpected message received: ${msg}`);
+ }
+ });
+ },
+ };
+
+ testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
+ await testExtension.startup();
+ testExtension.sendMessage("engage", `${scheme}://test`);
+ },
+ permDialogOptions: {
+ hasCheckbox: true,
+ chooserIsNext: true,
+ hasChangeApp: false,
+ actionCheckbox: true,
+ actionConfirm: true,
+ checkContents: dialogEl => {
+ let description = dialogEl.querySelector("#description");
+ let { id, args } =
+ description.ownerDocument.l10n.getAttributes(description);
+ is(
+ id,
+ "permission-dialog-description-extension",
+ "Should be using the correct string."
+ );
+ is(
+ args.extension,
+ "Generated extension",
+ "Should have the correct extension name."
+ );
+ },
+ },
+ chooserDialogOptions: {
+ hasCheckbox: true,
+ actionConfirm: false, // Cancel dialog
+ },
+ });
+
+ let extensionPrincipal =
+ Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(`moz-extension://${testExtension.uuid}/`),
+ {}
+ );
+
+ let key = getSkipProtoDialogPermissionKey(scheme);
+ is(
+ Services.perms.testPermissionFromPrincipal(extensionPrincipal, key),
+ Services.perms.ALLOW_ACTION,
+ "Should have permanently allowed the extension"
+ );
+ is(
+ Services.perms.testPermissionFromPrincipal(PRINCIPAL1, key),
+ Services.perms.UNKNOWN_ACTION,
+ "Should not have allowed the page"
+ );
+
+ await testExtension.unload();
+ });
+});
+
+/**
+ * Test that we use the redirect principal for the dialog when applicable.
+ */
+add_task(async function test_redirect_principal() {
+ let scheme = TEST_PROTOS[0];
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ await testOpenProto(browser, scheme, {
+ triggerLoad: useServerRedirect("location"),
+ permDialogOptions: {
+ checkboxOrigin: ORIGIN1,
+ chooserIsNext: true,
+ hasCheckbox: true,
+ actionConfirm: false, // Cancel dialog
+ },
+ });
+ });
+});
+
+/**
+ * Test that we use the redirect principal for the dialog for refresh headers.
+ */
+add_task(async function test_redirect_principal() {
+ let scheme = TEST_PROTOS[0];
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ await testOpenProto(browser, scheme, {
+ triggerLoad: useServerRedirect("refresh"),
+ permDialogOptions: {
+ checkboxOrigin: ORIGIN1,
+ chooserIsNext: true,
+ hasCheckbox: true,
+ actionConfirm: false, // Cancel dialog
+ },
+ });
+ });
+});
+
+/**
+ * Test that we use the redirect principal for the dialog for meta refreshes.
+ */
+add_task(async function test_redirect_principal() {
+ let scheme = TEST_PROTOS[0];
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ await testOpenProto(browser, scheme, {
+ triggerLoad: useServerRedirect("meta-refresh"),
+ permDialogOptions: {
+ checkboxOrigin: ORIGIN1,
+ chooserIsNext: true,
+ hasCheckbox: true,
+ actionConfirm: false, // Cancel dialog
+ },
+ });
+ });
+});
+
+/**
+ * Test that we use the redirect principal for the dialog for JS redirects.
+ */
+add_task(async function test_redirect_principal_js() {
+ let scheme = TEST_PROTOS[0];
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ await testOpenProto(browser, scheme, {
+ triggerLoad: () => {
+ let uri = `${scheme}://test`;
+
+ let innerParams = new URLSearchParams();
+ innerParams.set("uri", uri);
+ let params = new URLSearchParams();
+ params.set(
+ "uri",
+ "https://example.com/" +
+ ROOT_PATH +
+ "script_redirect.html?" +
+ innerParams.toString()
+ );
+ uri =
+ "https://example.org/" +
+ ROOT_PATH +
+ "script_redirect.html?" +
+ params.toString();
+ BrowserTestUtils.loadURIString(browser, uri);
+ },
+ permDialogOptions: {
+ checkboxOrigin: ORIGIN1,
+ chooserIsNext: true,
+ hasCheckbox: true,
+ actionConfirm: false, // Cancel dialog
+ },
+ });
+ });
+});
+
+/**
+ * Test that we use the redirect principal for the dialog for link clicks.
+ */
+add_task(async function test_redirect_principal_links() {
+ let scheme = TEST_PROTOS[0];
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ await testOpenProto(browser, scheme, {
+ triggerLoad: async () => {
+ let uri = `${scheme}://test`;
+
+ let params = new URLSearchParams();
+ params.set("uri", uri);
+ uri =
+ "https://example.com/" +
+ ROOT_PATH +
+ "redirect_helper.sjs?" +
+ params.toString();
+ await ContentTask.spawn(browser, { uri }, args => {
+ let textLink = content.document.createElement("a");
+ textLink.href = args.uri;
+ textLink.textContent = "click me";
+ content.document.body.appendChild(textLink);
+ textLink.click();
+ });
+ },
+ permDialogOptions: {
+ checkboxOrigin: ORIGIN1,
+ chooserIsNext: true,
+ hasCheckbox: true,
+ actionConfirm: false, // Cancel dialog
+ },
+ });
+ });
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox.js b/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox.js
new file mode 100644
index 0000000000..bc4b13730a
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox.js
@@ -0,0 +1,140 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests navigation to external protocol from sandboxed iframes.
+ */
+
+"use strict";
+
+requestLongerTimeout(2);
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.block_external_protocol_navigation_from_sandbox", true]],
+ });
+
+ await setupMailHandler();
+});
+
+add_task(async function test_sandbox_disabled() {
+ await runExtProtocolSandboxTest({ blocked: false, sandbox: null });
+});
+
+add_task(async function test_sandbox_allowed() {
+ let flags = [
+ "allow-popups",
+ "allow-top-navigation",
+ "allow-top-navigation-by-user-activation",
+ "allow-top-navigation-to-custom-protocols",
+ ];
+
+ for (let flag of flags) {
+ await runExtProtocolSandboxTest({
+ blocked: false,
+ sandbox: `allow-scripts ${flag}`,
+ });
+ }
+});
+
+add_task(async function test_sandbox_blocked() {
+ let flags = [
+ "",
+ "allow-same-origin",
+ "allow-forms",
+ "allow-scripts",
+ "allow-pointer-lock",
+ "allow-orientation-lock",
+ "allow-modals",
+ "allow-popups-to-escape-sandbox",
+ "allow-presentation",
+ "allow-storage-access-by-user-activation",
+ "allow-downloads",
+ ];
+
+ for (let flag of flags) {
+ await runExtProtocolSandboxTest({
+ blocked: true,
+ sandbox: `allow-scripts ${flag}`,
+ });
+ }
+});
+
+add_task(async function test_sandbox_blocked_triggers() {
+ info(
+ "For sandboxed frames external protocol navigation is blocked, no matter how it is triggered."
+ );
+ for (let triggerMethod of [
+ "trustedClick",
+ "untrustedClick",
+ "trustedLocationAPI",
+ "untrustedLocationAPI",
+ "frameSrc",
+ ]) {
+ await runExtProtocolSandboxTest({
+ blocked: true,
+ sandbox: "allow-scripts",
+ triggerMethod,
+ });
+ }
+
+ info(
+ "When allow-top-navigation-by-user-activation navigation to external protocols with transient user activations is allowed."
+ );
+ await runExtProtocolSandboxTest({
+ blocked: false,
+ sandbox: "allow-scripts allow-top-navigation-by-user-activation",
+ triggerMethod: "trustedClick",
+ });
+
+ await runExtProtocolSandboxTest({
+ blocked: true,
+ sandbox: "allow-scripts allow-top-navigation-by-user-activation",
+ triggerMethod: "untrustedClick",
+ });
+
+ await runExtProtocolSandboxTest({
+ blocked: true,
+ sandbox: "allow-scripts allow-top-navigation-by-user-activation",
+ triggerMethod: "untrustedLocationAPI",
+ });
+
+ await runExtProtocolSandboxTest({
+ blocked: true,
+ sandbox: "allow-scripts allow-top-navigation-by-user-activation",
+ triggerMethod: "frameSrc",
+ });
+});
+
+add_task(async function test_sandbox_combination() {
+ await runExtProtocolSandboxTest({
+ blocked: false,
+ sandbox:
+ "allow-scripts allow-downloads allow-top-navigation-to-custom-protocols",
+ });
+
+ await runExtProtocolSandboxTest({
+ blocked: false,
+ sandbox:
+ "allow-scripts allow-top-navigation allow-top-navigation-to-custom-protocols",
+ });
+
+ await runExtProtocolSandboxTest({
+ blocked: true,
+ sandbox: "allow-scripts allow-modals",
+ });
+});
+
+add_task(async function test_sandbox_iframe_redirect() {
+ await runExtProtocolSandboxTest({
+ blocked: true,
+ sandbox: "allow-scripts",
+ triggerMethod: "frameSrcRedirect",
+ });
+
+ await runExtProtocolSandboxTest({
+ blocked: false,
+ sandbox: "allow-scripts allow-top-navigation-to-custom-protocols",
+ triggerMethod: "frameSrcRedirect",
+ });
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox_csp.js b/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox_csp.js
new file mode 100644
index 0000000000..03d2fe8cf5
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox_csp.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests navigation to external protocol from csp-sandboxed iframes.
+ */
+
+"use strict";
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.block_external_protocol_navigation_from_sandbox", true]],
+ });
+
+ await setupMailHandler();
+});
+
+add_task(async function test_sandbox_csp() {
+ for (let triggerMethod of [
+ "trustedClick",
+ "untrustedClick",
+ "trustedLocationAPI",
+ "untrustedLocationAPI",
+ ]) {
+ await runExtProtocolSandboxTest({
+ blocked: false,
+ sandbox: "allow-scripts",
+ useCSPSandbox: true,
+ triggerMethod,
+ });
+ }
+
+ await runExtProtocolSandboxTest({
+ blocked: false,
+ sandbox: "allow-scripts allow-top-navigation-to-custom-protocols",
+ useCSPSandbox: true,
+ });
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_protocolhandler_loop.js b/uriloader/exthandler/tests/mochitest/browser_protocolhandler_loop.js
new file mode 100644
index 0000000000..149701fb23
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_protocolhandler_loop.js
@@ -0,0 +1,74 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_helperapp() {
+ // Set up the test infrastructure:
+ const kProt = "foopydoopydoo";
+ const extProtocolSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService);
+ const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+ );
+ let handlerInfo = extProtocolSvc.getProtocolHandlerInfo(kProt);
+ if (handlerSvc.exists(handlerInfo)) {
+ handlerSvc.fillHandlerInfo(handlerInfo, "");
+ }
+ // Say we want to use a specific app:
+ handlerInfo.preferredAction = Ci.nsIHandlerInfo.useHelperApp;
+ handlerInfo.alwaysAskBeforeHandling = false;
+
+ // Say it's us:
+ let selfFile = Services.dirsvc.get("XREExeF", Ci.nsIFile);
+ // Make sure it's the .app
+ if (AppConstants.platform == "macosx") {
+ while (
+ !selfFile.leafName.endsWith(".app") &&
+ !selfFile.leafName.endsWith(".app/")
+ ) {
+ selfFile = selfFile.parent;
+ }
+ }
+ let selfHandlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ selfHandlerApp.executable = selfFile;
+ handlerInfo.possibleApplicationHandlers.appendElement(selfHandlerApp);
+ handlerInfo.preferredApplicationHandler = selfHandlerApp;
+ handlerSvc.store(handlerInfo);
+
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ // Now, do some safety stubbing. If we do end up recursing we spawn
+ // infinite tabs. We definitely don't want that. Avoid it by stubbing
+ // our external URL handling bits:
+ let oldAddTab = gBrowser.addTab;
+ registerCleanupFunction(() => (gBrowser.addTab = oldAddTab));
+ let wrongThingHappenedPromise = new Promise(resolve => {
+ gBrowser.addTab = function (aURI) {
+ ok(false, "Tried to open unexpected URL in a tab: " + aURI);
+ resolve(null);
+ // Pass a dummy object to avoid upsetting BrowserContentHandler -
+ // if it thinks opening the tab failed, it tries to open a window instead,
+ // which we can't prevent as easily, and at which point we still end up
+ // with runaway tabs.
+ return {};
+ };
+ });
+
+ let askedUserPromise = waitForProtocolAppChooserDialog(browser, true);
+
+ BrowserTestUtils.loadURIString(browser, kProt + ":test");
+ let dialog = await Promise.race([
+ wrongThingHappenedPromise,
+ askedUserPromise,
+ ]);
+ ok(dialog, "Should have gotten a dialog");
+
+ let closePromise = waitForProtocolAppChooserDialog(browser, false);
+ dialog.close();
+ await closePromise;
+ askedUserPromise = null;
+ });
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_remember_download_option.js b/uriloader/exthandler/tests/mochitest/browser_remember_download_option.js
new file mode 100644
index 0000000000..f3fedab69c
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_remember_download_option.js
@@ -0,0 +1,61 @@
+add_task(async function () {
+ // create mocked objects
+ let launcher = createMockedObjects(true);
+
+ // open helper app dialog with mocked launcher
+ let dlg = await openHelperAppDialog(launcher);
+
+ let doc = dlg.document;
+ let dialogElement = doc.getElementById("unknownContentType");
+
+ // Set remember choice
+ ok(
+ !doc.getElementById("rememberChoice").checked,
+ "Remember choice checkbox should be not checked."
+ );
+ doc.getElementById("rememberChoice").checked = true;
+
+ // Make sure the mock handler information is not in nsIHandlerService
+ ok(
+ !gHandlerSvc.exists(launcher.MIMEInfo),
+ "Should not be in nsIHandlerService."
+ );
+
+ // close the dialog by pushing the ok button.
+ let dialogClosedPromise = BrowserTestUtils.windowClosed(dlg);
+ // Make sure the ok button is enabled, since the ok button might be disabled by
+ // EnableDelayHelper mechanism. Please refer the detailed
+ // https://searchfox.org/mozilla-central/source/toolkit/components/prompts/src/PromptUtils.sys.mjs#51
+ dialogElement.getButton("accept").disabled = false;
+ dialogElement.acceptDialog();
+ await dialogClosedPromise;
+
+ // check the mocked handler information is saved in nsIHandlerService
+ ok(gHandlerSvc.exists(launcher.MIMEInfo), "Should be in nsIHandlerService.");
+ // check the extension.
+ var mimeType = gHandlerSvc.getTypeFromExtension("abc");
+ is(mimeType, launcher.MIMEInfo.type, "Got correct mime type.");
+ for (let handlerInfo of gHandlerSvc.enumerate()) {
+ if (handlerInfo.type == launcher.MIMEInfo.type) {
+ // check the alwaysAskBeforeHandling
+ ok(
+ !handlerInfo.alwaysAskBeforeHandling,
+ "Should turn off the always ask."
+ );
+ // check the preferredApplicationHandler
+ ok(
+ handlerInfo.preferredApplicationHandler.equals(
+ launcher.MIMEInfo.preferredApplicationHandler
+ ),
+ "Should be equal to the mockedHandlerApp."
+ );
+ // check the perferredAction
+ is(
+ handlerInfo.preferredAction,
+ launcher.MIMEInfo.preferredAction,
+ "Should be equal to Ci.nsIHandlerInfo.useHelperApp."
+ );
+ break;
+ }
+ }
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_save_filenames.js b/uriloader/exthandler/tests/mochitest/browser_save_filenames.js
new file mode 100644
index 0000000000..f421a7a609
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_save_filenames.js
@@ -0,0 +1,823 @@
+// There are at least seven different ways in a which a file can be saved or downloaded. This
+// test ensures that the filename is determined correctly when saving in these ways. The seven
+// ways are:
+// - save the file individually from the File menu
+// - save as complete web page (this uses a different codepath than the previous one)
+// - dragging an image to the local file system
+// - copy an image and paste it as a file to the local file system (windows only)
+// - open a link with content-disposition set to attachment
+// - open a link with the download attribute
+// - save a link or image from the context menu
+
+requestLongerTimeout(8);
+
+let types = {
+ text: "text/plain",
+ html: "text/html",
+ png: "image/png",
+ jpeg: "image/jpeg",
+ webp: "image/webp",
+ otherimage: "image/unknown",
+ // Other js types (application/javascript and text/javascript) are handled by the system
+ // inconsistently, but application/x-javascript is handled by the external helper app service,
+ // so it is used here for this test.
+ js: "application/x-javascript",
+ binary: "application/octet-stream",
+ nonsense: "application/x-nonsense",
+ zip: "application/zip",
+ json: "application/json",
+ tar: "application/x-tar",
+};
+
+const PNG_DATA = atob(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" +
+ "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII="
+);
+
+const JPEG_DATA = atob(
+ "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4z" +
+ "NDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEB" +
+ "AxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS" +
+ "0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKz" +
+ "tLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgEC" +
+ "BAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpj" +
+ "ZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6" +
+ "/9oADAMBAAIRAxEAPwD3+iiigD//2Q=="
+);
+
+const WEBP_DATA = atob(
+ "UklGRiIAAABXRUJQVlA4TBUAAAAvY8AYAAfQ/4j+B4CE8H+/ENH/VCIA"
+);
+
+const DEFAULT_FILENAME =
+ AppConstants.platform == "win" ? "Untitled.htm" : "Untitled.html";
+
+const PROMISE_FILENAME_TYPE = "application/x-moz-file-promise-dest-filename";
+
+let MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+let expectedItems;
+let sendAsAttachment = false;
+let httpServer = null;
+
+function handleRequest(aRequest, aResponse) {
+ const queryString = new URLSearchParams(aRequest.queryString);
+ let type = queryString.get("type");
+ let filename = queryString.get("filename");
+ let dispname = queryString.get("dispname");
+
+ aResponse.setStatusLine(aRequest.httpVersion, 200);
+ if (type) {
+ aResponse.setHeader("Content-Type", types[type]);
+ }
+
+ if (dispname) {
+ let dispositionType = sendAsAttachment ? "attachment" : "inline";
+ aResponse.setHeader(
+ "Content-Disposition",
+ dispositionType + ';name="' + dispname + '"'
+ );
+ } else if (filename) {
+ let dispositionType = sendAsAttachment ? "attachment" : "inline";
+ aResponse.setHeader(
+ "Content-Disposition",
+ dispositionType + ';filename="' + filename + '"'
+ );
+ } else if (sendAsAttachment) {
+ aResponse.setHeader("Content-Disposition", "attachment");
+ }
+
+ if (type == "png") {
+ aResponse.write(PNG_DATA);
+ } else if (type == "jpeg") {
+ aResponse.write(JPEG_DATA);
+ } else if (type == "webp") {
+ aResponse.write(WEBP_DATA);
+ } else if (type == "html") {
+ aResponse.write(
+ "<html><head><title>file.inv</title></head><body>File</body></html>"
+ );
+ } else {
+ aResponse.write("// Some Text");
+ }
+}
+
+function handleBasicImageRequest(aRequest, aResponse) {
+ aResponse.setHeader("Content-Type", "image/png");
+ aResponse.write(PNG_DATA);
+}
+
+function handleRedirect(aRequest, aResponse) {
+ const queryString = new URLSearchParams(aRequest.queryString);
+ let filename = queryString.get("filename");
+
+ aResponse.setStatusLine(aRequest.httpVersion, 302);
+ aResponse.setHeader("Location", "/bell" + filename[0] + "?" + queryString);
+}
+
+function promiseDownloadFinished(list) {
+ return new Promise(resolve => {
+ list.addView({
+ onDownloadChanged(download) {
+ if (download.stopped) {
+ list.removeView(this);
+ resolve(download);
+ }
+ },
+ });
+ });
+}
+
+// nsIFile::CreateUnique crops long filenames if the path is too long, but
+// we don't know exactly how long depending on the full path length, so
+// for those save methods that use CreateUnique, instead just verify that
+// the filename starts with the right string and has the correct extension.
+function checkShortenedFilename(actual, expected) {
+ if (actual.length < expected.length) {
+ let actualDot = actual.lastIndexOf(".");
+ let actualExtension = actual.substring(actualDot);
+ let expectedExtension = expected.substring(expected.lastIndexOf("."));
+ if (
+ actualExtension == expectedExtension &&
+ expected.startsWith(actual.substring(0, actualDot))
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+add_setup(async function () {
+ const { HttpServer } = ChromeUtils.import(
+ "resource://testing-common/httpd.js"
+ );
+ httpServer = new HttpServer();
+ httpServer.start(8000);
+
+ // Need to load the page from localhost:8000 as the download attribute
+ // only applies to links from the same domain.
+ let saveFilenamesPage = FileUtils.getFile(
+ "CurWorkD",
+ "/browser/uriloader/exthandler/tests/mochitest/save_filenames.html".split(
+ "/"
+ )
+ );
+ httpServer.registerFile("/save_filenames.html", saveFilenamesPage);
+
+ // A variety of different scripts are set up to better ensure uniqueness.
+ httpServer.registerPathHandler("/save_filename.sjs", handleRequest);
+ httpServer.registerPathHandler("/save_thename.sjs", handleRequest);
+ httpServer.registerPathHandler("/getdata.png", handleRequest);
+ httpServer.registerPathHandler("/base", handleRequest);
+ httpServer.registerPathHandler("/basedata", handleRequest);
+ httpServer.registerPathHandler("/basetext", handleRequest);
+ httpServer.registerPathHandler("/text2.txt", handleRequest);
+ httpServer.registerPathHandler("/text3.gonk", handleRequest);
+ httpServer.registerPathHandler("/basic.png", handleBasicImageRequest);
+ httpServer.registerPathHandler("/aquamarine.jpeg", handleBasicImageRequest);
+ httpServer.registerPathHandler("/lazuli.exe", handleBasicImageRequest);
+ httpServer.registerPathHandler("/redir", handleRedirect);
+ httpServer.registerPathHandler("/bellr", handleRequest);
+ httpServer.registerPathHandler("/bellg", handleRequest);
+ httpServer.registerPathHandler("/bellb", handleRequest);
+ httpServer.registerPathHandler("/executable.exe", handleRequest);
+
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "http://localhost:8000/save_filenames.html"
+ );
+
+ expectedItems = await getItems("items");
+});
+
+function getItems(parentid) {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [parentid, AppConstants.platform],
+ (id, platform) => {
+ let elements = [];
+ let elem = content.document.getElementById(id).firstElementChild;
+ while (elem) {
+ let filename =
+ elem.dataset["filenamePlatform" + platform] || elem.dataset.filename;
+ let url = elem.getAttribute("src");
+ let draggable =
+ elem.localName == "img" && elem.dataset.nodrag != "true";
+ let unknown = elem.dataset.unknown;
+ let noattach = elem.dataset.noattach;
+ let savepagename = elem.dataset.savepagename;
+ elements.push({
+ draggable,
+ unknown,
+ filename,
+ url,
+ noattach,
+ savepagename,
+ });
+ elem = elem.nextElementSibling;
+ }
+ return elements;
+ }
+ );
+}
+
+function getDirectoryEntries(dir) {
+ let files = [];
+ let entries = dir.directoryEntries;
+ while (true) {
+ let file = entries.nextFile;
+ if (!file) {
+ break;
+ }
+ files.push(file.leafName);
+ }
+ entries.close();
+ return files;
+}
+
+// This test saves the document as a complete web page and verifies
+// that the resources are saved with the correct filename.
+add_task(async function save_document() {
+ let browser = gBrowser.selectedBrowser;
+
+ let tmp = SpecialPowers.Services.dirsvc.get("TmpD", Ci.nsIFile);
+ const baseFilename = "test_save_filenames_" + Date.now();
+
+ let tmpFile = tmp.clone();
+ tmpFile.append(baseFilename + "_document.html");
+ let tmpDir = tmp.clone();
+ tmpDir.append(baseFilename + "_document_files");
+
+ MockFilePicker.displayDirectory = tmpDir;
+ MockFilePicker.showCallback = function (fp) {
+ MockFilePicker.setFiles([tmpFile]);
+ MockFilePicker.filterIndex = 0; // kSaveAsType_Complete
+ };
+
+ let downloadsList = await Downloads.getList(Downloads.PUBLIC);
+ let savePromise = new Promise((resolve, reject) => {
+ downloadsList.addView({
+ onDownloadChanged(download) {
+ if (download.succeeded) {
+ downloadsList.removeView(this);
+ downloadsList.removeFinished();
+ resolve();
+ }
+ },
+ });
+ });
+ saveBrowser(browser);
+ await savePromise;
+
+ let filesSaved = getDirectoryEntries(tmpDir);
+
+ for (let idx = 0; idx < expectedItems.length; idx++) {
+ let filename = expectedItems[idx].filename;
+ if (idx == 66 && AppConstants.platform == "win") {
+ // This is special-cased on Windows. The default filename will be used, since
+ // the filename is invalid, but since the previous test file has the same issue,
+ // this second file will be saved with a number suffix added to it.
+ filename = "Untitled_002";
+ }
+
+ let file = tmpDir.clone();
+ file.append(filename);
+
+ let fileIdx = -1;
+ // Use checkShortenedFilename to check long filenames.
+ if (filename.length > 240) {
+ for (let t = 0; t < filesSaved.length; t++) {
+ if (
+ filesSaved[t].length > 60 &&
+ checkShortenedFilename(filesSaved[t], filename)
+ ) {
+ fileIdx = t;
+ break;
+ }
+ }
+ } else {
+ fileIdx = filesSaved.indexOf(filename);
+ }
+
+ ok(
+ fileIdx >= 0,
+ "file i" +
+ idx +
+ " " +
+ filename +
+ " was saved with the correct name using saveDocument"
+ );
+ if (fileIdx >= 0) {
+ // If found, remove it from the list. At end of the test, the
+ // list should be empty.
+ filesSaved.splice(fileIdx, 1);
+ }
+ }
+
+ is(filesSaved.length, 0, "all files accounted for");
+ tmpDir.remove(true);
+ tmpFile.remove(false);
+});
+
+// This test simulates dragging the images in the document and ensuring that
+// the correct filename is used for each one.
+// On Mac, the data is added in the parent process instead, so we cannot
+// test dragging directly.
+if (AppConstants.platform != "macosx") {
+ add_task(async function drag_files() {
+ let browser = gBrowser.selectedBrowser;
+
+ await SpecialPowers.spawn(browser, [PROMISE_FILENAME_TYPE], type => {
+ content.addEventListener("dragstart", event => {
+ content.draggedFile = event.dataTransfer.getData(type);
+ event.preventDefault();
+ });
+ });
+
+ for (let idx = 0; idx < expectedItems.length; idx++) {
+ if (!expectedItems[idx].draggable) {
+ // You can't drag non-images and invalid images.
+ continue;
+ }
+
+ await BrowserTestUtils.synthesizeMouse(
+ "#i" + idx,
+ 1,
+ 1,
+ { type: "mousedown" },
+ browser
+ );
+ await BrowserTestUtils.synthesizeMouse(
+ "#i" + idx,
+ 11,
+ 11,
+ { type: "mousemove" },
+ browser
+ );
+ await BrowserTestUtils.synthesizeMouse(
+ "#i" + idx,
+ 20,
+ 20,
+ { type: "mousemove" },
+ browser
+ );
+ await BrowserTestUtils.synthesizeMouse(
+ "#i" + idx,
+ 20,
+ 20,
+ { type: "mouseup" },
+ browser
+ );
+
+ let draggedFile = await SpecialPowers.spawn(browser, [], () => {
+ let file = content.draggedFile;
+ content.draggedFile = null;
+ return file;
+ });
+
+ is(
+ draggedFile,
+ expectedItems[idx].filename,
+ "i" +
+ idx +
+ " " +
+ expectedItems[idx].filename +
+ " was saved with the correct name when dragging"
+ );
+ }
+ });
+}
+
+// This test checks that copying an image provides the right filename
+// for pasting to the local file system. This is only implemented on Windows.
+if (AppConstants.platform == "win") {
+ add_task(async function copy_image() {
+ for (let idx = 0; idx < expectedItems.length; idx++) {
+ if (!expectedItems[idx].draggable) {
+ // You can't context-click on non-images.
+ continue;
+ }
+
+ let data = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [idx, PROMISE_FILENAME_TYPE],
+ (imagenum, type) => {
+ // No need to wait for the data to be really on the clipboard, we only
+ // need the promise data added when the command is performed.
+ SpecialPowers.setCommandNode(
+ content,
+ content.document.getElementById("i" + imagenum)
+ );
+ SpecialPowers.doCommand(content, "cmd_copyImageContents");
+
+ return SpecialPowers.getClipboardData(type);
+ }
+ );
+
+ is(
+ data,
+ expectedItems[idx].filename,
+ "i" +
+ idx +
+ " " +
+ expectedItems[idx].filename +
+ " was saved with the correct name when copying"
+ );
+ }
+ });
+}
+
+// This test checks the default filename selected when selecting to save
+// a file from either the context menu or what would happen when save page
+// as was selected from the file menu. Note that this tests a filename assigned
+// when using content-disposition: inline.
+add_task(async function saveas_files() {
+ // Iterate over each item and try saving them from the context menu,
+ // and the Save Page As command on the File menu.
+ for (let testname of ["context menu", "save page as"]) {
+ for (let idx = 0; idx < expectedItems.length; idx++) {
+ let menu;
+ if (testname == "context menu") {
+ if (!expectedItems[idx].draggable) {
+ // You can't context-click on non-images.
+ continue;
+ }
+
+ menu = document.getElementById("contentAreaContextMenu");
+ let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ BrowserTestUtils.synthesizeMouse(
+ "#i" + idx,
+ 5,
+ 5,
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ await popupShown;
+ } else {
+ if (expectedItems[idx].unknown == "typeonly") {
+ // Items marked with unknown="typeonly" have unknown content types and
+ // will be downloaded instead of opened in a tab.
+ let list = await Downloads.getList(Downloads.PUBLIC);
+ let downloadFinishedPromise = promiseDownloadFinished(list);
+
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: expectedItems[idx].url,
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+
+ let download = await downloadFinishedPromise;
+
+ let filename = PathUtils.filename(download.target.path);
+
+ let expectedFilename = expectedItems[idx].filename;
+ if (expectedFilename.length > 240) {
+ ok(
+ checkShortenedFilename(filename, expectedFilename),
+ "open link" +
+ idx +
+ " " +
+ expectedFilename +
+ " was downloaded with the correct name when opened as a url (with long name)"
+ );
+ } else {
+ is(
+ filename,
+ expectedFilename,
+ "open link" +
+ idx +
+ " " +
+ expectedFilename +
+ " was downloaded with the correct name when opened as a url"
+ );
+ }
+
+ try {
+ await IOUtils.remove(download.target.path);
+ } catch (ex) {}
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ continue;
+ }
+
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: expectedItems[idx].url,
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+ }
+
+ let filename = await new Promise(resolve => {
+ MockFilePicker.showCallback = function (fp) {
+ setTimeout(() => {
+ resolve(fp.defaultString);
+ }, 0);
+ return Ci.nsIFilePicker.returnCancel;
+ };
+
+ if (testname == "context menu") {
+ let menuitem = document.getElementById("context-saveimage");
+ menu.activateItem(menuitem);
+ } else if (testname == "save page as") {
+ document.getElementById("Browser:SavePage").doCommand();
+ }
+ });
+
+ // Trying to open an unknown or binary type will just open a blank
+ // page, so trying to save will just save the blank page with the
+ // filename Untitled.html.
+ let expectedFilename = expectedItems[idx].unknown
+ ? DEFAULT_FILENAME
+ : expectedItems[idx].savepagename || expectedItems[idx].filename;
+
+ // When saving via contentAreaUtils.js, the content disposition name
+ // field is used as an alternate.
+ if (expectedFilename == "save_thename.png") {
+ expectedFilename = "withname.png";
+ }
+
+ is(
+ filename,
+ expectedFilename,
+ "i" +
+ idx +
+ " " +
+ expectedFilename +
+ " was saved with the correct name " +
+ testname
+ );
+
+ if (testname == "save page as") {
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+ }
+ }
+});
+
+// This test checks that links that result in files with
+// content-disposition: attachment are saved with the right filenames.
+add_task(async function save_links() {
+ sendAsAttachment = true;
+
+ // Create some links based on each image and insert them into the document.
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ let doc = content.document;
+ let insertPos = doc.getElementById("attachment-links");
+
+ let idx = 0;
+ let elem = doc.getElementById("items").firstElementChild;
+ while (elem) {
+ let attachmentlink = doc.createElement("a");
+ attachmentlink.id = "attachmentlink" + idx;
+ attachmentlink.href = elem.localName == "object" ? elem.data : elem.src;
+ attachmentlink.textContent = elem.dataset.filename;
+ insertPos.appendChild(attachmentlink);
+ insertPos.appendChild(doc.createTextNode(" "));
+
+ elem = elem.nextElementSibling;
+ idx++;
+ }
+ });
+
+ let list = await Downloads.getList(Downloads.PUBLIC);
+
+ for (let idx = 0; idx < expectedItems.length; idx++) {
+ // Skip the items that won't have a content-disposition.
+ if (expectedItems[idx].noattach) {
+ continue;
+ }
+
+ let downloadFinishedPromise = promiseDownloadFinished(list);
+
+ BrowserTestUtils.synthesizeMouse(
+ "#attachmentlink" + idx,
+ 5,
+ 5,
+ {},
+ gBrowser.selectedBrowser
+ );
+
+ let download = await downloadFinishedPromise;
+
+ let filename = PathUtils.filename(download.target.path);
+
+ let expectedFilename = expectedItems[idx].filename;
+ // Use checkShortenedFilename to check long filenames.
+ if (expectedItems[idx].filename.length > 240) {
+ ok(
+ checkShortenedFilename(filename, expectedFilename),
+ "attachmentlink" +
+ idx +
+ " " +
+ expectedFilename +
+ " was saved with the correct name when opened as attachment (with long name)"
+ );
+ } else {
+ is(
+ filename,
+ expectedFilename,
+ "attachmentlink" +
+ idx +
+ " " +
+ expectedFilename +
+ " was saved with the correct name when opened as attachment"
+ );
+ }
+
+ try {
+ await IOUtils.remove(download.target.path);
+ } catch (ex) {}
+ }
+
+ sendAsAttachment = false;
+});
+
+// This test checks some cases where links to images are saved using Save Link As,
+// and when opening them in a new tab and then using Save Page As.
+add_task(async function saveas_image_links() {
+ let links = await getItems("links");
+
+ // Iterate over each link and try saving the links from the context menu,
+ // and then after opening a new tab for that link and then selecting
+ // the Save Page As command on the File menu.
+ for (let testname of ["save link as", "save link then save page as"]) {
+ for (let idx = 0; idx < links.length; idx++) {
+ let menu = document.getElementById("contentAreaContextMenu");
+ let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ BrowserTestUtils.synthesizeMouse(
+ "#link" + idx,
+ 5,
+ 5,
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ await popupShown;
+
+ let promptPromise = new Promise(resolve => {
+ MockFilePicker.showCallback = function (fp) {
+ setTimeout(() => {
+ resolve(fp.defaultString);
+ }, 0);
+ return Ci.nsIFilePicker.returnCancel;
+ };
+ });
+
+ if (testname == "save link as") {
+ let menuitem = document.getElementById("context-savelink");
+ menu.activateItem(menuitem);
+ } else {
+ let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+
+ let menuitem = document.getElementById("context-openlinkintab");
+ menu.activateItem(menuitem);
+
+ let tab = await newTabPromise;
+ await BrowserTestUtils.switchTab(gBrowser, tab);
+
+ document.getElementById("Browser:SavePage").doCommand();
+ }
+
+ let filename = await promptPromise;
+
+ let expectedFilename = links[idx].filename;
+ // Only codepaths that go through contentAreaUtils.js use the
+ // name from the content disposition.
+ if (testname == "save link as" && expectedFilename == "four.png") {
+ expectedFilename = "save_filename.png";
+ }
+
+ is(
+ filename,
+ expectedFilename,
+ "i" +
+ idx +
+ " " +
+ expectedFilename +
+ " link was saved with the correct name " +
+ testname
+ );
+
+ if (testname == "save link then save page as") {
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+ }
+ }
+});
+
+// This test checks that links that with a download attribute
+// are saved with the right filenames.
+add_task(async function save_download_links() {
+ let downloads = await getItems("downloads");
+
+ let list = await Downloads.getList(Downloads.PUBLIC);
+ for (let idx = 0; idx < downloads.length; idx++) {
+ let downloadFinishedPromise = promiseDownloadFinished(list);
+
+ BrowserTestUtils.synthesizeMouse(
+ "#download" + idx,
+ 2,
+ 2,
+ {},
+ gBrowser.selectedBrowser
+ );
+
+ let download = await downloadFinishedPromise;
+
+ let filename = PathUtils.filename(download.target.path);
+
+ if (downloads[idx].filename.length > 240) {
+ ok(
+ checkShortenedFilename(filename, downloads[idx].filename),
+ "download" +
+ idx +
+ " " +
+ downloads[idx].filename +
+ " was saved with the correct name when link has download attribute"
+ );
+ } else {
+ if (idx == 66 && filename == "Untitled(1)") {
+ // Sometimes, the previous test's file still exists or wasn't created in time
+ // and a non-duplicated name is created. Allow this rather than figuring out
+ // how to avoid it since it doesn't affect what is being tested here.
+ filename = "Untitled";
+ }
+
+ is(
+ filename,
+ downloads[idx].filename,
+ "download" +
+ idx +
+ " " +
+ downloads[idx].filename +
+ " was saved with the correct name when link has download attribute"
+ );
+ }
+
+ try {
+ await IOUtils.remove(download.target.path);
+ } catch (ex) {}
+ }
+});
+
+// This test verifies that invalid extensions are not removed when they
+// have been entered in the file picker.
+add_task(async function save_page_with_invalid_after_filepicker() {
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "http://localhost:8000/save_filename.sjs?type=html&filename=invfile.lnk"
+ );
+
+ let filename = await new Promise(resolve => {
+ MockFilePicker.showCallback = function (fp) {
+ let expectedFilename =
+ AppConstants.platform == "win" ? "invfile.lnk.htm" : "invfile.lnk.html";
+ is(fp.defaultString, expectedFilename, "supplied filename is correct");
+ setTimeout(() => {
+ resolve("otherfile.local");
+ }, 0);
+ return Ci.nsIFilePicker.returnCancel;
+ };
+
+ document.getElementById("Browser:SavePage").doCommand();
+ });
+
+ is(filename, "otherfile.local", "lnk extension has been preserved");
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_task(async function save_page_with_invalid_extension() {
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "http://localhost:8000/save_filename.sjs?type=html"
+ );
+
+ let filename = await new Promise(resolve => {
+ MockFilePicker.showCallback = function (fp) {
+ setTimeout(() => {
+ resolve(fp.defaultString);
+ }, 0);
+ return Ci.nsIFilePicker.returnCancel;
+ };
+
+ document.getElementById("Browser:SavePage").doCommand();
+ });
+
+ is(
+ filename,
+ AppConstants.platform == "win" ? "file.inv.htm" : "file.inv.html",
+ "html extension has been added"
+ );
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_task(async () => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ MockFilePicker.cleanup();
+ await new Promise(resolve => httpServer.stop(resolve));
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_shows_where_to_save_dialog.js b/uriloader/exthandler/tests/mochitest/browser_shows_where_to_save_dialog.js
new file mode 100644
index 0000000000..6bf375dfff
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_shows_where_to_save_dialog.js
@@ -0,0 +1,303 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { DownloadIntegration } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadIntegration.sys.mjs"
+);
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+const { handleInternally, useHelperApp, useSystemDefault, saveToDisk } =
+ Ci.nsIHandlerInfo;
+
+let MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.always_ask_before_handling_new_types", false],
+ ["browser.download.useDownloadDir", false],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ DownloadsPanel.panel,
+ "popuphidden"
+ );
+ DownloadsPanel.hidePanel();
+ await hiddenPromise;
+ MockFilePicker.cleanup();
+ });
+});
+
+// This test ensures that a "Save as..." filepicker dialog is shown for a file
+// if useDownloadDir ("Always ask where to save files") is set to false and
+// the filetype is set to save to disk.
+add_task(async function aDownloadSavedToDiskPromptsForFolder() {
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ ensureMIMEState(
+ { preferredAction: saveToDisk },
+ { type: "text/plain", ext: "txt" }
+ );
+ registerCleanupFunction(async () => {
+ await publicList.removeFinished();
+ });
+ let filePickerShownPromise = new Promise(resolve => {
+ MockFilePicker.showCallback = function (fp) {
+ setTimeout(resolve, 0);
+ return Ci.nsIFilePicker.returnCancel;
+ };
+ });
+
+ let loadingTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + "file_txt_attachment_test.txt",
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+
+ info("Waiting on filepicker.");
+ await filePickerShownPromise;
+ ok(true, "filepicker should have been shown");
+
+ BrowserTestUtils.removeTab(loadingTab);
+});
+
+// This test ensures that downloads configured to open internally create only
+// one file destination when saved via the filepicker, and don't prompt.
+add_task(async function testFilesHandledInternally() {
+ let dir = await setupFilePickerDirectory();
+
+ ensureMIMEState(
+ { preferredAction: handleInternally },
+ { type: "image/webp", ext: "webp" }
+ );
+
+ let filePickerShown = false;
+ MockFilePicker.showCallback = function (fp) {
+ filePickerShown = true;
+ return Ci.nsIFilePicker.returnCancel;
+ };
+
+ let thirdTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ url => {
+ info("Got load for " + url);
+ return url.endsWith("file_green.webp") && url.startsWith("file:");
+ },
+ true,
+ true
+ );
+ let loadingTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + "file_green.webp",
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+
+ let openedTab = await thirdTabPromise;
+ ok(!filePickerShown, "file picker should not have shown up.");
+
+ assertCorrectFile(dir, "file_green.webp");
+
+ // Cleanup
+ BrowserTestUtils.removeTab(loadingTab);
+ BrowserTestUtils.removeTab(openedTab);
+});
+
+// This test ensures that downloads configured to open with a system default
+// app create only one file destination and don't open the filepicker.
+add_task(async function testFilesHandledBySystemDefaultApp() {
+ let dir = await setupFilePickerDirectory();
+
+ ensureMIMEState({ preferredAction: useSystemDefault });
+
+ let filePickerShown = false;
+ MockFilePicker.showCallback = function (fp) {
+ filePickerShown = true;
+ return Ci.nsIFilePicker.returnCancel;
+ };
+
+ let oldLaunchFile = DownloadIntegration.launchFile;
+ let launchFileCalled = new Promise(resolve => {
+ DownloadIntegration.launchFile = async (file, mimeInfo) => {
+ is(
+ useSystemDefault,
+ mimeInfo.preferredAction,
+ "The file should be launched with a system app handler."
+ );
+ resolve();
+ };
+ });
+
+ let loadingTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + "file_pdf_application_pdf.pdf",
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+
+ await launchFileCalled;
+ ok(!filePickerShown, "file picker should not have shown up.");
+
+ assertCorrectFile(dir, "file_pdf_application_pdf.pdf");
+
+ // Cleanup
+ BrowserTestUtils.removeTab(loadingTab);
+ DownloadIntegration.launchFile = oldLaunchFile;
+});
+
+// This test ensures that downloads configured to open with a helper app create
+// only one file destination when saved via the filepicker.
+add_task(async function testFilesHandledByHelperApp() {
+ let dir = await setupFilePickerDirectory();
+
+ // Create a custom helper app so we can check that a launcherPath is
+ // configured for the serialized download.
+ let appHandler = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ appHandler.name = "Dummy Test Handler";
+ appHandler.executable = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ appHandler.executable.append("helper_handler_test.exe");
+
+ if (!appHandler.executable.exists()) {
+ appHandler.executable.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o777);
+ }
+
+ ensureMIMEState({
+ preferredAction: useHelperApp,
+ preferredHandlerApp: appHandler,
+ });
+
+ let filePickerShown = false;
+ MockFilePicker.showCallback = function (fp) {
+ filePickerShown = true;
+ return Ci.nsIFilePicker.returnCancel;
+ };
+
+ let publicDownloads = await Downloads.getList(Downloads.PUBLIC);
+ let downloadFinishedPromise = new Promise(resolve => {
+ publicDownloads.addView({
+ onDownloadChanged(download) {
+ if (download.succeeded || download.error) {
+ ok(
+ download.launcherPath.includes("helper_handler_test.exe"),
+ "Launcher path is available."
+ );
+ resolve();
+ }
+ },
+ });
+ });
+
+ let oldLaunchFile = DownloadIntegration.launchFile;
+ let launchFileCalled = new Promise(resolve => {
+ DownloadIntegration.launchFile = async (file, mimeInfo) => {
+ is(
+ useHelperApp,
+ mimeInfo.preferredAction,
+ "The file should be launched with a helper app handler."
+ );
+ resolve();
+ };
+ });
+
+ let loadingTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + "file_pdf_application_pdf.pdf",
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+
+ await downloadFinishedPromise;
+ await launchFileCalled;
+ ok(!filePickerShown, "file picker should not have shown up.");
+ assertCorrectFile(dir, "file_pdf_application_pdf.pdf");
+
+ // Cleanup
+ BrowserTestUtils.removeTab(loadingTab);
+ DownloadIntegration.launchFile = oldLaunchFile;
+});
+
+async function setupFilePickerDirectory() {
+ let saveDir = createSaveDir();
+ Services.prefs.setComplexValue("browser.download.dir", Ci.nsIFile, saveDir);
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+
+ MockFilePicker.displayDirectory = saveDir;
+ MockFilePicker.returnValue = MockFilePicker.returnOK;
+ MockFilePicker.showCallback = function (fp) {
+ let file = saveDir.clone();
+ file.append(fp.defaultString);
+ MockFilePicker.setFiles([file]);
+ };
+
+ registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref("browser.download.dir");
+ Services.prefs.clearUserPref("browser.download.folderList");
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let unfinishedDownloads = new Set(
+ (await publicList.getAll()).filter(dl => !dl.succeeded && !dl.error)
+ );
+ if (unfinishedDownloads.size) {
+ info(`Have ${unfinishedDownloads.size} unfinished downloads, waiting.`);
+ await new Promise(resolve => {
+ let view = {
+ onChanged(dl) {
+ if (unfinishedDownloads.has(dl) && (dl.succeeded || dl.error)) {
+ unfinishedDownloads.delete(dl);
+ info(`Removed another download.`);
+ if (!unfinishedDownloads.size) {
+ publicList.removeView(view);
+ resolve();
+ }
+ }
+ },
+ };
+ publicList.addView(view);
+ });
+ }
+ try {
+ await IOUtils.remove(saveDir.path, { recursive: true });
+ } catch (e) {
+ console.error(e);
+ }
+ });
+
+ return saveDir;
+}
+
+function assertCorrectFile(saveDir, filename) {
+ info("Make sure additional files haven't been created.");
+ let iter = saveDir.directoryEntries;
+ let file = iter.nextFile;
+ ok(file.path.includes(filename), "Download has correct filename");
+ ok(!iter.nextFile, "Only one file was created.");
+}
+
+function createSaveDir() {
+ info("Creating save directory.");
+ let time = new Date().getTime();
+ let saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ saveDir.append(time);
+ return saveDir;
+}
+
+function ensureMIMEState(
+ { preferredAction, preferredHandlerApp = null },
+ { type = "application/pdf", ext = "pdf" } = {}
+) {
+ const mimeInfo = gMimeSvc.getFromTypeAndExtension(type, ext);
+ mimeInfo.preferredAction = preferredAction;
+ mimeInfo.preferredApplicationHandler = preferredHandlerApp;
+ mimeInfo.alwaysAskBeforeHandling = false;
+ gHandlerSvc.store(mimeInfo);
+}
diff --git a/uriloader/exthandler/tests/mochitest/browser_txt_download_save_as.js b/uriloader/exthandler/tests/mochitest/browser_txt_download_save_as.js
new file mode 100644
index 0000000000..7155c35fd9
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_txt_download_save_as.js
@@ -0,0 +1,168 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { DownloadIntegration } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadIntegration.sys.mjs"
+);
+const HandlerService = Cc[
+ "@mozilla.org/uriloader/handler-service;1"
+].getService(Ci.nsIHandlerService);
+const MIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+const {
+ saveToDisk,
+ alwaysAsk,
+ handleInternally,
+ useHelperApp,
+ useSystemDefault,
+} = Ci.nsIHandlerInfo;
+const testDir = createTemporarySaveDirectory();
+const MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/content/tests/browser/common/mockTransfer.js",
+ this
+);
+
+async function testSaveAsDownload() {
+ await BrowserTestUtils.withNewTab(
+ `data:text/html,<a id="test-link" href="${TEST_PATH}/file_txt_attachment_test.txt">Test TXT Link</a>`,
+ async browser => {
+ let menu = document.getElementById("contentAreaContextMenu");
+ ok(menu, "Context menu exists on the page");
+
+ let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "a#test-link",
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+ await popupShown;
+ info("Context menu popup was successfully displayed");
+
+ let filePickerPromise = setupFilePicker();
+
+ info("Clicking Save As... context menu");
+ let menuitem = menu.querySelector("#context-savelink");
+ menu.activateItem(menuitem);
+ await filePickerPromise;
+ }
+ );
+}
+
+async function setupFilePicker() {
+ return new Promise(resolve => {
+ MockFilePicker.returnValue = MockFilePicker.returnOK;
+ MockFilePicker.displayDirectory = testDir;
+ MockFilePicker.showCallback = fp => {
+ ok(true, "filepicker should be visible");
+ ok(
+ fp.defaultExtension === "txt",
+ "Default extension in filepicker should be txt"
+ );
+ ok(
+ fp.defaultString === "file_txt_attachment_test.txt",
+ "Default string name in filepicker should have the correct file name"
+ );
+ const destFile = testDir.clone();
+ destFile.append(fp.defaultString);
+ MockFilePicker.setFiles([destFile]);
+
+ mockTransferCallback = success => {
+ ok(success, "File should have been downloaded successfully");
+ ok(destFile.exists(), "File should exist in test directory");
+ resolve(destFile);
+ };
+ };
+ });
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.always_ask_before_handling_new_types", false],
+ ["browser.download.useDownloadDir", false],
+ ],
+ });
+ mockTransferRegisterer.register();
+
+ let oldLaunchFile = DownloadIntegration.launchFile;
+ DownloadIntegration.launchFile = () => {
+ ok(false, "Download should not have launched");
+ };
+
+ registerCleanupFunction(async () => {
+ DownloadIntegration.launchFile = oldLaunchFile;
+ mockTransferRegisterer.unregister();
+
+ // We only want to run MockFilerPicker.cleanup after the entire test is run.
+ // Otherwise, we cannot use MockFilePicker for each preferredAction.
+ MockFilePicker.cleanup();
+
+ testDir.remove(true);
+ ok(!testDir.exists(), "Test directory should be removed");
+ });
+});
+
+/**
+ * Tests that selecting the context menu item `Save Link As…` on a txt file link
+ * opens the file picker and only downloads the file without any launches when
+ * browser.download.always_ask_before_handling_new_types is disabled.
+ */
+add_task(async function test_txt_save_as_link() {
+ let mimeInfo;
+
+ for (let preferredAction of [
+ saveToDisk,
+ alwaysAsk,
+ handleInternally,
+ useHelperApp,
+ useSystemDefault,
+ ]) {
+ mimeInfo = MIMEService.getFromTypeAndExtension("text/plain", "txt");
+ mimeInfo.alwaysAskBeforeHandling = preferredAction === alwaysAsk;
+ mimeInfo.preferredAction = preferredAction;
+ HandlerService.store(mimeInfo);
+
+ info(
+ `Setting up filepicker with preferredAction ${preferredAction} and ask = ${mimeInfo.alwaysAskBeforeHandling}`
+ );
+ await testSaveAsDownload(mimeInfo);
+ }
+});
+
+/**
+ * Tests that selecting the context menu item `Save Link As…` on a txt file link
+ * opens the file picker and only downloads the file without any launches when
+ * browser.download.always_ask_before_handling_new_types is disabled. For this
+ * particular test, set alwaysAskBeforeHandling to true.
+ */
+add_task(async function test_txt_save_as_link_alwaysAskBeforeHandling() {
+ let mimeInfo;
+
+ for (let preferredAction of [
+ saveToDisk,
+ alwaysAsk,
+ handleInternally,
+ useHelperApp,
+ useSystemDefault,
+ ]) {
+ mimeInfo = MIMEService.getFromTypeAndExtension("text/plain", "txt");
+ mimeInfo.alwaysAskBeforeHandling = true;
+ mimeInfo.preferredAction = preferredAction;
+ HandlerService.store(mimeInfo);
+
+ info(
+ `Setting up filepicker with preferredAction ${preferredAction} and ask = ${mimeInfo.alwaysAskBeforeHandling}`
+ );
+ await testSaveAsDownload(mimeInfo);
+ }
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_web_handler_app_pinned_tab.js b/uriloader/exthandler/tests/mochitest/browser_web_handler_app_pinned_tab.js
new file mode 100644
index 0000000000..2f06833665
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_web_handler_app_pinned_tab.js
@@ -0,0 +1,81 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let testURL =
+ "http://mochi.test:8888/browser/" +
+ "uriloader/exthandler/tests/mochitest/mailto.html";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gExternalProtocolService",
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ "nsIExternalProtocolService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gHandlerService",
+ "@mozilla.org/uriloader/handler-service;1",
+ "nsIHandlerService"
+);
+
+let prevAlwaysAskBeforeHandling;
+let prevPreferredAction;
+let prevPreferredApplicationHandler;
+
+add_setup(async function () {
+ let handler = gExternalProtocolService.getProtocolHandlerInfo("mailto", {});
+
+ // Create a fake mail handler
+ const APP_NAME = "ExMail";
+ const HANDLER_URL = "https://example.com/?extsrc=mailto&url=%s";
+ let app = Cc["@mozilla.org/uriloader/web-handler-app;1"].createInstance(
+ Ci.nsIWebHandlerApp
+ );
+ app.uriTemplate = HANDLER_URL;
+ app.name = APP_NAME;
+
+ // Store defaults
+ prevAlwaysAskBeforeHandling = handler.alwaysAskBeforeHandling;
+ prevPreferredAction = handler.preferredAction;
+ prevPreferredApplicationHandler = handler.preferredApplicationHandler;
+
+ // Set the fake app as default
+ handler.alwaysAskBeforeHandling = false;
+ handler.preferredAction = Ci.nsIHandlerInfo.useHelperApp;
+ handler.preferredApplicationHandler = app;
+ gHandlerService.store(handler);
+});
+
+registerCleanupFunction(async function () {
+ let handler = gExternalProtocolService.getProtocolHandlerInfo("mailto", {});
+ handler.alwaysAskBeforeHandling = prevAlwaysAskBeforeHandling;
+ handler.preferredAction = prevPreferredAction;
+ handler.preferredApplicationHandler = prevPreferredApplicationHandler;
+ gHandlerService.store(handler);
+});
+
+add_task(async function () {
+ const expectedURL =
+ "https://example.com/?extsrc=mailto&url=mailto%3Amail%40example.com";
+
+ // Load a page with mailto handler.
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURIString(browser, testURL);
+ await BrowserTestUtils.browserLoaded(browser, false, testURL);
+
+ // Pin as an app tab
+ gBrowser.pinTab(gBrowser.selectedTab);
+
+ // Click the link and check the new tab is correct
+ let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, expectedURL);
+ await BrowserTestUtils.synthesizeMouseAtCenter("#link", {}, browser);
+ let tab = await promiseTabOpened;
+ is(
+ gURLBar.value,
+ expectedURL,
+ "the mailto web handler is opened in a new tab"
+ );
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/uriloader/exthandler/tests/mochitest/browser_web_protocol_handlers.js b/uriloader/exthandler/tests/mochitest/browser_web_protocol_handlers.js
new file mode 100644
index 0000000000..8046629219
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/browser_web_protocol_handlers.js
@@ -0,0 +1,124 @@
+let testURL =
+ "https://example.com/browser/" +
+ "uriloader/exthandler/tests/mochitest/protocolHandler.html";
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["security.external_protocol_requires_permission", false]],
+ });
+
+ // Load a page registering a protocol handler.
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURIString(browser, testURL);
+ await BrowserTestUtils.browserLoaded(browser, false, testURL);
+
+ // Register the protocol handler by clicking the notificationbar button.
+ let notificationValue = "Protocol Registration: web+testprotocol";
+ let getNotification = () =>
+ gBrowser.getNotificationBox().getNotificationWithValue(notificationValue);
+ await BrowserTestUtils.waitForCondition(getNotification);
+ let notification = getNotification();
+ let button = notification.buttonContainer.querySelector("button");
+ ok(button, "got registration button");
+ button.click();
+
+ // Set the new handler as default.
+ const protoSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService);
+ let protoInfo = protoSvc.getProtocolHandlerInfo("web+testprotocol");
+ is(
+ protoInfo.preferredAction,
+ protoInfo.useHelperApp,
+ "using a helper application is the preferred action"
+ );
+ ok(!protoInfo.preferredApplicationHandler, "no preferred handler is set");
+ let handlers = protoInfo.possibleApplicationHandlers;
+ is(1, handlers.length, "only one handler registered for web+testprotocol");
+ let handler = handlers.queryElementAt(0, Ci.nsIHandlerApp);
+ ok(handler instanceof Ci.nsIWebHandlerApp, "the handler is a web handler");
+ is(
+ handler.uriTemplate,
+ "https://example.com/foobar?uri=%s",
+ "correct url template"
+ );
+ protoInfo.preferredApplicationHandler = handler;
+ protoInfo.alwaysAskBeforeHandling = false;
+ const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+ );
+ handlerSvc.store(protoInfo);
+
+ const expectedURL =
+ "https://example.com/foobar?uri=web%2Btestprotocol%3Atest";
+
+ // Create a framed link:
+ await SpecialPowers.spawn(browser, [], async function () {
+ let iframe = content.document.createElement("iframe");
+ iframe.src = `data:text/html,<a href="web+testprotocol:test">Click me</a>`;
+ content.document.body.append(iframe);
+ // Can't return this promise because it resolves to the event object.
+ await ContentTaskUtils.waitForEvent(iframe, "load");
+ iframe.contentDocument.querySelector("a").click();
+ });
+ let kidContext = browser.browsingContext.children[0];
+ await TestUtils.waitForCondition(() => {
+ let spec = kidContext.currentWindowGlobal?.documentURI?.spec || "";
+ return spec == expectedURL;
+ });
+ is(
+ kidContext.currentWindowGlobal.documentURI.spec,
+ expectedURL,
+ "Should load in frame."
+ );
+
+ // Middle-click a testprotocol link and check the new tab is correct
+ let link = "#link";
+
+ let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, expectedURL);
+ await BrowserTestUtils.synthesizeMouseAtCenter(link, { button: 1 }, browser);
+ let tab = await promiseTabOpened;
+ gBrowser.selectedTab = tab;
+ is(
+ gURLBar.value,
+ expectedURL,
+ "the expected URL is displayed in the location bar"
+ );
+ BrowserTestUtils.removeTab(tab);
+
+ // Shift-click the testprotocol link and check the new window.
+ let newWindowPromise = BrowserTestUtils.waitForNewWindow({
+ url: expectedURL,
+ });
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ link,
+ { shiftKey: true },
+ browser
+ );
+ let win = await newWindowPromise;
+ await BrowserTestUtils.waitForCondition(
+ () => win.gBrowser.currentURI.spec == expectedURL
+ );
+ is(
+ win.gURLBar.value,
+ expectedURL,
+ "the expected URL is displayed in the location bar"
+ );
+ await BrowserTestUtils.closeWindow(win);
+
+ // Click the testprotocol link and check the url in the current tab.
+ let loadPromise = BrowserTestUtils.browserLoaded(browser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(link, {}, browser);
+ await loadPromise;
+ await BrowserTestUtils.waitForCondition(() => gURLBar.value != testURL);
+ is(
+ gURLBar.value,
+ expectedURL,
+ "the expected URL is displayed in the location bar"
+ );
+
+ // Cleanup.
+ protoInfo.preferredApplicationHandler = null;
+ handlers.removeElementAt(0);
+ handlerSvc.store(protoInfo);
+});
diff --git a/uriloader/exthandler/tests/mochitest/download.bin b/uriloader/exthandler/tests/mochitest/download.bin
new file mode 100644
index 0000000000..0e4b0c7bae
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/download.bin
@@ -0,0 +1 @@
+abc123
diff --git a/uriloader/exthandler/tests/mochitest/download.sjs b/uriloader/exthandler/tests/mochitest/download.sjs
new file mode 100644
index 0000000000..d02d2b7355
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/download.sjs
@@ -0,0 +1,42 @@
+"use strict";
+
+function actuallyHandleRequest(req, res) {
+ res.setHeader("Content-Type", "application/octet-stream", false);
+ res.write("abc123");
+ res.finish();
+}
+
+function handleRequest(req, res) {
+ if (req.queryString.includes("finish")) {
+ res.write("OK");
+ let downloadReq = null;
+ getObjectState("downloadReq", o => {
+ downloadReq = o;
+ });
+ // Two possibilities: either the download request has already reached us, or not.
+ if (downloadReq) {
+ downloadReq.wrappedJSObject.callback();
+ } else {
+ // Set a variable to allow the request to complete immediately:
+ setState("finishReq", "true");
+ }
+ } else if (req.queryString.includes("reset")) {
+ res.write("OK");
+ setObjectState("downloadReq", null);
+ setState("finishReq", "false");
+ } else {
+ res.processAsync();
+ if (getState("finishReq") === "true") {
+ actuallyHandleRequest(req, res);
+ } else {
+ let o = {
+ callback() {
+ actuallyHandleRequest(req, res);
+ },
+ };
+ o.wrappedJSObject = o;
+ o.QueryInterface = () => o;
+ setObjectState("downloadReq", o);
+ }
+ }
+}
diff --git a/uriloader/exthandler/tests/mochitest/download_page.html b/uriloader/exthandler/tests/mochitest/download_page.html
new file mode 100644
index 0000000000..5a264888fa
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/download_page.html
@@ -0,0 +1,22 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset=UTF-8>
+ <title>Test page for link clicking</title>
+ <script type="text/javascript">
+ function launch_download(extra) {
+ window.open("download.sjs", "_blank", "height=100,width=100" + extra);
+ }
+ </script>
+</head>
+<body>
+ <a href="download.bin" id="regular_load">regular load</a>
+ <a href="download.bin" id="target_blank" target="_blank" rel="opener">target blank</a>
+ <a href="#" onclick="launch_download(''); return false" id="new_window">new window</a>
+ <a href="#" onclick="window.open('download_page.html?newwin'); return false" id="open_in_new_tab">click to reopen</a>
+ <a href="download.bin" id="target_blank_no_opener" rel="noopener" target="_blank">target blank (noopener)</a>
+ <a href="#" onclick="window.open('download.bin', '_blank', 'noopener'); return false" id="open_in_new_tab_no_opener">click to reopen (noopener)</a>
+ <a href="#" onclick="launch_download(',noopener'); return false" id="new_window_no_opener">new window (noopener)</a>
+</body>
diff --git a/uriloader/exthandler/tests/mochitest/file_as.exe b/uriloader/exthandler/tests/mochitest/file_as.exe
new file mode 100644
index 0000000000..f2f5ab47f3
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_as.exe
@@ -0,0 +1 @@
+Not actually an executable... but let's pretend!
diff --git a/uriloader/exthandler/tests/mochitest/file_as.exe^headers^ b/uriloader/exthandler/tests/mochitest/file_as.exe^headers^
new file mode 100644
index 0000000000..89f22e30be
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_as.exe^headers^
@@ -0,0 +1,2 @@
+Content-Type: binary/octet-stream
+Content-Disposition: attachment
diff --git a/uriloader/exthandler/tests/mochitest/file_external_protocol_iframe.html b/uriloader/exthandler/tests/mochitest/file_external_protocol_iframe.html
new file mode 100644
index 0000000000..eb2fb74441
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_external_protocol_iframe.html
@@ -0,0 +1 @@
+<iframe src="mailto:foo@bar.com"></iframe>
diff --git a/uriloader/exthandler/tests/mochitest/file_green.webp b/uriloader/exthandler/tests/mochitest/file_green.webp
new file mode 100644
index 0000000000..04b7f003b4
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_green.webp
Binary files differ
diff --git a/uriloader/exthandler/tests/mochitest/file_green.webp^headers^ b/uriloader/exthandler/tests/mochitest/file_green.webp^headers^
new file mode 100644
index 0000000000..3f6afd6625
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_green.webp^headers^
@@ -0,0 +1,3 @@
+Content-Disposition: attachment; filename=file_green.webp
+Content-Type: image/webp
+
diff --git a/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg b/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg
new file mode 100644
index 0000000000..b730c4c492
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg
@@ -0,0 +1,3 @@
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg">
+ <rect x="5" y="5" width="10" height="10" fill="white"/>
+</svg>
diff --git a/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg^headers^ b/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg^headers^
new file mode 100644
index 0000000000..5279ae8636
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg^headers^
@@ -0,0 +1,2 @@
+content-disposition: attachment; filename=file_image_svgxml_svg; filename*=UTF-8''file_image_svgxml.svg
+content-type: image/svg+xml
diff --git a/uriloader/exthandler/tests/mochitest/file_nested_protocol_request.html b/uriloader/exthandler/tests/mochitest/file_nested_protocol_request.html
new file mode 100644
index 0000000000..b1bb863f89
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_nested_protocol_request.html
@@ -0,0 +1 @@
+<iframe srcdoc="<a href='mailto:help@example.com'>Mail someone</a>"></iframe>
diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf b/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf
diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf^headers^ b/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf^headers^
new file mode 100644
index 0000000000..9e8cb41cba
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf^headers^
@@ -0,0 +1,2 @@
+Content-Disposition: attachment; filename="file_pdf_application_octet_stream.pdf"; filename*=UTF-8''file_pdf_application_octet_stream.pdf
+Content-Type: application/octet-stream
diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf b/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf
diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf^headers^ b/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf^headers^
new file mode 100644
index 0000000000..d1d59b9754
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf^headers^
@@ -0,0 +1,2 @@
+content-disposition: attachment; filename=file_pdf_application_pdf.pdf; filename*=UTF-8''file_pdf_application_pdf.pdf
+content-type: application/pdf
diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf b/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf
diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf^headers^ b/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf^headers^
new file mode 100644
index 0000000000..157c0e0943
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf^headers^
@@ -0,0 +1,2 @@
+content-disposition: attachment; filename=file_pdf_application_unknown.pdf; filename*=UTF-8''file_pdf_application_unknown.pdf
+content-type: application/unknown
diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf b/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf
diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf^headers^ b/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf^headers^
new file mode 100644
index 0000000000..6358f54f48
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf^headers^
@@ -0,0 +1,2 @@
+Content-Disposition: attachment; filename="file_pdf_binary_octet_stream.pdf"; filename*=UTF-8''file_pdf_binary_octet_stream.pdf
+Content-Type: binary/octet-stream
diff --git a/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt b/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt
diff --git a/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt^headers^ b/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt^headers^
new file mode 100644
index 0000000000..dcfed6af23
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt^headers^
@@ -0,0 +1,2 @@
+Content-Disposition: attachment; filename=file_txt_attachment_test.txt
+Content-Type: text/plain
diff --git a/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png b/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png
new file mode 100644
index 0000000000..743292dc6f
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png
Binary files differ
diff --git a/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png^headers^ b/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png^headers^
new file mode 100644
index 0000000000..06e0cd957f
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png^headers^
@@ -0,0 +1,2 @@
+Content-Disposition: inline; filename=file_with%40%40funny_name.png
+Content-Type: image/png
diff --git a/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm b/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm
new file mode 100644
index 0000000000..7bc738b8b4
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm
Binary files differ
diff --git a/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm^headers^ b/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm^headers^
new file mode 100644
index 0000000000..b77e9d3687
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm^headers^
@@ -0,0 +1,2 @@
+Content-Disposition: inline; filename=file_with%5Bfunny_name.webm
+Content-Type: video/webm
diff --git a/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml b/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml
new file mode 100644
index 0000000000..3a5792586a
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml
@@ -0,0 +1,4 @@
+<?xml version = "1.0" encoding = "utf-8"?>
+
+<something>
+</something>
diff --git a/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml^headers^ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml^headers^
new file mode 100644
index 0000000000..5bdc4448e8
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml^headers^
@@ -0,0 +1,2 @@
+Content-Disposition: attachment
+Content-Type: binary/octet-stream
diff --git a/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml b/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml
new file mode 100644
index 0000000000..3a5792586a
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml
@@ -0,0 +1,4 @@
+<?xml version = "1.0" encoding = "utf-8"?>
+
+<something>
+</something>
diff --git a/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml^headers^ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml^headers^
new file mode 100644
index 0000000000..ac0355d976
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml^headers^
@@ -0,0 +1,2 @@
+Content-Disposition: attachment; filename=file_xml_attachment_test.xml
+Content-Type: text/xml
diff --git a/uriloader/exthandler/tests/mochitest/head.js b/uriloader/exthandler/tests/mochitest/head.js
new file mode 100644
index 0000000000..183aeee20e
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/head.js
@@ -0,0 +1,535 @@
+var { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+var { HandlerServiceTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/HandlerServiceTestUtils.sys.mjs"
+);
+
+var gMimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+var gHandlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+);
+
+function createMockedHandlerApp() {
+ // Mock the executable
+ let mockedExecutable = FileUtils.getFile("TmpD", ["mockedExecutable"]);
+ if (!mockedExecutable.exists()) {
+ mockedExecutable.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o755);
+ }
+
+ // Mock the handler app
+ let mockedHandlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ mockedHandlerApp.executable = mockedExecutable;
+ mockedHandlerApp.detailedDescription = "Mocked handler app";
+
+ registerCleanupFunction(function () {
+ // remove the mocked executable from disk.
+ if (mockedExecutable.exists()) {
+ mockedExecutable.remove(true);
+ }
+ });
+
+ return mockedHandlerApp;
+}
+
+function createMockedObjects(createHandlerApp) {
+ // Mock the mime info
+ let internalMockedMIME = gMimeSvc.getFromTypeAndExtension(
+ "text/x-test-handler",
+ null
+ );
+ internalMockedMIME.alwaysAskBeforeHandling = true;
+ internalMockedMIME.preferredAction = Ci.nsIHandlerInfo.useHelperApp;
+ internalMockedMIME.appendExtension("abc");
+ if (createHandlerApp) {
+ let mockedHandlerApp = createMockedHandlerApp();
+ internalMockedMIME.description = mockedHandlerApp.detailedDescription;
+ internalMockedMIME.possibleApplicationHandlers.appendElement(
+ mockedHandlerApp
+ );
+ internalMockedMIME.preferredApplicationHandler = mockedHandlerApp;
+ }
+
+ // Proxy for the mocked MIME info for faking the read-only attributes
+ let mockedMIME = new Proxy(internalMockedMIME, {
+ get(target, property) {
+ switch (property) {
+ case "hasDefaultHandler":
+ return true;
+ case "defaultDescription":
+ return "Default description";
+ default:
+ return target[property];
+ }
+ },
+ });
+
+ // Mock the launcher:
+ let mockedLauncher = {
+ MIMEInfo: mockedMIME,
+ source: Services.io.newURI("http://www.mozilla.org/"),
+ suggestedFileName: "test_download_dialog.abc",
+ targetFileIsExecutable: false,
+ saveToDisk() {},
+ cancel() {},
+ setDownloadToLaunch() {},
+ setWebProgressListener() {},
+ saveDestinationAvailable() {},
+ contentLength: 42,
+ targetFile: null, // never read
+ // PRTime is microseconds since epoch, Date.now() returns milliseconds:
+ timeDownloadStarted: Date.now() * 1000,
+ QueryInterface: ChromeUtils.generateQI([
+ "nsICancelable",
+ "nsIHelperAppLauncher",
+ ]),
+ };
+
+ registerCleanupFunction(function () {
+ // remove the mocked mime info from database.
+ let mockHandlerInfo = gMimeSvc.getFromTypeAndExtension(
+ "text/x-test-handler",
+ null
+ );
+ if (gHandlerSvc.exists(mockHandlerInfo)) {
+ gHandlerSvc.remove(mockHandlerInfo);
+ }
+ });
+
+ return mockedLauncher;
+}
+
+function createTemporarySaveDirectory() {
+ var saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ saveDir.append("testsavedir");
+ if (!saveDir.exists()) {
+ saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ }
+ return saveDir;
+}
+
+async function openHelperAppDialog(launcher) {
+ let helperAppDialog = Cc[
+ "@mozilla.org/helperapplauncherdialog;1"
+ ].createInstance(Ci.nsIHelperAppLauncherDialog);
+
+ let helperAppDialogShownPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ try {
+ helperAppDialog.show(launcher, window, "foopy");
+ } catch (ex) {
+ ok(
+ false,
+ "Trying to show unknownContentType.xhtml failed with exception: " + ex
+ );
+ console.error(ex);
+ }
+ let dlg = await helperAppDialogShownPromise;
+
+ is(
+ dlg.location.href,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ "Got correct dialog"
+ );
+
+ return dlg;
+}
+
+/**
+ * Wait for a subdialog event indicating a dialog either opened
+ * or was closed.
+ *
+ * First argument is the browser in which to listen. If a tabbrowser,
+ * we listen to subdialogs for any tab of that browser.
+ */
+async function waitForSubDialog(browser, url, state) {
+ let eventStr = state ? "dialogopen" : "dialogclose";
+
+ let eventTarget;
+
+ // Tabbrowser?
+ if (browser.tabContainer) {
+ eventTarget = browser.tabContainer.ownerDocument.documentElement;
+ } else {
+ // Individual browser. Get its box:
+ let tabDialogBox = browser.ownerGlobal.gBrowser.getTabDialogBox(browser);
+ eventTarget = tabDialogBox.getTabDialogManager()._dialogStack;
+ }
+
+ let checkFn;
+
+ if (state) {
+ checkFn = dialogEvent => dialogEvent.detail.dialog?._openedURL == url;
+ }
+
+ let event = await BrowserTestUtils.waitForEvent(
+ eventTarget,
+ eventStr,
+ true,
+ checkFn
+ );
+
+ let { dialog } = event.detail;
+
+ // If the dialog is closing wait for it to be fully closed before resolving
+ if (!state) {
+ await dialog._closingPromise;
+ }
+
+ return event.detail.dialog;
+}
+
+/**
+ * Wait for protocol permission dialog open/close.
+ * @param {MozBrowser} browser - Browser element the dialog belongs to.
+ * @param {boolean} state - true: dialog open, false: dialog close
+ * @returns {Promise<SubDialog>} - Returns a promise which resolves with the
+ * SubDialog object of the dialog which closed or opened.
+ */
+async function waitForProtocolPermissionDialog(browser, state) {
+ return waitForSubDialog(
+ browser,
+ "chrome://mozapps/content/handling/permissionDialog.xhtml",
+ state
+ );
+}
+
+/**
+ * Wait for protocol app chooser dialog open/close.
+ * @param {MozBrowser} browser - Browser element the dialog belongs to.
+ * @param {boolean} state - true: dialog open, false: dialog close
+ * @returns {Promise<SubDialog>} - Returns a promise which resolves with the
+ * SubDialog object of the dialog which closed or opened.
+ */
+async function waitForProtocolAppChooserDialog(browser, state) {
+ return waitForSubDialog(
+ browser,
+ "chrome://mozapps/content/handling/appChooser.xhtml",
+ state
+ );
+}
+
+async function promiseDownloadFinished(list, stopFromOpening) {
+ return new Promise(resolve => {
+ list.addView({
+ onDownloadChanged(download) {
+ if (stopFromOpening) {
+ download.launchWhenSucceeded = false;
+ }
+ info("Download changed!");
+ if (download.succeeded || download.error) {
+ info("Download succeeded or errored");
+ list.removeView(this);
+ resolve(download);
+ }
+ },
+ });
+ });
+}
+
+function setupMailHandler() {
+ let mailHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("mailto");
+ let gOldMailHandlers = [];
+
+ // Remove extant web handlers because they have icons that
+ // we fetch from the web, which isn't allowed in tests.
+ let handlers = mailHandlerInfo.possibleApplicationHandlers;
+ for (let i = handlers.Count() - 1; i >= 0; i--) {
+ try {
+ let handler = handlers.queryElementAt(i, Ci.nsIWebHandlerApp);
+ gOldMailHandlers.push(handler);
+ // If we get here, this is a web handler app. Remove it:
+ handlers.removeElementAt(i);
+ } catch (ex) {}
+ }
+
+ let previousHandling = mailHandlerInfo.alwaysAskBeforeHandling;
+ mailHandlerInfo.alwaysAskBeforeHandling = true;
+
+ // Create a dummy web mail handler so we always know the mailto: protocol.
+ // Without this, the test fails on VMs without a default mailto: handler,
+ // because no dialog is ever shown, as we ignore subframe navigations to
+ // protocols that cannot be handled.
+ let dummy = Cc["@mozilla.org/uriloader/web-handler-app;1"].createInstance(
+ Ci.nsIWebHandlerApp
+ );
+ dummy.name = "Handler 1";
+ dummy.uriTemplate = "https://example.com/first/%s";
+ mailHandlerInfo.possibleApplicationHandlers.appendElement(dummy);
+
+ gHandlerSvc.store(mailHandlerInfo);
+ registerCleanupFunction(() => {
+ // Re-add the original protocol handlers:
+ let mailHandlers = mailHandlerInfo.possibleApplicationHandlers;
+ for (let i = handlers.Count() - 1; i >= 0; i--) {
+ try {
+ // See if this is a web handler. If it is, it'll throw, otherwise,
+ // we will remove it.
+ mailHandlers.queryElementAt(i, Ci.nsIWebHandlerApp);
+ mailHandlers.removeElementAt(i);
+ } catch (ex) {}
+ }
+ for (let h of gOldMailHandlers) {
+ mailHandlers.appendElement(h);
+ }
+ mailHandlerInfo.alwaysAskBeforeHandling = previousHandling;
+ gHandlerSvc.store(mailHandlerInfo);
+ });
+}
+
+let gDownloadDir;
+
+async function setDownloadDir() {
+ let tmpDir = PathUtils.join(
+ PathUtils.tempDir,
+ "testsavedir" + Math.floor(Math.random() * 2 ** 32)
+ );
+ // Create this dir if it doesn't exist (ignores existing dirs)
+ await IOUtils.makeDirectory(tmpDir);
+ registerCleanupFunction(async function () {
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+ try {
+ await IOUtils.remove(tmpDir, { recursive: true });
+ } catch (e) {
+ console.error(e);
+ }
+ });
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setCharPref("browser.download.dir", tmpDir);
+ return tmpDir;
+}
+
+add_setup(async function test_common_initialize() {
+ gDownloadDir = await setDownloadDir();
+ Services.prefs.setCharPref("browser.download.loglevel", "Debug");
+ registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("browser.download.loglevel");
+ });
+});
+
+async function removeAllDownloads() {
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let downloads = await publicList.getAll();
+ for (let download of downloads) {
+ await publicList.remove(download);
+ if (await IOUtils.exists(download.target.path)) {
+ await download.finalize(true);
+ }
+ }
+}
+
+// Helpers for external protocol sandbox tests.
+const EXT_PROTO_URI_MAILTO = "mailto:test@example.com";
+
+/**
+ * Creates and iframe and navigate to an external protocol from the iframe.
+ * @param {MozBrowser} browser - Browser to spawn iframe in.
+ * @param {string} sandboxAttr - Sandbox attribute value for the iframe.
+ * @param {'trustedClick'|'untrustedClick'|'trustedLocationAPI'|'untrustedLocationAPI'|'frameSrc'|'frameSrcRedirect'} triggerMethod
+ * - How to trigger the navigation to the external protocol.
+ */
+async function navigateExternalProtoFromIframe(
+ browser,
+ sandboxAttr,
+ useCSPSandbox = false,
+ triggerMethod = "trustedClick"
+) {
+ if (
+ ![
+ "trustedClick",
+ "untrustedClick",
+ "trustedLocationAPI",
+ "untrustedLocationAPI",
+ "frameSrc",
+ "frameSrcRedirect",
+ ].includes(triggerMethod)
+ ) {
+ throw new Error("Invalid trigger method " + triggerMethod);
+ }
+
+ // Construct the url to use as iframe src.
+ let testPath = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ );
+ let frameSrc = testPath + "/protocol_custom_sandbox_helper.sjs";
+
+ // Load the external protocol directly via the frame src field.
+ if (triggerMethod == "frameSrc") {
+ frameSrc = EXT_PROTO_URI_MAILTO;
+ } else if (triggerMethod == "frameSrcRedirect") {
+ let url = new URL(frameSrc);
+ url.searchParams.set("redirectCustomProtocol", "true");
+ frameSrc = url.href;
+ }
+
+ // If enabled set the sandbox attributes via CSP header instead. To do
+ // this we need to pass the sandbox flags to the test server via query
+ // params.
+ if (useCSPSandbox) {
+ let url = new URL(frameSrc);
+ url.searchParams.set("cspSandbox", sandboxAttr);
+ frameSrc = url.href;
+
+ // If we use CSP sandbox attributes we shouldn't set any via iframe attribute.
+ sandboxAttr = null;
+ }
+
+ // Create a sandboxed iframe and navigate to the external protocol.
+ await SpecialPowers.spawn(
+ browser,
+ [sandboxAttr, frameSrc, EXT_PROTO_URI_MAILTO, triggerMethod],
+ async (sandbox, src, extProtoURI, trigger) => {
+ let frame = content.document.createElement("iframe");
+
+ if (sandbox != null) {
+ frame.sandbox = sandbox;
+ }
+
+ frame.src = src;
+
+ let useFrameSrc = trigger == "frameSrc" || trigger == "frameSrcRedirect";
+
+ // Create frame load promise.
+ let frameLoadPromise;
+ // We won't get a load event if we directly put the external protocol in
+ // the frame src.
+ if (!useFrameSrc) {
+ frameLoadPromise = ContentTaskUtils.waitForEvent(frame, "load", false);
+ }
+
+ content.document.body.appendChild(frame);
+ await frameLoadPromise;
+
+ if (!useFrameSrc) {
+ // Trigger the external protocol navigation in the iframe. We test
+ // navigation by clicking links and navigation via the history API.
+ await SpecialPowers.spawn(
+ frame,
+ [extProtoURI, trigger],
+ async (uri, trigger2) => {
+ let link = content.document.createElement("a");
+ link.innerText = "CLICK ME";
+ link.id = "extProtoLink";
+ content.document.body.appendChild(link);
+
+ if (trigger2 == "trustedClick" || trigger2 == "untrustedClick") {
+ link.href = uri;
+ } else if (
+ trigger2 == "trustedLocationAPI" ||
+ trigger2 == "untrustedLocationAPI"
+ ) {
+ link.setAttribute("onclick", `location.href = '${uri}'`);
+ }
+
+ if (
+ trigger2 == "untrustedClick" ||
+ trigger2 == "untrustedLocationAPI"
+ ) {
+ link.click();
+ } else if (
+ trigger2 == "trustedClick" ||
+ trigger2 == "trustedLocationAPI"
+ ) {
+ await ContentTaskUtils.waitForCondition(
+ () => link,
+ "wait for link to be present"
+ );
+ await EventUtils.synthesizeMouseAtCenter(link, {}, content);
+ }
+ }
+ );
+ }
+ }
+ );
+}
+
+/**
+ * Wait for the sandbox error message which is shown in the web console when an
+ * external protocol navigation from a sandboxed context is blocked.
+ * @returns {Promise} - Promise which resolves once message has been logged.
+ */
+function waitForExtProtocolSandboxError() {
+ return new Promise(resolve => {
+ Services.console.registerListener(function onMessage(msg) {
+ let { message, logLevel } = msg;
+ if (logLevel != Ci.nsIConsoleMessage.error) {
+ return;
+ }
+ if (
+ !message.includes(
+ `Blocked navigation to custom protocol “${EXT_PROTO_URI_MAILTO}” from a sandboxed context.`
+ )
+ ) {
+ return;
+ }
+ Services.console.unregisterListener(onMessage);
+ resolve();
+ });
+ });
+}
+
+/**
+ * Run the external protocol sandbox test using iframes.
+ * @param {Object} options
+ * @param {boolean} options.blocked - Whether the navigation should be blocked.
+ * @param {string} options.sandbox - See {@link navigateExternalProtoFromIframe}.
+ * @param {string} options.useCSPSandbox - See {@link navigateExternalProtoFromIframe}.
+ * @param {string} options.triggerMethod - See {@link navigateExternalProtoFromIframe}.
+ * @returns {Promise} - Promise which resolves once the test has finished.
+ */
+function runExtProtocolSandboxTest(options) {
+ let { blocked, sandbox, useCSPSandbox = false, triggerMethod } = options;
+
+ let testPath = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ );
+
+ info("runSandboxTest options: " + JSON.stringify(options));
+ return BrowserTestUtils.withNewTab(
+ testPath + "/protocol_custom_sandbox_helper.sjs",
+ async browser => {
+ if (blocked) {
+ let errorPromise = waitForExtProtocolSandboxError();
+ await navigateExternalProtoFromIframe(
+ browser,
+ sandbox,
+ useCSPSandbox,
+ triggerMethod
+ );
+ await errorPromise;
+
+ ok(
+ errorPromise,
+ "Should not show the dialog for iframe with sandbox " + sandbox
+ );
+ } else {
+ let dialogWindowOpenPromise = waitForProtocolAppChooserDialog(
+ browser,
+ true
+ );
+ await navigateExternalProtoFromIframe(
+ browser,
+ sandbox,
+ useCSPSandbox,
+ triggerMethod
+ );
+ let dialog = await dialogWindowOpenPromise;
+
+ ok(dialog, "Should show the dialog for sandbox " + sandbox);
+
+ // Close dialog before closing the tab to avoid intermittent failures.
+ let dialogWindowClosePromise = waitForProtocolAppChooserDialog(
+ browser,
+ false
+ );
+
+ dialog.close();
+ await dialogWindowClosePromise;
+ }
+ }
+ );
+}
diff --git a/uriloader/exthandler/tests/mochitest/invalidCharFileExtension.sjs b/uriloader/exthandler/tests/mochitest/invalidCharFileExtension.sjs
new file mode 100644
index 0000000000..8afa04cfe0
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/invalidCharFileExtension.sjs
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function handleRequest(request, response) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+
+ if (!request.queryString.match(/^name=/)) {
+ return;
+ }
+ var name = decodeURIComponent(request.queryString.substring(5));
+
+ response.setHeader("Content-Type", 'image/png; name="' + name + '"');
+ response.setHeader(
+ "Content-Disposition",
+ 'attachment; filename="' + name + '"'
+ );
+}
diff --git a/uriloader/exthandler/tests/mochitest/mailto.html b/uriloader/exthandler/tests/mochitest/mailto.html
new file mode 100644
index 0000000000..d507697443
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/mailto.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Mailto handler</title>
+ <meta content="text/html;charset=utf-8" http-equiv="Content-Type">
+ <meta content="utf-8" http-equiv="encoding">
+ </head>
+ <body>
+ <a id="link" href="mailto:mail@example.com">mailto link</a>
+ </body>
+</html>
diff --git a/uriloader/exthandler/tests/mochitest/mime_type_download.sjs b/uriloader/exthandler/tests/mochitest/mime_type_download.sjs
new file mode 100644
index 0000000000..a33331d0cf
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/mime_type_download.sjs
@@ -0,0 +1,21 @@
+function handleRequest(request, response) {
+ "use strict";
+ Cu.importGlobalProperties(["URLSearchParams"]);
+ let content = "";
+ let params = new URLSearchParams(request.queryString);
+ let extension = params.get("extension");
+ let contentType = params.get("contentType");
+ if (params.has("withHeader")) {
+ response.setHeader(
+ "Content-Disposition",
+ `attachment; filename="mime_type_download${
+ extension ? "." + extension : ""
+ }";`,
+ false
+ );
+ }
+ response.setHeader("Content-Type", contentType, false);
+ response.setHeader("Content-Length", "" + content.length, false);
+ response.setStatusLine(request.httpVersion, 200);
+ response.write(content);
+}
diff --git a/uriloader/exthandler/tests/mochitest/mochitest.ini b/uriloader/exthandler/tests/mochitest/mochitest.ini
new file mode 100644
index 0000000000..c2ab1f5099
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/mochitest.ini
@@ -0,0 +1,17 @@
+[test_invalidCharFileExtension.xhtml]
+skip-if = toolkit == 'android' # Bug 1525959
+support-files =
+ HelperAppLauncherDialog_chromeScript.js
+ invalidCharFileExtension.sjs
+[test_nullCharFile.xhtml]
+skip-if = toolkit == 'android' # Bug 1525959
+support-files =
+ HelperAppLauncherDialog_chromeScript.js
+[test_unknown_ext_protocol_handlers.html]
+[test_unsafeBidiChars.xhtml]
+skip-if =
+ toolkit == 'android' # Bug 1525959
+ http3
+support-files =
+ HelperAppLauncherDialog_chromeScript.js
+ unsafeBidiFileName.sjs
diff --git a/uriloader/exthandler/tests/mochitest/protocolHandler.html b/uriloader/exthandler/tests/mochitest/protocolHandler.html
new file mode 100644
index 0000000000..eff8a53aab
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/protocolHandler.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Protocol handler</title>
+ <meta content="text/html;charset=utf-8" http-equiv="Content-Type">
+ <meta content="utf-8" http-equiv="encoding">
+ </head>
+ <body>
+ <script type="text/javascript">
+ navigator.registerProtocolHandler("web+testprotocol",
+ "https://example.com/foobar?uri=%s",
+ "Test Protocol");
+ </script>
+ <a id="link" href="web+testprotocol:test">testprotocol link</a>
+ </body>
+</html>
diff --git a/uriloader/exthandler/tests/mochitest/protocol_custom_sandbox_helper.sjs b/uriloader/exthandler/tests/mochitest/protocol_custom_sandbox_helper.sjs
new file mode 100644
index 0000000000..faf2937a08
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/protocol_custom_sandbox_helper.sjs
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function handleRequest(request, response) {
+ Cu.importGlobalProperties(["URLSearchParams"]);
+
+ let query = new URLSearchParams(request.queryString);
+
+ // Set CSP sandbox attributes if caller requests any.
+ let cspSandbox = query.get("cspSandbox");
+ if (cspSandbox) {
+ response.setHeader(
+ "Content-Security-Policy",
+ "sandbox " + cspSandbox,
+ false
+ );
+ }
+
+ // Redirect to custom protocol via HTTP 302.
+ if (query.get("redirectCustomProtocol")) {
+ response.setStatusLine(request.httpVersion, 302, "Found");
+
+ response.setHeader("Location", "mailto:test@example.com", false);
+ response.write("Redirect!");
+ return;
+ }
+
+ response.setStatusLine(request.httpVersion, 200);
+ response.write("OK");
+}
diff --git a/uriloader/exthandler/tests/mochitest/redirect_helper.sjs b/uriloader/exthandler/tests/mochitest/redirect_helper.sjs
new file mode 100644
index 0000000000..5c1068bebb
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/redirect_helper.sjs
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.importGlobalProperties(["URLSearchParams"]);
+
+function handleRequest(request, response) {
+ let params = new URLSearchParams(request.queryString);
+ let uri = params.get("uri");
+ let redirectType = params.get("redirectType") || "location";
+ switch (redirectType) {
+ case "refresh":
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Refresh", "0; url=" + uri);
+ break;
+
+ case "meta-refresh":
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html");
+ response.write(`<meta http-equiv="refresh" content="0; url=${uri}">`);
+ break;
+
+ case "location":
+ // fall through
+ default:
+ response.setStatusLine(request.httpVersion, 302, "Moved Temporarily");
+ response.setHeader("Location", uri);
+ }
+}
diff --git a/uriloader/exthandler/tests/mochitest/save_filenames.html b/uriloader/exthandler/tests/mochitest/save_filenames.html
new file mode 100644
index 0000000000..1535a0f657
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/save_filenames.html
@@ -0,0 +1,360 @@
+<html>
+<head>
+<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
+</head>
+<body>
+<style>
+ img { padding: 10px; border: 1px solid red; }
+ a { padding-left: 10px; }
+</style>
+
+<span id="items">
+
+<!-- simple filename -->
+<img id="i0" src="http://localhost:8000/basic.png"
+ data-noattach="true" data-filename="basic.png">
+
+<!-- simple filename with content disposition -->
+<img id="i1" src="http://localhost:8000/save_filename.sjs?type=png&filename=simple.png" data-filename="simple.png">
+
+<!-- invalid characters in the filename -->
+<img id="i2" src="http://localhost:8000/save_filename.sjs?type=png&filename=invalidfilename/a:b*c%63d.png" data-filename="invalidfilename_a b ccd.png">
+
+<!-- invalid extension for a png image -->
+<img id="i3" src="http://localhost:8000/save_filename.sjs?type=png&filename=invalidextension.pang" data-filename="invalidextension.png">
+
+<!-- jpeg extension for a png image -->
+<img id="i4" src="http://localhost:8000/save_filename.sjs?type=png&filename=reallyapng.jpeg" data-filename="reallyapng.png">
+
+<!-- txt extension for a png image -->
+<img id="i5" src="http://localhost:8000/save_filename.sjs?type=png&filename=nottext.txt" data-filename="nottext.png">
+
+<!-- no extension for a png image -->
+<img id="i6" src="http://localhost:8000/save_filename.sjs?type=png&filename=noext" data-filename="noext.png">
+
+<!-- empty extension for a png image -->
+<img id="i7" src="http://localhost:8000/save_filename.sjs?type=png&filename=noextdot." data-filename="noextdot.png">
+
+<!-- long filename -->
+<img id="i8" src="http://localhost:8000/save_filename.sjs?type=png&filename=averylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilename.png"
+ data-filename="averylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongf.png">
+
+<!-- long filename with invalid extension -->
+<img id="i9" src="http://localhost:8000/save_filename.sjs?type=png&filename=bverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilename.exe"
+ data-filename="bverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongf.png">
+
+<!-- long filename with invalid extension -->
+<img id="i10" src="http://localhost:8000/save_filename.sjs?type=png&filename=cverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilename.exe.jpg"
+ data-filename="cverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongf.png">
+
+<!-- jpeg with jpg extension -->
+<img id="i11" src="http://localhost:8000/save_filename.sjs?type=jpeg&filename=thejpg.jpg" data-filename="thejpg.jpg">
+
+<!-- jpeg with jpeg extension -->
+<img id="i12" src="http://localhost:8000/save_filename.sjs?type=jpeg&filename=thejpg.jpeg" data-filename="thejpg.jpeg">
+
+<!-- jpeg with invalid extension -->
+<img id="i13" src="http://localhost:8000/save_filename.sjs?type=jpeg&filename=morejpg.exe" data-filename="morejpg.jpg" data-filename-platformlinux="morejpg.jpeg">
+
+<!-- jpeg with multiple extensions -->
+<img id="i14" src="http://localhost:8000/save_filename.sjs?type=jpeg&filename=anotherjpg.jpg.exe" data-filename="anotherjpg.jpg.jpg" data-filename-platformlinux="anotherjpg.jpg.jpeg">
+
+<!-- jpeg with no filename portion -->
+<img id="i15" src="http://localhost:8000/save_filename.sjs?type=jpeg&filename=.jpg"
+ data-filename="jpg.jpg" data-filename-platformlinux="jpg.jpeg">
+
+<!-- png with no filename portion and invalid extension -->
+<img id="i16" src="http://localhost:8000/save_filename.sjs?type=png&filename=.exe" data-filename="exe.png">
+
+<!-- png with escaped characters -->
+<img id="i17" src="http://localhost:8000/save_filename.sjs?type=png&filename=first%20file.png" data-filename="first file.png">
+
+<!-- png with more escaped characters -->
+<img id="i18" src="http://localhost:8000/save_filename.sjs?type=png&filename=second%32file%2Eexe" data-filename="second2file.png">
+
+<!-- unknown type with png extension -->
+<img id="i19" src="http://localhost:8000/save_filename.sjs?type=nonsense&filename=nonsense1.png"
+ data-nodrag="true" data-unknown="typeonly" data-filename="nonsense1.png">
+
+<!-- unknown type with exe extension -->
+<img id="i20" src="http://localhost:8000/save_filename.sjs?type=nonsense&filename=nonsense2.exe"
+ data-nodrag="true" data-unknown="typeonly" data-filename="nonsense2.exe">
+
+<!-- unknown type with no extension -->
+<img id="i21" src="http://localhost:8000/save_filename.sjs?type=nonsense&filename=nonsense3"
+ data-nodrag="true" data-unknown="typeonly" data-filename="nonsense3">
+
+<!-- simple script -->
+<script id="i22" src="http://localhost:8000/save_filename.sjs?type=js&filename=script1.js" data-filename="script1.js"></script>
+
+<!-- script with invalid extension. -->
+<script id="i23" src="http://localhost:8000/save_filename.sjs?type=js&filename=script2.exe"
+ data-filename="script2.exe" data-savepagename="script2.exe.js"></script>
+
+<!-- script with escaped characters -->
+<script id="i24" src="http://localhost:8000/save_filename.sjs?type=js&filename=script%20%33.exe"
+ data-filename="script 3.exe" data-savepagename="script 3.exe.js"></script>
+
+<!-- script with long filename -->
+<script id="i25" src="http://localhost:8000/save_filename.sjs?type=js&filename=script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789.js"
+ data-filename="script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script12345.js"></script>
+
+<!-- binary with exe extension -->
+<object id="i26" data="http://localhost:8000/save_filename.sjs?type=binary&filename=download1.exe"
+ data-unknown="true" data-filename="download1.exe"></object>
+
+<!-- binary with invalid extension -->
+<object id="i27" data="http://localhost:8000/save_filename.sjs?type=binary&filename=download2.png"
+ data-unknown="true" data-filename="download2.png"></object>
+
+<!-- binary with no extension -->
+<object id="i28" data="http://localhost:8000/save_filename.sjs?type=binary&filename=downloadnoext"
+ data-unknown="true" data-filename="downloadnoext"></object>
+
+<!-- binary with no other invalid characters -->
+<object id="i29" data="http://localhost:8000/save_filename.sjs?type=binary&filename=binary^%31%20exe.exe"
+ data-unknown="true" data-filename="binary^1 exe.exe"></object>
+
+<!-- unknown image type with no extension, but ending in png -->
+<img id="i30" src="http://localhost:8000/save_filename.sjs?type=otherimage&filename=specialpng"
+ data-unknown="typeonly" data-nodrag="true" data-filename="specialpng">
+
+<!-- unknown image type with no extension, but ending in many dots -->
+<img id="i31" src="http://localhost:8000/save_filename.sjs?type=otherimage&filename=extrapng..."
+ data-unknown="typeonly" data-nodrag="true" data-filename="extrapng">
+
+<!-- image type with no content-disposition filename specified -->
+<img id="i32" src="http://localhost:8000/save_filename.sjs?type=png" data-filename="save_filename.png">
+
+<!-- binary with no content-disposition filename specified -->
+<object id="i33" data="http://localhost:8000/save_filename.sjs?type=binary"
+ data-unknown="true" data-filename="save_filename.sjs"></object>
+
+<!-- image where url has png extension -->
+<img id="i34" src="http://localhost:8000/getdata.png?type=png&filename=override.png" data-filename="override.png">
+
+<!-- image where url has png extension but content disposition has incorrect extension -->
+<img id="i35" src="http://localhost:8000/getdata.png?type=png&filename=flower.jpeg" data-filename="flower.png">
+
+<!-- image where url has png extension but content disposition does not -->
+<img id="i36" src="http://localhost:8000/getdata.png?type=png&filename=ruby" data-filename="ruby.png">
+
+<!-- image where url has png extension but content disposition has invalid characters -->
+<img id="i37" src="http://localhost:8000/getdata.png?type=png&filename=sapphire/data" data-filename="sapphire_data.png">
+
+<!-- image where neither content disposition or url have an extension -->
+<img id="i38" src="http://localhost:8000/base?type=png&filename=emerald" data-filename="emerald.png">
+
+<!-- image where filename is not specified -->
+<img id="i39" src="http://localhost:8000/base?type=png" data-filename="base.png">
+
+<!-- simple script where url filename has no extension -->
+<script id="i40" src="http://localhost:8000/base?type=js&filename=script4.js" data-filename="script4.js"></script>
+
+<!-- script where url filename has no extension and invalid extension in content disposition filename -->
+<script id="i41" src="http://localhost:8000/base?type=js&filename=script5.exe"
+ data-filename="script5.exe" data-savepagename="script5.exe.js"></script>
+
+<!-- script where url filename has no extension and escaped characters in content disposition filename-->
+<script id="i42" src="http://localhost:8000/base?type=js&filename=script%20%36.exe"
+ data-filename="script 6.exe" data-savepagename="script 6.exe.js"></script>
+
+<!-- text where filename is present -->
+<img id="i43" src="http://localhost:8000/getdata.png?type=text&filename=readme.txt"
+ data-nodrag="true" data-filename="readme.txt">
+
+<!-- text where filename is present with a different extension -->
+<img id="i44" src="http://localhost:8000/getdata.png?type=text&filename=main.cpp"
+ data-nodrag="true" data-filename="main.cpp">
+
+<!-- text where extension is not present -->
+<img id="i45" src="http://localhost:8000/getdata.png?type=text&filename=readme"
+ data-nodrag="true" data-filename="readme">
+
+<!-- text where extension is not present and url does not have extension -->
+<img id="i46" src="http://localhost:8000/base?type=text&filename=info"
+ data-nodrag="true" data-filename="info">
+
+<!-- text where filename is not present -->
+<img id="i47" src="http://localhost:8000/basetext?type=text"
+ data-nodrag="true" data-filename="basetext">
+
+<!-- text where url has extension -->
+<img id="i48" src="http://localhost:8000/text2.txt?type=text"
+ data-nodrag="true" data-filename="text2.txt">
+
+<!-- text where url has extension -->
+<img id="i49" src="http://localhost:8000/text3.gonk?type=text"
+ data-nodrag="true" data-filename="text3.gonk">
+
+<!-- text with long filename -->
+<img id="i50" src="http://localhost:8000/text3.gonk?type=text&filename=text0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789text0123456789zztext0123456789zztext0123456789zztext01234567.exe.txt" data-nodrag="true" data-filename="text0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext012345.txt">
+
+<!-- webp image -->
+<img id="i51" src="http://localhost:8000/save_filename.sjs?type=webp&filename=webpimage.webp"
+ data-filename="webpimage.webp">
+
+<!-- webp image with jpg extension -->
+<img id="i52" src="http://localhost:8000/save_filename.sjs?type=webp&filename=realwebpimage.jpg"
+ data-filename="realwebpimage.webp">
+
+<!-- no content type specified -->
+<img id="i53" src="http://localhost:8000/save_filename.sjs?&filename=notype.png"
+ data-nodrag="true" data-filename="notype.png">
+
+<!-- no content type specified. -->
+<img id="i54" src="http://localhost:8000/save_filename.sjs?&filename=notypebin.exe"
+ data-nodrag="true" data-filename="notypebin.exe">
+
+<!-- extension contains invalid characters -->
+<img id="i55" src="http://localhost:8000/save_filename.sjs?type=png&filename=extinvalid.a?*"
+ data-filename="extinvalid.png">
+
+<!-- filename with redirect and content disposition -->
+<img id="i56" src="http://localhost:8000/redir?type=png&filename=red.png" data-filename="red.png">
+
+<!-- filename with redirect and different type -->
+<img id="i57" src="http://localhost:8000/redir?type=jpeg&filename=green.png"
+ data-filename="green.jpg" data-filename-platformlinux="green.jpeg">
+
+<!-- filename with redirect and binary type -->
+<object id="i58" data="http://localhost:8000/redir?type=binary&filename=blue.png"
+ data-unknown="true" data-filename="blue.png"></object>
+
+<!-- filename in url with incorrect extension -->
+<img id="i59" src="http://localhost:8000/aquamarine.jpeg"
+ data-noattach="true" data-filename="aquamarine.png">
+
+<!-- filename in url with exe extension, but returns a png image -->
+<img id="i60" src="http://localhost:8000/lazuli.exe"
+ data-noattach="true" data-filename="lazuli.png">
+
+<!-- filename with leading, trailing and duplicate spaces -->
+<img id="i61" src="http://localhost:8000/save_filename.sjs?type=png&filename= with spaces.png "
+ data-filename="with spaces.png">
+
+<!-- filename with leading and trailing periods -->
+<img id="i62" src="http://localhost:8000/save_filename.sjs?type=png&filename=..with..dots..png.."
+ data-filename="with..dots..png">
+
+<!-- filename with non-ascii character -->
+<img id="i63" src="http://localhost:8000/base?type=png&filename=s%C3%B6meescapes.%C3%B7ng" data-filename="sömeescapes.png">
+
+<!-- filename with content disposition name assigned. The name is only used
+ when selecting to manually save, otherwise it is ignored. -->
+<img id="i64" src="http://localhost:8000/save_thename.sjs?type=png&dispname=withname"
+ data-filename="save_thename.png">
+
+<!-- reserved filename on Windows -->
+<img id="i65" src="http://localhost:8000/save_filename.sjs?type=text&filename=com1"
+ data-nodrag="true" data-filename="com1" data-filename-platformwin="Untitled">
+
+<!-- reserved filename with extension on Windows -->
+<img id="i66" src="http://localhost:8000/save_filename.sjs?type=text&filename=com2.any"
+ data-nodrag="true" data-filename="com2.any" data-filename-platformwin="Untitled">
+
+<!-- simple zip file -->
+<object id="i67" data="http://localhost:8000/save_filename.sjs?type=zip&filename=simple.zip" data-filename="simple.zip"
+ data-unknown="true"></object>
+
+<!-- simple zip file with differing extension -->
+<object id="i68" data="http://localhost:8000/save_filename.sjs?type=zip&filename=simple.jar" data-filename="simple.jar"
+ data-unknown="true"></object>
+
+<!-- simple zip file with no extension -->
+<object id="i69" data="http://localhost:8000/save_filename.sjs?type=zip&filename=simplepack" data-filename="simplepack.zip"
+ data-unknown="true"></object>
+
+<!-- simple json file -->
+<object id="i70" data="http://localhost:8000/save_filename.sjs?type=json&filename=simple.json" data-filename="simple.json"
+ data-unknown="true"></object>
+
+<!-- simple json file with differing extension -->
+<object id="i71" data="http://localhost:8000/save_filename.sjs?type=json&filename=simple.dat" data-filename="simple.dat"
+ data-unknown="true"></object>
+
+<!-- compressed file with .gz extension -->
+<img id="i72" src="http://localhost:8000/save_filename.sjs?type=png&filename=compressed.png.gz" data-filename="compressed.png.png">
+
+<!-- compressed file with .tar.gz extension -->
+<object id="i73" data="http://localhost:8000/save_filename.sjs?type=tar&filename=compressed2.tar.gz" data-filename="compressed2.tar.gz"
+ data-unknown="true"></object>
+
+<!-- compressed file with bittar.gz extension. There is no tar mime info on Windows so the filename is not changed. -->
+<object id="i74" data="http://localhost:8000/save_filename.sjs?type=tar&filename=compressed3.bittar.gz"
+ data-filename="compressed3.bittar.gz"
+ data-unknown="true"></object>
+
+<!-- compressed file with .tar.bz2 extension -->
+<object id="i75" data="http://localhost:8000/save_filename.sjs?type=tar&filename=buzz.tar.bz2" data-filename="buzz.tar.bz2"
+ data-unknown="true"></object>
+
+<!-- executable with no filename specified and an extension specified within the url -->
+<img id="i76" src="http://localhost:8000/executable.exe?type=nonsense"
+ data-nodrag="true" data-unknown="typeonly" data-filename="executable.exe">
+
+<!-- embedded child html -->
+<object id="i77" data="http://localhost:8000/save_filename.sjs?type=html&filename=child.par"
+ data-filename="child.par" data-unknown="true"></object>
+
+<!-- file starting with a dot with and unknown extension -->
+<img id="i78" src="http://localhost:8000/save_filename.sjs?type=png&filename=.extension" data-filename="extension.png">
+
+<!-- html file starting with a dot -->
+<object id="i79" data="http://localhost:8000/save_filename.sjs?type=html&filename=.alternate"
+ data-filename="alternate.html" data-filename-platformwin="alternate.htm" data-unknown="true"></object>
+
+<!-- unrecognized file type starting with a dot -->
+<object id="i80" data="http://localhost:8000/save_filename.sjs?type=otherimage&filename=.alternate" data-filename="alternate"
+ data-unknown="true"></object>
+
+<!-- filename with lnk extension -->
+<img id="i81" src="http://localhost:8000/save_filename.sjs?type=nonsense&filename=shortcut.lnk"
+ data-nodrag="true" data-unknown="typeonly"
+ data-filename="shortcut.lnk.download">
+
+<!-- long filename with lnk extension -->
+<img id="i82" src="http://localhost:8000/save_filename.sjs?type=nonsense&filename=longshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshort1234567890.lnk"
+ data-nodrag="true" data-unknown="typeonly"
+ data-filename="longshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshort1234567890.lnk.download">
+
+</span>
+
+<!-- This set is used to test the filename specified by the download attribute is validated correctly. -->
+<span id="downloads">
+ <a id="download0" href="http://localhost:8000/base" download="pearl.png" data-filename="pearl.png">Link</a>
+ <a id="download1" href="http://localhost:8000/save_filename.sjs?type=png" download="opal.jpeg" data-filename="opal.png">Link</a>
+ <a id="download2" href="http://localhost:8000/save_filename.sjs?type=jpeg"
+ download="amethyst.png" data-filename="amethyst.jpg"
+ data-filename-platformlinux="amethyst.jpeg">Link</a>
+ <a id="download3" href="http://localhost:8000/save_filename.sjs?type=text"
+ download="onyx.png" data-filename="onyx.png">Link</a>
+ <!-- The content-disposition overrides the download attribute. -->
+ <a id="download4" href="http://localhost:8000/save_filename.sjs?type=png&filename=fakename.jpeg" download="topaz.jpeg" data-filename="fakename.png">Link</a>
+ <a id="download5" href="http://localhost:8000/save_filename.sjs?type=png"
+ download="amber?.png" data-filename="amber .png">Link</a>
+ <a id="download6" href="http://localhost:8000/save_filename.sjs?type=jpeg"
+ download="jade.:*jpeg" data-filename="jade.jpg"
+ data-filename-platformlinux="jade.jpeg">Link</a>>
+ <a id="download7" href="http://localhost:8000/save_filename.sjs?type=png"
+ download="thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename.png"
+ data-filename="thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisavery.png">Link</a>
+ <a id="download8" href="http://localhost:8000/base"
+ download="&Tab;&NewLine;&nbsp;&#11&#x180e;&#x180e;&#12 spa ced.png &Tab;&NewLine;&nbsp;&#x180e;&#x180e;&#11&#12 "
+ data-filename="spa ced.png">Link</a>
+</span>
+
+<span id="links">
+ <a id="link0" href="http://localhost:8000/save_filename.sjs?type=png&filename=one.png" data-filename="one.png">One</a>
+ <a id="link1" href="http://localhost:8000/save_filename.sjs?type=png&filename=two.jpeg" data-filename="two.png">Two</a>
+ <a id="link2" href="http://localhost:8000/save_filename.sjs?type=png&filename=three.con" data-filename="three.png">Three</a>
+ <a id="link3" href="http://localhost:8000/save_filename.sjs?type=png&dispname=four" data-filename="four.png">Four</a>
+ <a id="link4" href="http://localhost:8000/save_filename.sjs?type=png&filename=five.local" data-filename="five.png">Five</a>
+</span>
+
+<!-- The content-disposition attachment generates links from the images/objects/scripts above
+ and inserts them here. -->
+<p id="attachment-links">
+</p>
+
+</body></html>
diff --git a/uriloader/exthandler/tests/mochitest/script_redirect.html b/uriloader/exthandler/tests/mochitest/script_redirect.html
new file mode 100644
index 0000000000..31e0dc6a7e
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/script_redirect.html
@@ -0,0 +1,5 @@
+<script>
+ let params = new URL(document.location).searchParams;
+ let uri = params.get("uri");
+ document.location = uri;
+</script>
diff --git a/uriloader/exthandler/tests/mochitest/test_invalidCharFileExtension.xhtml b/uriloader/exthandler/tests/mochitest/test_invalidCharFileExtension.xhtml
new file mode 100644
index 0000000000..177af3757f
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/test_invalidCharFileExtension.xhtml
@@ -0,0 +1,65 @@
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <title>Test for Handling of unsafe bidi chars</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+<iframe id="test"></iframe>
+<script type="text/javascript">
+var tests = [
+ ["test.png:large", "test.png"],
+ ["test.png/large", "test.png"],
+ [":test.png::large:", "test.png"],
+];
+
+add_task(async function() {
+ function promiseMessage(messageName) {
+ return new Promise(resolve => {
+ chromeScript.addMessageListener(messageName, function listener(data) {
+ chromeScript.removeMessageListener(messageName, listener);
+ resolve(data);
+ });
+ });
+ }
+
+ let url = SimpleTest.getTestFileURL("HelperAppLauncherDialog_chromeScript.js");
+ let chromeScript = SpecialPowers.loadChromeScript(url);
+
+ function wrongAPICallListener(msg) {
+ ok(
+ false,
+ `Called ${msg} when always ask pref was set to ${
+ Services.prefs.getBoolPref(
+ "browser.download.always_ask_before_handling_new_types"
+ )
+ }, which shouldn't happen.`
+ );
+ }
+ chromeScript.addMessageListener("wrongAPICall", wrongAPICallListener);
+
+ for (let prefVal of [false, true]) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.always_ask_before_handling_new_types", prefVal]]
+ });
+ // Run all the tests.
+ for (let [name, expected] of tests) {
+ let promiseName = promiseMessage("suggestedFileName");
+ document.getElementById("test").src =
+ "invalidCharFileExtension.sjs?name=" + encodeURIComponent(name);
+ is((await promiseName), expected, "got the expected sanitized name");
+ }
+ }
+
+ // Clean up.
+ let promise = promiseMessage("unregistered");
+ chromeScript.sendAsyncMessage("unregister");
+ await promise;
+
+ chromeScript.removeMessageListener("wrongAPICall", wrongAPICallListener);
+ chromeScript.destroy();
+});
+</script>
+</body>
+</html>
diff --git a/uriloader/exthandler/tests/mochitest/test_nullCharFile.xhtml b/uriloader/exthandler/tests/mochitest/test_nullCharFile.xhtml
new file mode 100644
index 0000000000..b153395e81
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/test_nullCharFile.xhtml
@@ -0,0 +1,67 @@
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <title>Test for Handling of null char</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+<iframe id="test"></iframe>
+<script type="text/javascript">
+var tests = [
+ ["test.html\u0000.png", "test.html_.png"],
+ ["test.html.\u0000png", "test.html._png"],
+];
+
+add_task(async function() {
+ function promiseMessage(messageName) {
+ return new Promise(resolve => {
+ chromeScript.addMessageListener(messageName, function listener(data) {
+ chromeScript.removeMessageListener(messageName, listener);
+ resolve(data);
+ });
+ });
+ }
+
+ let url = SimpleTest.getTestFileURL("HelperAppLauncherDialog_chromeScript.js");
+ let chromeScript = SpecialPowers.loadChromeScript(url);
+
+ function wrongAPICallListener(msg) {
+ ok(
+ false,
+ `Called ${msg} when always ask pref was set to ${
+ Services.prefs.getBoolPref(
+ "browser.download.always_ask_before_handling_new_types"
+ )
+ }, which shouldn't happen.`
+ );
+ }
+ chromeScript.addMessageListener("wrongAPICall", wrongAPICallListener);
+
+ for (let prefVal of [false, true]) {
+ info("Pushing pref");
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.always_ask_before_handling_new_types", prefVal]]
+ });
+ for (let [name, expected] of tests) {
+ let promiseName = promiseMessage("suggestedFileName");
+ const a = document.createElement('a');
+ // Pass an unknown mimetype so we don't "correct" the extension:
+ a.href = "data:application/baconizer;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg==";
+ a.download = name;
+ a.dispatchEvent(new MouseEvent('click'));
+ is((await promiseName), expected, "got the expected sanitized name");
+ }
+ }
+
+ // Clean up.
+ let promise = promiseMessage("unregistered");
+ chromeScript.sendAsyncMessage("unregister");
+ await promise;
+
+ chromeScript.removeMessageListener("wrongAPICall", wrongAPICallListener);
+ chromeScript.destroy();
+});
+</script>
+</body>
+</html>
diff --git a/uriloader/exthandler/tests/mochitest/test_spammy_page.html b/uriloader/exthandler/tests/mochitest/test_spammy_page.html
new file mode 100644
index 0000000000..b1e60a1e8e
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/test_spammy_page.html
@@ -0,0 +1,27 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8" />
+ <title>...</title>
+</head>
+<body>
+ <p> Hello, it's the spammy page! </p>
+<script type="text/javascript">
+ let count = 0;
+window.onload = window.onclick = function() {
+ if (count < 100) {
+ count++;
+ let l = document.createElement('a');
+ l.href = 'data:text/plain,some text';
+ l.download = 'sometext.pdf';
+
+ document.body.appendChild(l);
+ l.click();
+ }
+}
+</script>
+<a id="image" href="${TEST_ROOT}/file_with@@funny_name.png">Image</a>
+</body>
+</html>
diff --git a/uriloader/exthandler/tests/mochitest/test_unknown_ext_protocol_handlers.html b/uriloader/exthandler/tests/mochitest/test_unknown_ext_protocol_handlers.html
new file mode 100644
index 0000000000..f8727db605
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/test_unknown_ext_protocol_handlers.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Test for no error reporting for unknown external protocols</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<iframe id="testFrame"></iframe>
+<script type="text/javascript">
+
+SimpleTest.waitForExplicitFinish();
+
+window.onload = () => {
+ let testFrame = document.getElementById("testFrame");
+
+ try {
+ testFrame.contentWindow.location.href = "unknownextproto:";
+ ok(true, "There is no error reporting for unknown external protocol navigation.");
+ } catch (e) {
+ ok(false, "There should be no error reporting for unknown external protocol navigation.");
+ }
+
+ SimpleTest.finish();
+};
+</script>
+</body>
+</html>
diff --git a/uriloader/exthandler/tests/mochitest/test_unsafeBidiChars.xhtml b/uriloader/exthandler/tests/mochitest/test_unsafeBidiChars.xhtml
new file mode 100644
index 0000000000..34c6c956fd
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/test_unsafeBidiChars.xhtml
@@ -0,0 +1,89 @@
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <title>Test for Handling of unsafe bidi chars</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+<iframe id="test"></iframe>
+<script type="text/javascript">
+var unsafeBidiChars = [
+ "\xe2\x80\xaa", // LRE
+ "\xe2\x80\xab", // RLE
+ "\xe2\x80\xac", // PDF
+ "\xe2\x80\xad", // LRO
+ "\xe2\x80\xae", // RLO
+];
+
+var tests = [
+ "{1}.test",
+ "{1}File.test",
+ "Fi{1}le.test",
+ "File{1}.test",
+ "File.{1}test",
+ "File.te{1}st",
+ "File.test{1}",
+ "File.{1}",
+];
+
+function replace(name, x) {
+ return name.replace(/\{1\}/, x);
+}
+
+function sanitize(name) {
+ return replace(name, "_");
+}
+
+add_task(async function() {
+ function promiseMessage(messageName) {
+ return new Promise(resolve => {
+ chromeScript.addMessageListener(messageName, function listener(data) {
+ chromeScript.removeMessageListener(messageName, listener);
+ resolve(data);
+ });
+ });
+ }
+
+ let url = SimpleTest.getTestFileURL("HelperAppLauncherDialog_chromeScript.js");
+ let chromeScript = SpecialPowers.loadChromeScript(url);
+
+ function wrongAPICallListener(msg) {
+ ok(
+ false,
+ `Called ${msg} when always ask pref was set to ${
+ Services.prefs.getBoolPref(
+ "browser.download.always_ask_before_handling_new_types"
+ )
+ }, which shouldn't happen.`
+ );
+ }
+ chromeScript.addMessageListener("wrongAPICall", wrongAPICallListener);
+
+ for (let prefVal of [false, true]) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.always_ask_before_handling_new_types", prefVal]]
+ });
+ for (let test of tests) {
+ for (let char of unsafeBidiChars) {
+ let promiseName = promiseMessage("suggestedFileName");
+ let name = replace(test, char);
+ let expected = sanitize(test);
+ document.getElementById("test").src =
+ "unsafeBidiFileName.sjs?name=" + encodeURIComponent(name);
+ is((await promiseName), expected, "got the expected sanitized name");
+ }
+ }
+ }
+
+ // Clean up.
+ let promise = promiseMessage("unregistered");
+ chromeScript.sendAsyncMessage("unregister");
+ await promise;
+
+ chromeScript.removeMessageListener("wrongAPICall", wrongAPICallListener);
+ chromeScript.destroy();
+});
+</script>
+</body>
+</html>
diff --git a/uriloader/exthandler/tests/mochitest/unsafeBidiFileName.sjs b/uriloader/exthandler/tests/mochitest/unsafeBidiFileName.sjs
new file mode 100644
index 0000000000..4f88ff6de2
--- /dev/null
+++ b/uriloader/exthandler/tests/mochitest/unsafeBidiFileName.sjs
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function handleRequest(request, response) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+
+ if (!request.queryString.match(/^name=/)) {
+ return;
+ }
+ var name = decodeURIComponent(request.queryString.substring(5));
+
+ response.setHeader(
+ "Content-Type",
+ 'application/octet-stream; name="' + name + '"'
+ );
+ response.setHeader("Content-Disposition", 'inline; filename="' + name + '"');
+}
diff --git a/uriloader/exthandler/tests/moz.build b/uriloader/exthandler/tests/moz.build
new file mode 100644
index 0000000000..691ab2932a
--- /dev/null
+++ b/uriloader/exthandler/tests/moz.build
@@ -0,0 +1,35 @@
+# -*- 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/.
+
+MOCHITEST_MANIFESTS += ["mochitest/mochitest.ini"]
+
+XPCSHELL_TESTS_MANIFESTS += ["unit/xpcshell.ini"]
+
+BROWSER_CHROME_MANIFESTS += ["mochitest/browser.ini"]
+
+TEST_DIRS += [
+ "gtest",
+]
+
+TESTING_JS_MODULES += [
+ "HandlerServiceTestUtils.sys.mjs",
+]
+
+GeckoSimplePrograms(
+ [
+ "WriteArgument",
+ ],
+ linkage=None,
+)
+
+if CONFIG["COMPILE_ENVIRONMENT"]:
+ TEST_HARNESS_FILES.xpcshell.uriloader.exthandler.tests.unit += [
+ "!WriteArgument%s" % CONFIG["BIN_SUFFIX"]
+ ]
+
+USE_LIBS += [
+ "nspr",
+]
diff --git a/uriloader/exthandler/tests/unit/handlers.json b/uriloader/exthandler/tests/unit/handlers.json
new file mode 100644
index 0000000000..51ce581d83
--- /dev/null
+++ b/uriloader/exthandler/tests/unit/handlers.json
@@ -0,0 +1,83 @@
+{
+ "defaultHandlersVersion": {
+ "en-US": 999
+ },
+ "mimeTypes": {
+ "example/type.handleinternally": {
+ "unknownProperty": "preserved",
+ "action": 3,
+ "extensions": ["example_one"]
+ },
+ "example/type.savetodisk": {
+ "action": 0,
+ "ask": true,
+ "handlers": [
+ {
+ "name": "Example Default Handler",
+ "uriTemplate": "https://www.example.com/?url=%s"
+ }
+ ],
+ "extensions": ["example_two", "example_three"]
+ },
+ "example/type.usehelperapp": {
+ "action": 2,
+ "ask": true,
+ "handlers": [
+ {
+ "name": "Example Default Handler",
+ "uriTemplate": "https://www.example.com/?url=%s"
+ },
+ {
+ "name": "Example Possible Handler One",
+ "uriTemplate": "http://www.example.com/?id=1&url=%s"
+ },
+ {
+ "name": "Example Possible Handler Two",
+ "uriTemplate": "http://www.example.com/?id=2&url=%s"
+ }
+ ],
+ "extensions": ["example_two", "example_three"]
+ },
+ "example/type.usesystemdefault": {
+ "action": 4,
+ "handlers": [
+ null,
+ {
+ "name": "Example Possible Handler",
+ "uriTemplate": "http://www.example.com/?url=%s"
+ }
+ ]
+ }
+ },
+ "schemes": {
+ "examplescheme.usehelperapp": {
+ "action": 2,
+ "ask": true,
+ "handlers": [
+ {
+ "name": "Example Default Handler",
+ "uriTemplate": "https://www.example.com/?url=%s"
+ },
+ {
+ "name": "Example Possible Handler One",
+ "uriTemplate": "http://www.example.com/?id=1&url=%s"
+ },
+ {
+ "name": "Example Possible Handler Two",
+ "uriTemplate": "http://www.example.com/?id=2&url=%s"
+ }
+ ]
+ },
+ "examplescheme.usesystemdefault": {
+ "action": 4,
+ "handlers": [
+ null,
+ {
+ "name": "Example Possible Handler",
+ "uriTemplate": "http://www.example.com/?url=%s"
+ }
+ ]
+ }
+ },
+ "isDownloadsImprovementsAlreadyMigrated": true
+}
diff --git a/uriloader/exthandler/tests/unit/head.js b/uriloader/exthandler/tests/unit/head.js
new file mode 100644
index 0000000000..2f65582ae9
--- /dev/null
+++ b/uriloader/exthandler/tests/unit/head.js
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * Initialization for tests related to invoking external handler applications.
+ */
+
+"use strict";
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const { HandlerServiceTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/HandlerServiceTestUtils.sys.mjs"
+);
+var { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gHandlerService",
+ "@mozilla.org/uriloader/handler-service;1",
+ "nsIHandlerService"
+);
+
+do_get_profile();
+
+let jsonPath = PathUtils.join(PathUtils.profileDir, "handlers.json");
+
+/**
+ * Unloads the nsIHandlerService data store, so the back-end file can be
+ * accessed or modified, and the new data will be loaded at the next access.
+ */
+let unloadHandlerStore = async function () {
+ // If this function is called before the nsIHandlerService instance has been
+ // initialized for the first time, the observer below will not be registered.
+ // We have to force initialization to prevent the function from stalling.
+ gHandlerService;
+
+ let promise = TestUtils.topicObserved("handlersvc-json-replace-complete");
+ Services.obs.notifyObservers(null, "handlersvc-json-replace");
+ await promise;
+};
+
+/**
+ * Unloads the data store and deletes it.
+ */
+let deleteHandlerStore = async function () {
+ await unloadHandlerStore();
+
+ await IOUtils.remove(jsonPath, { ignoreAbsent: true });
+
+ Services.prefs.clearUserPref("gecko.handlerService.defaultHandlersVersion");
+};
+
+/**
+ * Unloads the data store and replaces it with the test data file.
+ */
+let copyTestDataToHandlerStore = async function () {
+ await unloadHandlerStore();
+
+ await IOUtils.copy(do_get_file("handlers.json").path, jsonPath);
+
+ Services.prefs.setIntPref("gecko.handlerService.defaultHandlersVersion", 100);
+};
+
+/**
+ * Ensures the files are removed and the services unloaded when the tests end.
+ */
+registerCleanupFunction(async function test_terminate() {
+ await deleteHandlerStore();
+});
diff --git a/uriloader/exthandler/tests/unit/mailcap b/uriloader/exthandler/tests/unit/mailcap
new file mode 100644
index 0000000000..dc93ef8042
--- /dev/null
+++ b/uriloader/exthandler/tests/unit/mailcap
@@ -0,0 +1,2 @@
+text/plain; cat '%s'; needsterminal
+text/plain; sed '%s'
diff --git a/uriloader/exthandler/tests/unit/test_badMIMEType.js b/uriloader/exthandler/tests/unit/test_badMIMEType.js
new file mode 100644
index 0000000000..49c5e8d848
--- /dev/null
+++ b/uriloader/exthandler/tests/unit/test_badMIMEType.js
@@ -0,0 +1,29 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ // "text/plain" has an 0xFF character appended to it. This means it's an
+ // invalid string, which is tricky to enter using a text editor (I used
+ // emacs' hexl-mode). It also means an ordinary text editor might drop it
+ // or convert it to something that *is* valid (in UTF8). So we measure
+ // its length to make sure this hasn't happened.
+ var badMimeType = "text/plainÿ";
+ Assert.equal(badMimeType.length, 11);
+ try {
+ Cc["@mozilla.org/mime;1"]
+ .getService(Ci.nsIMIMEService)
+ .getFromTypeAndExtension(badMimeType, "txt");
+ } catch (e) {
+ if (
+ !(e instanceof Ci.nsIException) ||
+ e.result != Cr.NS_ERROR_NOT_AVAILABLE
+ ) {
+ throw e;
+ }
+ // This is an expected exception, thrown if the type can't be determined
+ }
+ // Not crashing is good enough
+ Assert.equal(true, true);
+}
diff --git a/uriloader/exthandler/tests/unit/test_defaults_handlerService.js b/uriloader/exthandler/tests/unit/test_defaults_handlerService.js
new file mode 100644
index 0000000000..47fd18a642
--- /dev/null
+++ b/uriloader/exthandler/tests/unit/test_defaults_handlerService.js
@@ -0,0 +1,159 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gExternalProtocolService",
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ "nsIExternalProtocolService"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ kHandlerList: "resource://gre/modules/handlers/HandlerList.sys.mjs",
+});
+
+add_task(async function test_check_defaults_get_added() {
+ let protocols = Object.keys(kHandlerList.default.schemes);
+ for (let protocol of protocols) {
+ let protocolHandlerCount =
+ kHandlerList.default.schemes[protocol].handlers.length;
+ Assert.ok(
+ gHandlerService.wrappedJSObject._store.data.schemes[protocol].stubEntry,
+ `Expect stub for ${protocol}`
+ );
+ let info = gExternalProtocolService.getProtocolHandlerInfo(protocol, {});
+ Assert.ok(
+ info,
+ `Should be able to get protocol handler info for ${protocol}`
+ );
+ let handlers = Array.from(
+ info.possibleApplicationHandlers.enumerate(Ci.nsIHandlerApp)
+ );
+ handlers = handlers.filter(h => h instanceof Ci.nsIWebHandlerApp);
+ Assert.equal(
+ handlers.length,
+ protocolHandlerCount,
+ `Default web handlers for ${protocol} should match`
+ );
+ let { alwaysAskBeforeHandling, preferredAction } = info;
+ // Actually store something, pretending there was a change:
+ let infoToWrite = gExternalProtocolService.getProtocolHandlerInfo(
+ protocol,
+ {}
+ );
+ gHandlerService.store(infoToWrite);
+ ok(
+ !gHandlerService.wrappedJSObject._store.data.schemes[protocol].stubEntry,
+ "Expect stub entry info to go away"
+ );
+
+ let newInfo = gExternalProtocolService.getProtocolHandlerInfo(protocol, {});
+ Assert.equal(
+ alwaysAskBeforeHandling,
+ newInfo.alwaysAskBeforeHandling,
+ protocol + " - always ask shouldn't change"
+ );
+ Assert.equal(
+ preferredAction,
+ newInfo.preferredAction,
+ protocol + " - preferred action shouldn't change"
+ );
+ await deleteHandlerStore();
+ }
+});
+
+add_task(async function test_check_default_modification() {
+ Assert.ok(
+ true,
+ JSON.stringify(gHandlerService.wrappedJSObject._store.data.schemes.mailto)
+ );
+ Assert.ok(
+ gHandlerService.wrappedJSObject._store.data.schemes.mailto.stubEntry,
+ "Expect stub for mailto"
+ );
+ let mailInfo = gExternalProtocolService.getProtocolHandlerInfo("mailto", {});
+ mailInfo.alwaysAskBeforeHandling = false;
+ mailInfo.preferredAction = Ci.nsIHandlerInfo.useSystemDefault;
+ gHandlerService.store(mailInfo);
+ Assert.ok(
+ !gHandlerService.wrappedJSObject._store.data.schemes.mailto.stubEntry,
+ "Stub entry should be removed immediately."
+ );
+ let newMail = gExternalProtocolService.getProtocolHandlerInfo("mailto", {});
+ Assert.equal(newMail.preferredAction, Ci.nsIHandlerInfo.useSystemDefault);
+ Assert.equal(newMail.alwaysAskBeforeHandling, false);
+ await deleteHandlerStore();
+});
+
+add_task(async function test_migrations() {
+ const kTestData = [
+ ["A", "http://compose.mail.yahoo.co.jp/ym/Compose?To=%s"],
+ ["B", "http://www.inbox.lv/rfc2368/?value=%s"],
+ ["C", "http://poczta.interia.pl/mh/?mailto=%s"],
+ ["D", "http://win.mail.ru/cgi-bin/sentmsg?mailto=%s"],
+ ];
+ // Set up the test handlers. This doesn't use prefs like the previous test,
+ // because we now refuse to import insecure handler prefs. They can only
+ // exist if they were added into the handler store before this restriction
+ // was created (bug 1526890).
+ gHandlerService.wrappedJSObject._injectDefaultProtocolHandlers();
+ let handler = gExternalProtocolService.getProtocolHandlerInfo("mailto");
+ while (handler.possibleApplicationHandlers.length) {
+ handler.possibleApplicationHandlers.removeElementAt(0);
+ }
+ for (let [name, uriTemplate] of kTestData) {
+ let app = Cc["@mozilla.org/uriloader/web-handler-app;1"].createInstance(
+ Ci.nsIWebHandlerApp
+ );
+ app.uriTemplate = uriTemplate;
+ app.name = name;
+ handler.possibleApplicationHandlers.appendElement(app);
+ }
+ gHandlerService.store(handler);
+
+ // Now migrate them:
+ Services.prefs.setCharPref("browser.handlers.migrations", "blah,secure-mail");
+ gHandlerService.wrappedJSObject._migrateProtocolHandlersIfNeeded();
+
+ // Now check the result:
+ handler = gExternalProtocolService.getProtocolHandlerInfo("mailto");
+
+ let expectedURIs = new Set([
+ "https://mail.yahoo.co.jp/compose/?To=%s",
+ "https://mail.inbox.lv/compose?to=%s",
+ "https://poczta.interia.pl/mh/?mailto=%s",
+ "https://e.mail.ru/cgi-bin/sentmsg?mailto=%s",
+ ]);
+
+ let possibleApplicationHandlers = Array.from(
+ handler.possibleApplicationHandlers.enumerate(Ci.nsIWebHandlerApp)
+ );
+ // Set iterators are stable to deletion, so this works:
+ for (let expected of expectedURIs) {
+ for (let app of possibleApplicationHandlers) {
+ if (app instanceof Ci.nsIWebHandlerApp && app.uriTemplate == expected) {
+ Assert.ok(true, "Found handler with URI " + expected);
+ // ... even when we remove items.
+ expectedURIs.delete(expected);
+ break;
+ }
+ }
+ }
+ Assert.equal(expectedURIs.size, 0, "Should have seen all the expected URIs.");
+
+ for (let app of possibleApplicationHandlers) {
+ if (app instanceof Ci.nsIWebHandlerApp) {
+ Assert.ok(
+ !kTestData.some(n => n[1] == app.uriTemplate),
+ "Should not be any of the original handlers"
+ );
+ }
+ }
+
+ Assert.ok(
+ !handler.preferredApplicationHandler,
+ "Shouldn't have preferred handler initially."
+ );
+ await deleteHandlerStore();
+});
diff --git a/uriloader/exthandler/tests/unit/test_downloads_improvements_migration.js b/uriloader/exthandler/tests/unit/test_downloads_improvements_migration.js
new file mode 100644
index 0000000000..49e4493b23
--- /dev/null
+++ b/uriloader/exthandler/tests/unit/test_downloads_improvements_migration.js
@@ -0,0 +1,244 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+);
+
+const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+
+const { Integration } = ChromeUtils.importESModule(
+ "resource://gre/modules/Integration.sys.mjs"
+);
+
+/* global DownloadIntegration */
+Integration.downloads.defineESModuleGetter(
+ this,
+ "DownloadIntegration",
+ "resource://gre/modules/DownloadIntegration.sys.mjs"
+);
+
+/**
+ * Tests that the migration runs and that only
+ * files with preferredAction alwaysAsk are updated.
+ */
+add_task(async function test_migration() {
+ // Create mock implementation of shouldDownloadInternally for test case
+ let oldShouldViewDownloadInternally =
+ DownloadIntegration.shouldViewDownloadInternally;
+ DownloadIntegration.shouldViewDownloadInternally = (mimeType, extension) => {
+ let downloadTypesViewableInternally = [
+ {
+ extension: "pdf",
+ mimeTypes: ["application/pdf"],
+ },
+ {
+ extension: "webp",
+ mimeTypes: ["image/webp"],
+ },
+ ];
+
+ for (const mockHandler of downloadTypesViewableInternally) {
+ if (mockHandler.mimeTypes.includes(mimeType)) {
+ return true;
+ }
+ }
+
+ return false;
+ };
+
+ registerCleanupFunction(async function () {
+ Services.prefs.clearUserPref(
+ "browser.download.improvements_to_download_panel"
+ );
+ DownloadIntegration.shouldViewDownloadInternally =
+ oldShouldViewDownloadInternally;
+ });
+
+ // For setup, set pref to false. Will be enabled later.
+ Services.prefs.setBoolPref(
+ "browser.download.improvements_to_download_panel",
+ false
+ );
+
+ // Plain text file
+ let txtHandlerInfo = mimeSvc.getFromTypeAndExtension("text/plain", "txt");
+ txtHandlerInfo.preferredAction = Ci.nsIHandlerInfo.alwaysAsk;
+ txtHandlerInfo.alwaysAskBeforeHandling = true;
+ // PDF file
+ let pdfHandlerInfo = mimeSvc.getFromTypeAndExtension(
+ "application/pdf",
+ "pdf"
+ );
+ pdfHandlerInfo.preferredAction = Ci.nsIHandlerInfo.alwaysAsk;
+ pdfHandlerInfo.alwaysAskBeforeHandling = true;
+ // WebP file
+ let webpHandlerInfo = mimeSvc.getFromTypeAndExtension("image/webp", "webp");
+ webpHandlerInfo.preferredAction = Ci.nsIHandlerInfo.useSystemDefault;
+ webpHandlerInfo.alwaysAskBeforeHandling = false;
+
+ handlerSvc.store(txtHandlerInfo);
+ handlerSvc.store(pdfHandlerInfo);
+ handlerSvc.store(webpHandlerInfo);
+
+ Services.prefs.setBoolPref(
+ "browser.download.improvements_to_download_panel",
+ true
+ );
+ gHandlerService.wrappedJSObject._migrateDownloadsImprovementsIfNeeded();
+
+ txtHandlerInfo = mimeSvc.getFromTypeAndExtension("text/plain", "txt");
+ pdfHandlerInfo = mimeSvc.getFromTypeAndExtension("application/pdf", "pdf");
+ webpHandlerInfo = mimeSvc.getFromTypeAndExtension("image/webp", "webp");
+ let data = gHandlerService.wrappedJSObject._store.data;
+ Assert.equal(
+ data.isDownloadsImprovementsAlreadyMigrated,
+ true,
+ "isDownloadsImprovementsAlreadyMigrated should be set to true"
+ );
+ Assert.equal(
+ pdfHandlerInfo.preferredAction,
+ Ci.nsIHandlerInfo.handleInternally,
+ "application/pdf - preferredAction should be handleInternally"
+ );
+ Assert.equal(
+ pdfHandlerInfo.alwaysAskBeforeHandling,
+ false,
+ "application/pdf - alwaysAskBeforeHandling should be false"
+ );
+ Assert.equal(
+ webpHandlerInfo.preferredAction,
+ Ci.nsIHandlerInfo.useSystemDefault,
+ "image/webp - preferredAction should be useSystemDefault"
+ );
+ Assert.equal(
+ webpHandlerInfo.alwaysAskBeforeHandling,
+ false,
+ "image/webp - alwaysAskBeforeHandling should be false"
+ );
+ Assert.equal(
+ txtHandlerInfo.preferredAction,
+ Ci.nsIHandlerInfo.saveToDisk,
+ "text/plain - preferredAction should be saveToDisk"
+ );
+ Assert.equal(
+ txtHandlerInfo.alwaysAskBeforeHandling,
+ false,
+ "text/plain - alwaysAskBeforeHandling should be false"
+ );
+});
+
+/**
+ * Tests that the migration does not run if the migration was already run.
+ */
+add_task(async function test_migration_already_run() {
+ let data = gHandlerService.wrappedJSObject._store.data;
+ data.isDownloadsImprovementsAlreadyMigrated = true;
+
+ // Plain text file
+ let txtHandlerInfo = mimeSvc.getFromTypeAndExtension("text/plain", "txt");
+ txtHandlerInfo.preferredAction = Ci.nsIHandlerInfo.alwaysAsk;
+ txtHandlerInfo.alwaysAskBeforeHandling = true;
+ // PDF file
+ let pdfHandlerInfo = mimeSvc.getFromTypeAndExtension(
+ "application/pdf",
+ "pdf"
+ );
+ pdfHandlerInfo.preferredAction = Ci.nsIHandlerInfo.alwaysAsk;
+ pdfHandlerInfo.alwaysAskBeforeHandling = true;
+
+ handlerSvc.store(txtHandlerInfo);
+ handlerSvc.store(pdfHandlerInfo);
+
+ gHandlerService.wrappedJSObject._migrateDownloadsImprovementsIfNeeded();
+
+ txtHandlerInfo = mimeSvc.getFromTypeAndExtension("text/plain", "txt");
+ pdfHandlerInfo = mimeSvc.getFromTypeAndExtension("application/pdf", "pdf");
+ data = gHandlerService.wrappedJSObject._store.data;
+ Assert.equal(
+ pdfHandlerInfo.preferredAction,
+ Ci.nsIHandlerInfo.alwaysAsk,
+ "application/pdf - preferredAction should be alwaysAsk"
+ );
+ Assert.equal(
+ pdfHandlerInfo.alwaysAskBeforeHandling,
+ true,
+ "application/pdf - alwaysAskBeforeHandling should be true"
+ );
+ Assert.equal(
+ txtHandlerInfo.preferredAction,
+ Ci.nsIHandlerInfo.alwaysAsk,
+ "text/plain - preferredAction should be alwaysAsk"
+ );
+ Assert.equal(
+ txtHandlerInfo.alwaysAskBeforeHandling,
+ true,
+ "text/plain - alwaysAskBeforeHandling should be true"
+ );
+});
+
+/**
+ * Test migration of SVG and XML info.
+ */
+add_task(async function test_migration_xml_svg() {
+ let data = gHandlerService.wrappedJSObject._store.data;
+ // Plain text file
+ let txtHandlerInfo = mimeSvc.getFromTypeAndExtension("text/plain", "txt");
+ txtHandlerInfo.preferredAction = Ci.nsIHandlerInfo.alwaysAsk;
+ txtHandlerInfo.alwaysAskBeforeHandling = true;
+ // SVG file
+ let svgHandlerInfo = mimeSvc.getFromTypeAndExtension("image/svg+xml", "svg");
+ svgHandlerInfo.preferredAction = Ci.nsIHandlerInfo.handleInternally;
+ svgHandlerInfo.alwaysAskBeforeHandling = false;
+ // XML file
+ let xmlHandlerInfo = mimeSvc.getFromTypeAndExtension("text/xml", "xml");
+ xmlHandlerInfo.preferredAction = Ci.nsIHandlerInfo.handleInternally;
+ xmlHandlerInfo.alwaysAskBeforeHandling = false;
+
+ handlerSvc.store(txtHandlerInfo);
+ handlerSvc.store(svgHandlerInfo);
+ handlerSvc.store(xmlHandlerInfo);
+
+ gHandlerService.wrappedJSObject._migrateSVGXMLIfNeeded();
+
+ txtHandlerInfo = mimeSvc.getFromTypeAndExtension("text/plain", "txt");
+ svgHandlerInfo = mimeSvc.getFromTypeAndExtension("image/svg+xml", "svg");
+ xmlHandlerInfo = mimeSvc.getFromTypeAndExtension("text/xml", "xml");
+ data = gHandlerService.wrappedJSObject._store.data;
+ Assert.equal(
+ svgHandlerInfo.preferredAction,
+ Ci.nsIHandlerInfo.saveToDisk,
+ "image/svg+xml - preferredAction should be saveToDisk"
+ );
+ Assert.equal(
+ svgHandlerInfo.alwaysAskBeforeHandling,
+ false,
+ "image/svg+xml - alwaysAskBeforeHandling should be false"
+ );
+ Assert.equal(
+ xmlHandlerInfo.preferredAction,
+ Ci.nsIHandlerInfo.saveToDisk,
+ "text/xml - preferredAction should be saveToDisk"
+ );
+ Assert.equal(
+ xmlHandlerInfo.alwaysAskBeforeHandling,
+ false,
+ "text/xml - alwaysAskBeforeHandling should be false"
+ );
+ Assert.equal(
+ txtHandlerInfo.preferredAction,
+ Ci.nsIHandlerInfo.alwaysAsk,
+ "text/plain - preferredAction should be alwaysAsk"
+ );
+ Assert.equal(
+ txtHandlerInfo.alwaysAskBeforeHandling,
+ true,
+ "text/plain - alwaysAskBeforeHandling should be true"
+ );
+
+ ok(
+ data.isSVGXMLAlreadyMigrated,
+ "Should have stored migration state on the data object."
+ );
+});
diff --git a/uriloader/exthandler/tests/unit/test_filename_sanitize.js b/uriloader/exthandler/tests/unit/test_filename_sanitize.js
new file mode 100644
index 0000000000..d8ab12c266
--- /dev/null
+++ b/uriloader/exthandler/tests/unit/test_filename_sanitize.js
@@ -0,0 +1,398 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This test verifies that
+// nsIMIMEService.validateFileNameForSaving sanitizes filenames
+// properly with different flags.
+
+"use strict";
+
+add_task(async function validate_filename_method() {
+ let mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+
+ function checkFilename(filename, flags, mime = "image/png") {
+ return mimeService.validateFileNameForSaving(filename, mime, flags);
+ }
+
+ Assert.equal(checkFilename("basicfile.png", 0), "basicfile.png");
+ Assert.equal(checkFilename(" whitespace.png ", 0), "whitespace.png");
+ Assert.equal(
+ checkFilename(" .whitespaceanddots.png...", 0),
+ "whitespaceanddots.png"
+ );
+ Assert.equal(
+ checkFilename(" \u00a0 \u00a0 extrawhitespace.png \u00a0 \u00a0 ", 0),
+ "extrawhitespace.png"
+ );
+ Assert.equal(
+ checkFilename(" filename with whitespace.png ", 0),
+ "filename with whitespace.png"
+ );
+ Assert.equal(checkFilename("\\path.png", 0), "_path.png");
+ Assert.equal(
+ checkFilename("\\path*and/$?~file.png", 0),
+ "_path and_$ ~file.png"
+ );
+ Assert.equal(
+ checkFilename(" \u180e whit\u180ee.png \u180e", 0),
+ "whit\u180ee.png"
+ );
+ Assert.equal(checkFilename("簡単簡単簡単", 0), "簡単簡単簡単.png");
+ Assert.equal(checkFilename(" happy\u061c\u2069.png", 0), "happy__.png");
+ Assert.equal(
+ checkFilename("12345678".repeat(31) + "abcdefgh.png", 0),
+ "12345678".repeat(31) + "ab.png"
+ );
+ Assert.equal(
+ checkFilename("簡単".repeat(41) + ".png", 0),
+ "簡単".repeat(41) + ".png"
+ );
+ Assert.equal(
+ checkFilename("a" + "簡単".repeat(42) + ".png", 0),
+ "a" + "簡単".repeat(40) + "簡.png"
+ );
+ Assert.equal(
+ checkFilename("a" + "簡単".repeat(56) + ".png", 0),
+ "a" + "簡単".repeat(40) + ".png"
+ );
+ Assert.equal(checkFilename("café.png", 0), "café.png");
+ Assert.equal(
+ checkFilename("café".repeat(50) + ".png", 0),
+ "café".repeat(50) + ".png"
+ );
+ Assert.equal(
+ checkFilename("café".repeat(51) + ".png", 0),
+ "café".repeat(49) + "caf.png"
+ );
+
+ Assert.equal(
+ checkFilename("\u{100001}\u{100002}.png", 0),
+ "\u{100001}\u{100002}.png"
+ );
+ Assert.equal(
+ checkFilename("\u{100001}\u{100002}".repeat(31) + ".png", 0),
+ "\u{100001}\u{100002}".repeat(31) + ".png"
+ );
+ Assert.equal(
+ checkFilename("\u{100001}\u{100002}".repeat(32) + ".png", 0),
+ "\u{100001}\u{100002}".repeat(30) + "\u{100001}.png"
+ );
+
+ Assert.equal(
+ checkFilename("noextensionfile".repeat(16), 0),
+ "noextensionfile".repeat(16) + ".png"
+ );
+ Assert.equal(
+ checkFilename("noextensionfile".repeat(17), 0),
+ "noextensionfile".repeat(16) + "noextensio.png"
+ );
+ Assert.equal(
+ checkFilename("noextensionfile".repeat(16) + "noextensionfil.", 0),
+ "noextensionfile".repeat(16) + "noextensio.png"
+ );
+
+ Assert.equal(checkFilename(" first .png ", 0), "first .png");
+ Assert.equal(
+ checkFilename(
+ " second .png ",
+ mimeService.VALIDATE_DONT_COLLAPSE_WHITESPACE
+ ),
+ "second .png"
+ );
+
+ // For whatever reason, the Android mime handler accepts the .jpeg
+ // extension for image/png, so skip this test there.
+ if (AppConstants.platform != "android") {
+ Assert.equal(checkFilename("thi/*rd.jpeg", 0), "thi_ rd.png");
+ }
+
+ Assert.equal(
+ checkFilename("f*\\ourth file.jpg", mimeService.VALIDATE_SANITIZE_ONLY),
+ "f _ourth file.jpg"
+ );
+ Assert.equal(
+ checkFilename(
+ "f*\\ift h.jpe*\\g",
+ mimeService.VALIDATE_SANITIZE_ONLY |
+ mimeService.VALIDATE_DONT_COLLAPSE_WHITESPACE
+ ),
+ "f _ift h.jpe _g"
+ );
+ Assert.equal(checkFilename("sixth.j pe/*g", 0), "sixth.png");
+
+ let repeatStr = "12345678".repeat(31);
+ Assert.equal(
+ checkFilename(
+ repeatStr + "seventh.png",
+ mimeService.VALIDATE_DONT_TRUNCATE
+ ),
+ repeatStr + "seventh.png"
+ );
+ Assert.equal(
+ checkFilename(repeatStr + "seventh.png", 0),
+ repeatStr + "se.png"
+ );
+
+ // no filename, so index is used by default.
+ Assert.equal(checkFilename(".png", 0), "png.png");
+
+ // sanitization only, so Untitled is not added, but initial period is stripped.
+ Assert.equal(
+ checkFilename(".png", mimeService.VALIDATE_SANITIZE_ONLY),
+ "png"
+ );
+
+ // correct .png extension is applied.
+ Assert.equal(checkFilename(".butterpecan.icecream", 0), "butterpecan.png");
+
+ // sanitization only, so extension is not modified, but initial period is stripped.
+ Assert.equal(
+ checkFilename(".butterpecan.icecream", mimeService.VALIDATE_SANITIZE_ONLY),
+ "butterpecan.icecream"
+ );
+
+ let ext = ".fairlyLongExtension";
+ Assert.equal(
+ checkFilename(repeatStr + ext, mimeService.VALIDATE_SANITIZE_ONLY),
+ repeatStr.substring(0, 254 - ext.length) + ext
+ );
+
+ ext = "lo%?n/ginvalid? ch\\ars";
+ Assert.equal(
+ checkFilename(repeatStr + ext, mimeService.VALIDATE_SANITIZE_ONLY),
+ repeatStr + "lo% n_"
+ );
+
+ ext = ".long/invalid%? ch\\ars";
+ Assert.equal(
+ checkFilename(repeatStr + ext, mimeService.VALIDATE_SANITIZE_ONLY),
+ repeatStr.substring(0, 233) + ".long_invalid% ch_ars"
+ );
+
+ Assert.equal(
+ checkFilename("test_テスト_T\x83E\\S\x83T.png", 0),
+ "test_テスト_T E_S T.png"
+ );
+ Assert.equal(
+ checkFilename("test_テスト_T\x83E\\S\x83T.pテ\x83ng", 0),
+ "test_テスト_T E_S T.png"
+ );
+
+ // Check we don't invalidate surrogate pairs when trimming.
+ Assert.equal(checkFilename("test😀", 0, ""), "test😀");
+ Assert.equal(checkFilename("test😀😀", 0, ""), "test😀😀");
+
+ // Now check some media types
+ Assert.equal(
+ mimeService.validateFileNameForSaving("video.ogg", "video/ogg", 0),
+ "video.ogg",
+ "video.ogg"
+ );
+ Assert.equal(
+ mimeService.validateFileNameForSaving("video.ogv", "video/ogg", 0),
+ "video.ogv",
+ "video.ogv"
+ );
+ Assert.equal(
+ mimeService.validateFileNameForSaving("video.ogt", "video/ogg", 0),
+ "video.ogv",
+ "video.ogt"
+ );
+
+ Assert.equal(
+ mimeService.validateFileNameForSaving("audio.mp3", "audio/mpeg", 0),
+ "audio.mp3",
+ "audio.mp3"
+ );
+ Assert.equal(
+ mimeService.validateFileNameForSaving("audio.mpega", "audio/mpeg", 0),
+ "audio.mpega",
+ "audio.mpega"
+ );
+ Assert.equal(
+ mimeService.validateFileNameForSaving("audio.mp2", "audio/mpeg", 0),
+ "audio.mp2",
+ "audio.mp2"
+ );
+
+ let expected = "audio.mp3";
+ if (AppConstants.platform == "linux") {
+ expected = "audio.mpga";
+ } else if (AppConstants.platform == "android") {
+ expected = "audio.mp4";
+ }
+
+ Assert.equal(
+ mimeService.validateFileNameForSaving("audio.mp4", "audio/mpeg", 0),
+ expected,
+ "audio.mp4"
+ );
+
+ Assert.equal(
+ mimeService.validateFileNameForSaving("sound.m4a", "audio/mp4", 0),
+ "sound.m4a",
+ "sound.m4a"
+ );
+ Assert.equal(
+ mimeService.validateFileNameForSaving("sound.m4b", "audio/mp4", 0),
+ AppConstants.platform == "android" ? "sound.m4a" : "sound.m4b",
+ "sound.m4b"
+ );
+ Assert.equal(
+ mimeService.validateFileNameForSaving("sound.m4c", "audio/mp4", 0),
+ AppConstants.platform == "macosx" ? "sound.mp4" : "sound.m4a",
+ "sound.mpc"
+ );
+
+ // This has a long filename with a 13 character extension. The end of the filename should be
+ // cropped to fit into 255 bytes.
+ Assert.equal(
+ mimeService.validateFileNameForSaving(
+ "라이브9.9만 시청컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장24%102 000원 브랜드데이 앵콜 🎁 1.등 유산균 컬처렐 특가!",
+ "text/unknown",
+ mimeService.VALIDATE_SANITIZE_ONLY
+ ),
+ "라이브9.9만 시청컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 .등 유산균 컬처렐 특가!",
+ "very long filename with extension"
+ );
+
+ // This filename has a very long extension, almost the entire filename.
+ Assert.equal(
+ mimeService.validateFileNameForSaving(
+ "라이브9.9만 시청컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장24%102 000원 브랜드데이 앵콜 🎁 1등 유산균 컬처렐 특가!",
+ "text/unknown",
+ mimeService.VALIDATE_SANITIZE_ONLY
+ ),
+ "라이브9",
+ "another very long filename with long extension"
+ );
+
+ // This filename is cropped at 254 bytes.
+ Assert.equal(
+ mimeService.validateFileNameForSaving(
+ ".라이브99만 시청컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장24%102 000원 브랜드데이 앵콜 🎁 1등 유산균 컬처렐 특가!",
+ "text/unknown",
+ mimeService.VALIDATE_SANITIZE_ONLY
+ ),
+ "라이브99만 시청컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장24%102 000원 브랜드데",
+ "very filename with extension only"
+ );
+
+ Assert.equal(
+ mimeService.validateFileNameForSaving("filename.LNK", "text/unknown", 0),
+ "filename.LNK.download",
+ "filename.LNK"
+ );
+ Assert.equal(
+ mimeService.validateFileNameForSaving("filename.local", "text/unknown", 0),
+ "filename.local.download",
+ "filename.local"
+ );
+ Assert.equal(
+ mimeService.validateFileNameForSaving("filename.url", "text/unknown", 0),
+ "filename.url.download",
+ "filename.url"
+ );
+ Assert.equal(
+ mimeService.validateFileNameForSaving("filename.URl", "text/unknown", 0),
+ "filename.URl.download",
+ "filename.URl"
+ );
+ Assert.equal(
+ mimeService.validateFileNameForSaving("filename.scf", "text/unknown", 0),
+ "filename.scf.download",
+ "filename.scf"
+ );
+ Assert.equal(
+ mimeService.validateFileNameForSaving("filename.sCF", "text/unknown", 0),
+ "filename.sCF.download",
+ "filename.sCF"
+ );
+
+ Assert.equal(
+ mimeService.validateFileNameForSaving("filename.lnk\n", "text/unknown", 0),
+ "filename.lnk.download",
+ "filename.lnk with newline"
+ );
+
+ Assert.equal(
+ mimeService.validateFileNameForSaving(
+ "filename.lnk\n ",
+ "text/unknown",
+ 0
+ ),
+ "filename.lnk.download",
+ "filename.lnk with newline"
+ );
+
+ Assert.equal(
+ mimeService.validateFileNameForSaving(
+ "filename.\n\t lnk",
+ "text/unknown",
+ 0
+ ),
+ "filename. lnk",
+ "filename.lnk with space and newline"
+ );
+
+ Assert.equal(
+ mimeService.validateFileNameForSaving(
+ "filename.local\u180e\u180e\u180e",
+ "text/unknown",
+ 0
+ ),
+ "filename.local.download",
+ "filename.lnk with vowel separators"
+ );
+
+ Assert.equal(
+ mimeService.validateFileNameForSaving(
+ "filename.LNK",
+ "text/unknown",
+ mimeService.VALIDATE_SANITIZE_ONLY
+ ),
+ "filename.LNK.download",
+ "filename.LNK sanitize only"
+ );
+
+ Assert.equal(
+ mimeService.validateFileNameForSaving(
+ "filename.LNK\n",
+ "text/unknown",
+ mimeService.VALIDATE_ALLOW_INVALID_FILENAMES
+ ),
+ "filename.LNK",
+ "filename.LNK allow invalid"
+ );
+
+ Assert.equal(
+ mimeService.validateFileNameForSaving(
+ "filename.URL\n",
+ "text/unknown",
+ mimeService.VALIDATE_SANITIZE_ONLY |
+ mimeService.VALIDATE_ALLOW_INVALID_FILENAMES
+ ),
+ "filename.URL",
+ "filename.URL allow invalid, sanitize only"
+ );
+
+ Assert.equal(
+ mimeService.validateFileNameForSaving(
+ "filename.desktop",
+ "text/unknown",
+ mimeService.VALIDATE_SANITIZE_ONLY
+ ),
+ "filename.desktop.download",
+ "filename.desktop sanitize only"
+ );
+ Assert.equal(
+ mimeService.validateFileNameForSaving(
+ "filename.DESKTOP\n",
+ "text/unknown",
+ mimeService.VALIDATE_SANITIZE_ONLY |
+ mimeService.VALIDATE_ALLOW_INVALID_FILENAMES
+ ),
+ "filename.DESKTOP",
+ "filename.DESKTOP allow invalid, sanitize only"
+ );
+});
diff --git a/uriloader/exthandler/tests/unit/test_getFromTypeAndExtension.js b/uriloader/exthandler/tests/unit/test_getFromTypeAndExtension.js
new file mode 100644
index 0000000000..6f4fe52a49
--- /dev/null
+++ b/uriloader/exthandler/tests/unit/test_getFromTypeAndExtension.js
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_utf8_extension() {
+ const mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+ let someMIME = mimeService.getFromTypeAndExtension(
+ "application/x-nonsense",
+ ".тест"
+ );
+ Assert.stringContains(someMIME.description, "тест");
+ // primary extension isn't set on macOS or android, see bug 1721181
+ if (AppConstants.platform != "macosx" && AppConstants.platform != "android") {
+ Assert.equal(someMIME.primaryExtension, ".тест");
+ }
+});
diff --git a/uriloader/exthandler/tests/unit/test_getMIMEInfo_pdf.js b/uriloader/exthandler/tests/unit/test_getMIMEInfo_pdf.js
new file mode 100644
index 0000000000..03b0cea25e
--- /dev/null
+++ b/uriloader/exthandler/tests/unit/test_getMIMEInfo_pdf.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gMIMEService",
+ "@mozilla.org/mime;1",
+ "nsIMIMEService"
+);
+
+// PDF files should always have a generic description instead
+// of relying on what is registered with the Operating System.
+add_task(async function test_check_unknown_mime_type() {
+ const mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+ let pdfType = mimeService.getTypeFromExtension("pdf");
+ Assert.equal(pdfType, "application/pdf");
+ let extension = mimeService.getPrimaryExtension("application/pdf", "");
+ Assert.equal(extension, "pdf", "Expect pdf extension when given mime");
+ let mimeInfo = gMIMEService.getFromTypeAndExtension("", "pdf");
+ let stringBundle = Services.strings.createBundle(
+ "chrome://mozapps/locale/downloads/unknownContentType.properties"
+ );
+ Assert.equal(
+ mimeInfo.description,
+ stringBundle.GetStringFromName("pdfExtHandlerDescription"),
+ "PDF has generic description"
+ );
+});
diff --git a/uriloader/exthandler/tests/unit/test_getMIMEInfo_unknown_mime_type.js b/uriloader/exthandler/tests/unit/test_getMIMEInfo_unknown_mime_type.js
new file mode 100644
index 0000000000..9beef9d9c5
--- /dev/null
+++ b/uriloader/exthandler/tests/unit/test_getMIMEInfo_unknown_mime_type.js
@@ -0,0 +1,32 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Zip files can be opened by Windows explorer, so we should always be able to
+// determine a description and default handler for them. However, things can
+// get messy if they are sent to us with a mime type other than what Windows
+// considers the "right" mimetype (application/x-zip-compressed), like
+// application/zip, which is what most places (IANA, macOS, probably all linux
+// distros, Apache, etc.) think is the "right" mimetype.
+add_task(async function test_check_unknown_mime_type() {
+ const mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+ let zipType = mimeService.getTypeFromExtension("zip");
+ Assert.equal(zipType, "application/x-zip-compressed");
+ try {
+ let extension = mimeService.getPrimaryExtension("application/zip", "");
+ Assert.equal(
+ extension,
+ "zip",
+ "Expect our own info to provide an extension for zip files."
+ );
+ } catch (ex) {
+ Assert.ok(false, "We shouldn't throw when getting zip info.");
+ }
+ let found = {};
+ let mimeInfo = mimeService.getMIMEInfoFromOS("application/zip", "zip", found);
+ Assert.ok(
+ mimeInfo.hasDefaultHandler,
+ "Should have a default app for zip files"
+ );
+});
diff --git a/uriloader/exthandler/tests/unit/test_getTypeFromExtension_ext_to_type_mapping.js b/uriloader/exthandler/tests/unit/test_getTypeFromExtension_ext_to_type_mapping.js
new file mode 100644
index 0000000000..7202db58de
--- /dev/null
+++ b/uriloader/exthandler/tests/unit/test_getTypeFromExtension_ext_to_type_mapping.js
@@ -0,0 +1,65 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test for bug 508030 <https://bugzilla.mozilla.org/show_bug.cgi?id=508030>:
+ * nsIMIMEService.getTypeFromExtension fails to find a match in the
+ * "ext-to-type-mapping" category if the provided extension is not lowercase.
+ */
+function run_test() {
+ // --- Common services ---
+
+ const mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+
+ const categoryManager = Services.catMan;
+
+ // --- Test procedure ---
+
+ const kTestExtension = "testextension";
+ const kTestExtensionMixedCase = "testExtensIon";
+ const kTestMimeType = "application/x-testextension";
+
+ // Ensure that the test extension is not initially recognized by the operating
+ // system or the "ext-to-type-mapping" category.
+ try {
+ // Try and get the MIME type associated with the extension.
+ mimeService.getTypeFromExtension(kTestExtension);
+ // The line above should have thrown an exception.
+ do_throw("nsIMIMEService.getTypeFromExtension succeeded unexpectedly");
+ } catch (e) {
+ if (
+ !(e instanceof Ci.nsIException) ||
+ e.result != Cr.NS_ERROR_NOT_AVAILABLE
+ ) {
+ throw e;
+ }
+ // This is an expected exception, thrown if the type can't be determined.
+ // Any other exception would cause the test to fail.
+ }
+
+ // Add a temporary category entry mapping the extension to the MIME type.
+ categoryManager.addCategoryEntry(
+ "ext-to-type-mapping",
+ kTestExtension,
+ kTestMimeType,
+ false,
+ true
+ );
+
+ // Check that the mapping is recognized in the simple case.
+ var type = mimeService.getTypeFromExtension(kTestExtension);
+ Assert.equal(type, kTestMimeType);
+
+ // Check that the mapping is recognized even if the extension has mixed case.
+ type = mimeService.getTypeFromExtension(kTestExtensionMixedCase);
+ Assert.equal(type, kTestMimeType);
+
+ // Clean up after ourselves.
+ categoryManager.deleteCategoryEntry(
+ "ext-to-type-mapping",
+ kTestExtension,
+ false
+ );
+}
diff --git a/uriloader/exthandler/tests/unit/test_getTypeFromExtension_with_empty_Content_Type.js b/uriloader/exthandler/tests/unit/test_getTypeFromExtension_with_empty_Content_Type.js
new file mode 100644
index 0000000000..5076a57738
--- /dev/null
+++ b/uriloader/exthandler/tests/unit/test_getTypeFromExtension_with_empty_Content_Type.js
@@ -0,0 +1,218 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test for bug 484579 <https://bugzilla.mozilla.org/show_bug.cgi?id=484579>:
+ * nsIMIMEService.getTypeFromExtension may fail unexpectedly on Windows when
+ * "Content Type" is empty in the registry.
+ */
+
+// We must use a file extension that isn't listed in nsExternalHelperAppService's
+// defaultMimeEntries, otherwise the code takes a shortcut skipping the registry.
+const FILE_EXTENSION = ".nfo";
+// This is used to ensure the test properly used the mock, so that if we change
+// the underlying code, it won't be skipped.
+let gTestUsedOurMock = false;
+
+function run_test() {
+ // Activate the override of the file association data in the registry.
+ registerMockWindowsRegKeyFactory();
+
+ // Check the mock has been properly activated.
+ let regKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+ regKey.open(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CLASSES_ROOT,
+ FILE_EXTENSION,
+ Ci.nsIWindowsRegKey.ACCESS_QUERY_VALUE
+ );
+ Assert.equal(
+ regKey.readStringValue("content type"),
+ "",
+ "Check the mock replied as expected."
+ );
+ Assert.ok(gTestUsedOurMock, "The test properly used the mock registry");
+ // Reset gTestUsedOurMock, because we just used it.
+ gTestUsedOurMock = false;
+ // Try and get the MIME type associated with the extension. If this
+ // operation does not throw an unexpected exception, the test succeeds.
+ Assert.throws(
+ () => {
+ Cc["@mozilla.org/mime;1"]
+ .getService(Ci.nsIMIMEService)
+ .getTypeFromExtension(FILE_EXTENSION);
+ },
+ /NS_ERROR_NOT_AVAILABLE/,
+ "Should throw a NOT_AVAILABLE exception"
+ );
+
+ Assert.ok(gTestUsedOurMock, "The test properly used the mock registry");
+}
+
+/**
+ * Constructs a new mock registry key by wrapping the provided object.
+ *
+ * This mock implementation is tailored for this test, and forces consumers
+ * of the readStringValue method to believe that the "Content Type" value of
+ * the FILE_EXTENSION key under HKEY_CLASSES_ROOT is an empty string.
+ *
+ * The same value read from "HKEY_LOCAL_MACHINE\SOFTWARE\Classes" is not
+ * affected.
+ *
+ * @param aWrappedObject An actual nsIWindowsRegKey implementation.
+ */
+function MockWindowsRegKey(aWrappedObject) {
+ this._wrappedObject = aWrappedObject;
+
+ // This function creates a forwarding function for wrappedObject
+ function makeForwardingFunction(functionName) {
+ return function () {
+ return aWrappedObject[functionName].apply(aWrappedObject, arguments);
+ };
+ }
+
+ // Forward all the functions that are not explicitly overridden
+ for (var propertyName in aWrappedObject) {
+ if (!(propertyName in this)) {
+ if (typeof aWrappedObject[propertyName] == "function") {
+ this[propertyName] = makeForwardingFunction(propertyName);
+ } else {
+ this[propertyName] = aWrappedObject[propertyName];
+ }
+ }
+ }
+}
+
+MockWindowsRegKey.prototype = {
+ // --- Overridden nsISupports interface functions ---
+
+ QueryInterface: ChromeUtils.generateQI(["nsIWindowsRegKey"]),
+
+ // --- Overridden nsIWindowsRegKey interface functions ---
+
+ open(aRootKey, aRelPath, aMode) {
+ // Remember the provided root key and path
+ this._rootKey = aRootKey;
+ this._relPath = aRelPath;
+
+ // Create the actual registry key
+ return this._wrappedObject.open(aRootKey, aRelPath, aMode);
+ },
+
+ openChild(aRelPath, aMode) {
+ // Open the child key and wrap it
+ var innerKey = this._wrappedObject.openChild(aRelPath, aMode);
+ var key = new MockWindowsRegKey(innerKey);
+
+ // Set the properties of the child key and return it
+ key._rootKey = this._rootKey;
+ key._relPath = this._relPath + aRelPath;
+ return key;
+ },
+
+ createChild(aRelPath, aMode) {
+ // Create the child key and wrap it
+ var innerKey = this._wrappedObject.createChild(aRelPath, aMode);
+ var key = new MockWindowsRegKey(innerKey);
+
+ // Set the properties of the child key and return it
+ key._rootKey = this._rootKey;
+ key._relPath = this._relPath + aRelPath;
+ return key;
+ },
+
+ get childCount() {
+ return this._wrappedObject.childCount;
+ },
+
+ get valueCount() {
+ return this._wrappedObject.valueCount;
+ },
+
+ readStringValue(aName) {
+ // If this is the key under test, return a fake value
+ if (
+ this._rootKey == Ci.nsIWindowsRegKey.ROOT_KEY_CLASSES_ROOT &&
+ this._relPath.toLowerCase() == FILE_EXTENSION &&
+ aName.toLowerCase() == "content type"
+ ) {
+ gTestUsedOurMock = true;
+ return "";
+ }
+ // Return the real value from the registry
+ return this._wrappedObject.readStringValue(aName);
+ },
+};
+
+function registerMockWindowsRegKeyFactory() {
+ const kMockCID = Components.ID("{9b23dfe9-296b-4740-ba1c-d39c9a16e55e}");
+ const kWindowsRegKeyContractID = "@mozilla.org/windows-registry-key;1";
+ // Preserve the original CID.
+ let originalWindowsRegKeyCID = Cc[kWindowsRegKeyContractID].number;
+
+ // See bug 1694345 - nsNotifyAddrListener::CheckAdaptersAddresses might
+ // attempt to use the registry off the main thread, so we disable that
+ // feature while the mock registry is active.
+ let oldSuffixListPref = Services.prefs.getBoolPref(
+ "network.notify.dnsSuffixList"
+ );
+ Services.prefs.setBoolPref("network.notify.dnsSuffixList", false);
+
+ let oldCheckForProxiesPref = Services.prefs.getBoolPref(
+ "network.notify.checkForProxies"
+ );
+ Services.prefs.setBoolPref("network.notify.checkForProxies", false);
+
+ let oldCheckForNRPTPref = Services.prefs.getBoolPref(
+ "network.notify.checkForNRPT"
+ );
+ Services.prefs.setBoolPref("network.notify.checkForNRPT", false);
+
+ info("Create a mock RegKey factory");
+ let originalRegKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+ let mockWindowsRegKeyFactory = {
+ createInstance(iid) {
+ info("Create a mock wrapper around RegKey");
+ var key = new MockWindowsRegKey(originalRegKey);
+ return key.QueryInterface(iid);
+ },
+ };
+ info("Register the mock RegKey factory");
+ let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+ registrar.registerFactory(
+ kMockCID,
+ "Mock Windows Registry Key Implementation",
+ kWindowsRegKeyContractID,
+ mockWindowsRegKeyFactory
+ );
+
+ registerCleanupFunction(() => {
+ // Free references to the mock factory
+ registrar.unregisterFactory(kMockCID, mockWindowsRegKeyFactory);
+ // Restore the original factory
+ registrar.registerFactory(
+ Components.ID(originalWindowsRegKeyCID),
+ "",
+ kWindowsRegKeyContractID,
+ null
+ );
+
+ Services.prefs.setBoolPref(
+ "network.notify.dnsSuffixList",
+ oldSuffixListPref
+ );
+ Services.prefs.setBoolPref(
+ "network.notify.checkForProxies",
+ oldCheckForProxiesPref
+ );
+ Services.prefs.setBoolPref(
+ "network.notify.checkForNRPT",
+ oldCheckForNRPTPref
+ );
+ });
+}
diff --git a/uriloader/exthandler/tests/unit/test_handlerService.js b/uriloader/exthandler/tests/unit/test_handlerService.js
new file mode 100644
index 0000000000..46a4d9fdd9
--- /dev/null
+++ b/uriloader/exthandler/tests/unit/test_handlerService.js
@@ -0,0 +1,467 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ //* *************************************************************************//
+ // Constants
+
+ const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+ );
+
+ const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+
+ const protoSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService);
+
+ const prefSvc = Services.prefs;
+
+ let noMailto = false;
+ if (mozinfo.os == "win") {
+ // Check mailto handler from registry.
+ // If registry entry is nothing, no mailto handler
+ let regSvc = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+ try {
+ regSvc.open(regSvc.ROOT_KEY_CLASSES_ROOT, "mailto", regSvc.ACCESS_READ);
+ noMailto = false;
+ } catch (ex) {
+ noMailto = true;
+ }
+ regSvc.close();
+ }
+
+ if (mozinfo.os == "linux") {
+ // Check mailto handler from GIO
+ // If there isn't one, then we have no mailto handler
+ let gIOSvc = Cc["@mozilla.org/gio-service;1"].createInstance(
+ Ci.nsIGIOService
+ );
+ try {
+ gIOSvc.getAppForURIScheme("mailto");
+ noMailto = false;
+ } catch (ex) {
+ noMailto = true;
+ }
+ }
+
+ //* *************************************************************************//
+ // Sample Data
+
+ // It doesn't matter whether or not this nsIFile is actually executable,
+ // only that it has a path and exists. Since we don't know any executable
+ // that exists on all platforms (except possibly the application being
+ // tested, but there doesn't seem to be a way to get a reference to that
+ // from the directory service), we use the temporary directory itself.
+ var executable = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ // XXX We could, of course, create an actual executable in the directory:
+ // executable.append("localhandler");
+ // if (!executable.exists())
+ // executable.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o755);
+
+ var localHandler = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ localHandler.name = "Local Handler";
+ localHandler.executable = executable;
+
+ var webHandler = Cc[
+ "@mozilla.org/uriloader/web-handler-app;1"
+ ].createInstance(Ci.nsIWebHandlerApp);
+ webHandler.name = "Web Handler";
+ webHandler.uriTemplate = "http://www.example.com/?%s";
+
+ // FIXME: these tests create and manipulate enough variables that it would
+ // make sense to move each test into its own scope so we don't run the risk
+ // of one test stomping on another's data.
+
+ //* *************************************************************************//
+ // Test Default Properties
+
+ // Get a handler info for a MIME type that neither the application nor
+ // the OS knows about and make sure its properties are set to the proper
+ // default values.
+
+ var handlerInfo = mimeSvc.getFromTypeAndExtension("nonexistent/type", null);
+
+ // Make sure it's also an nsIHandlerInfo.
+ Assert.ok(handlerInfo instanceof Ci.nsIHandlerInfo);
+
+ Assert.equal(handlerInfo.type, "nonexistent/type");
+
+ // Deprecated property, but we should still make sure it's set correctly.
+ Assert.equal(handlerInfo.MIMEType, "nonexistent/type");
+
+ // These properties are the ones the handler service knows how to store.
+ Assert.equal(handlerInfo.preferredAction, Ci.nsIHandlerInfo.saveToDisk);
+ Assert.equal(handlerInfo.preferredApplicationHandler, null);
+ Assert.equal(handlerInfo.possibleApplicationHandlers.length, 0);
+ Assert.equal(
+ handlerInfo.alwaysAskBeforeHandling,
+ prefSvc.getBoolPref(
+ "browser.download.always_ask_before_handling_new_types",
+ false
+ )
+ );
+
+ // These properties are initialized to default values by the service,
+ // so we might as well make sure they're initialized to the right defaults.
+ Assert.equal(handlerInfo.description, "");
+ Assert.equal(handlerInfo.hasDefaultHandler, false);
+ Assert.equal(handlerInfo.defaultDescription, "");
+
+ const kExternalWarningDefault =
+ "network.protocol-handler.warn-external-default";
+ prefSvc.setBoolPref(kExternalWarningDefault, true);
+
+ // XXX add more thorough protocol info property checking
+
+ // no OS default handler exists
+ var protoInfo = protoSvc.getProtocolHandlerInfo("x-moz-rheet");
+ Assert.equal(protoInfo.preferredAction, protoInfo.alwaysAsk);
+ Assert.ok(protoInfo.alwaysAskBeforeHandling);
+
+ // OS default exists, injected default does not exist,
+ // explicit warning pref: false
+ const kExternalWarningPrefPrefix = "network.protocol-handler.warn-external.";
+ prefSvc.setBoolPref(kExternalWarningPrefPrefix + "http", false);
+ protoInfo = protoSvc.getProtocolHandlerInfo("http");
+ Assert.equal(0, protoInfo.possibleApplicationHandlers.length);
+ // NOTE: this assertion will fail if the system executing the test does not
+ // have a handler registered for the http protocol. This is very unlikely to
+ // actually happen except on certain configurations of Linux, but one of
+ // those configurations is the default WSL Ubuntu install. So, if you are
+ // running this test locally and seeing a failure here, it might not be
+ // anything to really worry about.
+ Assert.ok(!protoInfo.alwaysAskBeforeHandling);
+
+ // OS default exists, injected default does not exist,
+ // explicit warning pref: true
+ prefSvc.setBoolPref(kExternalWarningPrefPrefix + "http", true);
+ protoInfo = protoSvc.getProtocolHandlerInfo("http");
+ // OS handler isn't included in possibleApplicationHandlers, so length is 0
+ // Once they become instances of nsILocalHandlerApp, this number will need
+ // to change.
+ Assert.equal(0, protoInfo.possibleApplicationHandlers.length);
+ Assert.ok(protoInfo.alwaysAskBeforeHandling);
+
+ // OS default exists, injected default exists, explicit warning pref: false
+ prefSvc.setBoolPref(kExternalWarningPrefPrefix + "mailto", false);
+ protoInfo = protoSvc.getProtocolHandlerInfo("mailto");
+ if (AppConstants.MOZ_APP_NAME == "thunderbird") {
+ Assert.equal(0, protoInfo.possibleApplicationHandlers.length);
+ } else {
+ Assert.equal(1, protoInfo.possibleApplicationHandlers.length);
+ }
+
+ // Win7+ or Linux's GIO might not have a default mailto: handler
+ if (noMailto) {
+ Assert.ok(protoInfo.alwaysAskBeforeHandling);
+ } else {
+ Assert.ok(!protoInfo.alwaysAskBeforeHandling);
+ }
+
+ // OS default exists, injected default exists, explicit warning pref: true
+ prefSvc.setBoolPref(kExternalWarningPrefPrefix + "mailto", true);
+ protoInfo = protoSvc.getProtocolHandlerInfo("mailto");
+ if (AppConstants.MOZ_APP_NAME == "thunderbird") {
+ Assert.equal(0, protoInfo.possibleApplicationHandlers.length);
+ } else {
+ Assert.equal(1, protoInfo.possibleApplicationHandlers.length);
+ // Win7+ or Linux's GIO may have no default mailto: handler, so we'd ask
+ // anyway. Otherwise, the default handlers will not have stored preferred
+ // actions etc., so re-requesting them after the warning pref has changed
+ // will use the updated pref value. So both when we have and do not have
+ // a default mailto: handler, we'll ask:
+ Assert.ok(protoInfo.alwaysAskBeforeHandling);
+ // As soon as anyone actually stores updated defaults into the profile
+ // database, that default will stop tracking the warning pref.
+ }
+ // Now set the value stored in RDF to true, and the pref to false, to make
+ // sure we still get the right value. (Basically, same thing as above but
+ // with the values reversed.)
+ prefSvc.setBoolPref(kExternalWarningPrefPrefix + "mailto", false);
+ protoInfo.alwaysAskBeforeHandling = true;
+ handlerSvc.store(protoInfo);
+ protoInfo = protoSvc.getProtocolHandlerInfo("mailto");
+ if (AppConstants.MOZ_APP_NAME == "thunderbird") {
+ Assert.equal(0, protoInfo.possibleApplicationHandlers.length);
+ } else {
+ Assert.equal(1, protoInfo.possibleApplicationHandlers.length);
+ Assert.ok(protoInfo.alwaysAskBeforeHandling);
+ }
+
+ //* *************************************************************************//
+ // Test Round-Trip Data Integrity
+
+ // Test round-trip data integrity by setting the properties of the handler
+ // info object to different values, telling the handler service to store the
+ // object, and then retrieving a new info object for the same type and making
+ // sure its properties are identical.
+
+ handlerInfo.preferredAction = Ci.nsIHandlerInfo.useHelperApp;
+ handlerInfo.preferredApplicationHandler = localHandler;
+ handlerInfo.alwaysAskBeforeHandling = false;
+
+ handlerSvc.store(handlerInfo);
+
+ handlerInfo = mimeSvc.getFromTypeAndExtension("nonexistent/type", null);
+
+ Assert.equal(handlerInfo.preferredAction, Ci.nsIHandlerInfo.useHelperApp);
+
+ Assert.notEqual(handlerInfo.preferredApplicationHandler, null);
+ var preferredHandler = handlerInfo.preferredApplicationHandler;
+ Assert.equal(typeof preferredHandler, "object");
+ Assert.equal(preferredHandler.name, "Local Handler");
+ Assert.ok(preferredHandler instanceof Ci.nsILocalHandlerApp);
+ preferredHandler.QueryInterface(Ci.nsILocalHandlerApp);
+ Assert.equal(preferredHandler.executable.path, localHandler.executable.path);
+
+ Assert.ok(!handlerInfo.alwaysAskBeforeHandling);
+
+ // Make sure the handler service's enumerate method lists all known handlers.
+ var handlerInfo2 = mimeSvc.getFromTypeAndExtension("nonexistent/type2", null);
+ handlerSvc.store(handlerInfo2);
+ var handlerTypes = ["nonexistent/type", "nonexistent/type2"];
+ handlerTypes.push("mailto");
+ for (let handler of handlerSvc.enumerate()) {
+ Assert.notEqual(handlerTypes.indexOf(handler.type), -1);
+ handlerTypes.splice(handlerTypes.indexOf(handler.type), 1);
+ }
+ Assert.equal(handlerTypes.length, 0);
+ // Make sure the handler service's remove method removes a handler record.
+ handlerSvc.remove(handlerInfo2);
+ let handlers = handlerSvc.enumerate();
+ while (handlers.hasMoreElements()) {
+ Assert.notEqual(
+ handlers.getNext().QueryInterface(Ci.nsIHandlerInfo).type,
+ handlerInfo2.type
+ );
+ }
+
+ // Make sure we can store and retrieve a handler info object with no preferred
+ // handler.
+ var noPreferredHandlerInfo = mimeSvc.getFromTypeAndExtension(
+ "nonexistent/no-preferred-handler",
+ null
+ );
+ handlerSvc.store(noPreferredHandlerInfo);
+ noPreferredHandlerInfo = mimeSvc.getFromTypeAndExtension(
+ "nonexistent/no-preferred-handler",
+ null
+ );
+ Assert.equal(noPreferredHandlerInfo.preferredApplicationHandler, null);
+
+ // Make sure that the handler service removes an existing handler record
+ // if we store a handler info object with no preferred handler.
+ var removePreferredHandlerInfo = mimeSvc.getFromTypeAndExtension(
+ "nonexistent/rem-preferred-handler",
+ null
+ );
+ removePreferredHandlerInfo.preferredApplicationHandler = localHandler;
+ handlerSvc.store(removePreferredHandlerInfo);
+ removePreferredHandlerInfo = mimeSvc.getFromTypeAndExtension(
+ "nonexistent/rem-preferred-handler",
+ null
+ );
+ removePreferredHandlerInfo.preferredApplicationHandler = null;
+ handlerSvc.store(removePreferredHandlerInfo);
+ removePreferredHandlerInfo = mimeSvc.getFromTypeAndExtension(
+ "nonexistent/rem-preferred-handler",
+ null
+ );
+ Assert.equal(removePreferredHandlerInfo.preferredApplicationHandler, null);
+
+ // Make sure we can store and retrieve a handler info object with possible
+ // handlers. We test both adding and removing handlers.
+
+ // Get a handler info and make sure it has no possible handlers.
+ var possibleHandlersInfo = mimeSvc.getFromTypeAndExtension(
+ "nonexistent/possible-handlers",
+ null
+ );
+ Assert.equal(possibleHandlersInfo.possibleApplicationHandlers.length, 0);
+
+ // Store and re-retrieve the handler and make sure it still has no possible
+ // handlers.
+ handlerSvc.store(possibleHandlersInfo);
+ possibleHandlersInfo = mimeSvc.getFromTypeAndExtension(
+ "nonexistent/possible-handlers",
+ null
+ );
+ Assert.equal(possibleHandlersInfo.possibleApplicationHandlers.length, 0);
+
+ // Add two handlers, store the object, re-retrieve it, and make sure it has
+ // two handlers.
+ possibleHandlersInfo.possibleApplicationHandlers.appendElement(localHandler);
+ possibleHandlersInfo.possibleApplicationHandlers.appendElement(webHandler);
+ handlerSvc.store(possibleHandlersInfo);
+ possibleHandlersInfo = mimeSvc.getFromTypeAndExtension(
+ "nonexistent/possible-handlers",
+ null
+ );
+ Assert.equal(possibleHandlersInfo.possibleApplicationHandlers.length, 2);
+
+ // Figure out which is the local and which is the web handler and the index
+ // in the array of the local handler, which is the one we're going to remove
+ // to test removal of a handler.
+ var handler1 =
+ possibleHandlersInfo.possibleApplicationHandlers.queryElementAt(
+ 0,
+ Ci.nsIHandlerApp
+ );
+ var handler2 =
+ possibleHandlersInfo.possibleApplicationHandlers.queryElementAt(
+ 1,
+ Ci.nsIHandlerApp
+ );
+ var localPossibleHandler, webPossibleHandler, localIndex;
+ if (handler1 instanceof Ci.nsILocalHandlerApp) {
+ [localPossibleHandler, webPossibleHandler, localIndex] = [
+ handler1,
+ handler2,
+ 0,
+ ];
+ } else {
+ [localPossibleHandler, webPossibleHandler, localIndex] = [
+ handler2,
+ handler1,
+ 1,
+ ];
+ }
+ localPossibleHandler.QueryInterface(Ci.nsILocalHandlerApp);
+ webPossibleHandler.QueryInterface(Ci.nsIWebHandlerApp);
+
+ // Make sure the two handlers are the ones we stored.
+ Assert.equal(localPossibleHandler.name, localHandler.name);
+ Assert.ok(localPossibleHandler.equals(localHandler));
+ Assert.equal(webPossibleHandler.name, webHandler.name);
+ Assert.ok(webPossibleHandler.equals(webHandler));
+
+ // Remove a handler, store the object, re-retrieve it, and make sure
+ // it only has one handler.
+ possibleHandlersInfo.possibleApplicationHandlers.removeElementAt(localIndex);
+ handlerSvc.store(possibleHandlersInfo);
+ possibleHandlersInfo = mimeSvc.getFromTypeAndExtension(
+ "nonexistent/possible-handlers",
+ null
+ );
+ Assert.equal(possibleHandlersInfo.possibleApplicationHandlers.length, 1);
+
+ // Make sure the handler is the one we didn't remove.
+ webPossibleHandler =
+ possibleHandlersInfo.possibleApplicationHandlers.queryElementAt(
+ 0,
+ Ci.nsIWebHandlerApp
+ );
+ Assert.equal(webPossibleHandler.name, webHandler.name);
+ Assert.ok(webPossibleHandler.equals(webHandler));
+
+ // ////////////////////////////////////////////////////
+ // handler info command line parameters and equality
+ var localApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ var handlerApp = localApp.QueryInterface(Ci.nsIHandlerApp);
+
+ Assert.ok(handlerApp.equals(localApp));
+
+ localApp.executable = executable;
+
+ Assert.equal(0, localApp.parameterCount);
+ localApp.appendParameter("-test1");
+ Assert.equal(1, localApp.parameterCount);
+ localApp.appendParameter("-test2");
+ Assert.equal(2, localApp.parameterCount);
+ Assert.ok(localApp.parameterExists("-test1"));
+ Assert.ok(localApp.parameterExists("-test2"));
+ Assert.ok(!localApp.parameterExists("-false"));
+ localApp.clearParameters();
+ Assert.equal(0, localApp.parameterCount);
+
+ var localApp2 = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+
+ localApp2.executable = executable;
+
+ localApp.clearParameters();
+ Assert.ok(localApp.equals(localApp2));
+
+ // equal:
+ // cut -d 1 -f 2
+ // cut -d 1 -f 2
+
+ localApp.appendParameter("-test1");
+ localApp.appendParameter("-test2");
+ localApp.appendParameter("-test3");
+ localApp2.appendParameter("-test1");
+ localApp2.appendParameter("-test2");
+ localApp2.appendParameter("-test3");
+ Assert.ok(localApp.equals(localApp2));
+
+ // not equal:
+ // cut -d 1 -f 2
+ // cut -f 1 -d 2
+
+ localApp.clearParameters();
+ localApp2.clearParameters();
+
+ localApp.appendParameter("-test1");
+ localApp.appendParameter("-test2");
+ localApp.appendParameter("-test3");
+ localApp2.appendParameter("-test2");
+ localApp2.appendParameter("-test1");
+ localApp2.appendParameter("-test3");
+ Assert.ok(!localApp2.equals(localApp));
+
+ var str;
+ str = localApp.getParameter(0);
+ Assert.equal(str, "-test1");
+ str = localApp.getParameter(1);
+ Assert.equal(str, "-test2");
+ str = localApp.getParameter(2);
+ Assert.equal(str, "-test3");
+
+ // FIXME: test round trip integrity for a protocol.
+ // FIXME: test round trip integrity for a handler info with a web handler.
+
+ //* *************************************************************************//
+ // getTypeFromExtension tests
+
+ // test nonexistent extension
+ var lolType = handlerSvc.getTypeFromExtension("lolcat");
+ Assert.equal(lolType, "");
+
+ // add a handler for the extension
+ var lolHandler = mimeSvc.getFromTypeAndExtension("application/lolcat", null);
+
+ Assert.ok(!lolHandler.extensionExists("lolcat"));
+ lolHandler.preferredAction = Ci.nsIHandlerInfo.useHelperApp;
+ lolHandler.preferredApplicationHandler = localHandler;
+ lolHandler.alwaysAskBeforeHandling = false;
+ lolHandler.appendExtension("lolcat");
+
+ // store the handler
+ Assert.ok(!handlerSvc.exists(lolHandler));
+ handlerSvc.store(lolHandler);
+ Assert.ok(handlerSvc.exists(lolHandler));
+
+ // test now-existent extension
+ lolType = handlerSvc.getTypeFromExtension("lolcat");
+ Assert.equal(lolType, "application/lolcat");
+
+ // test mailcap entries with needsterminal are ignored on non-Windows non-Mac.
+ if (mozinfo.os != "win" && mozinfo.os != "mac") {
+ prefSvc.setStringPref(
+ "helpers.private_mailcap_file",
+ do_get_file("mailcap").path
+ );
+ handlerInfo = mimeSvc.getFromTypeAndExtension("text/plain", null);
+ Assert.equal(handlerInfo.preferredAction, Ci.nsIHandlerInfo.saveToDisk);
+ Assert.equal(handlerInfo.defaultDescription, "sed");
+ }
+}
diff --git a/uriloader/exthandler/tests/unit/test_handlerService_store.js b/uriloader/exthandler/tests/unit/test_handlerService_store.js
new file mode 100644
index 0000000000..03573dbc2c
--- /dev/null
+++ b/uriloader/exthandler/tests/unit/test_handlerService_store.js
@@ -0,0 +1,752 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests the nsIHandlerService interface.
+ */
+
+// Set up an nsIWebHandlerApp instance that can be used in multiple tests.
+let webHandlerApp = Cc[
+ "@mozilla.org/uriloader/web-handler-app;1"
+].createInstance(Ci.nsIWebHandlerApp);
+webHandlerApp.name = "Web Handler";
+webHandlerApp.uriTemplate = "https://www.example.com/?url=%s";
+let expectedWebHandlerApp = {
+ name: webHandlerApp.name,
+ uriTemplate: webHandlerApp.uriTemplate,
+};
+
+// Set up an nsILocalHandlerApp instance that can be used in multiple tests. The
+// executable should exist, but it doesn't need to point to an actual file, so
+// we simply initialize it to the path of an existing directory.
+let localHandlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+].createInstance(Ci.nsILocalHandlerApp);
+localHandlerApp.name = "Local Handler";
+localHandlerApp.executable = FileUtils.getFile("TmpD", []);
+let expectedLocalHandlerApp = {
+ name: localHandlerApp.name,
+ executable: localHandlerApp.executable,
+};
+
+/**
+ * Returns a new nsIHandlerInfo instance initialized to known values that don't
+ * depend on the platform and are easier to verify later.
+ *
+ * @param type
+ * Because the "preferredAction" is initialized to saveToDisk, this
+ * should represent a MIME type rather than a protocol.
+ */
+function getKnownHandlerInfo(type) {
+ let handlerInfo = HandlerServiceTestUtils.getBlankHandlerInfo(type);
+ handlerInfo.preferredAction = Ci.nsIHandlerInfo.saveToDisk;
+ handlerInfo.alwaysAskBeforeHandling = false;
+ return handlerInfo;
+}
+
+/**
+ * Checks that the information stored in the handler service instance under
+ * testing matches the test data files.
+ */
+function assertAllHandlerInfosMatchTestData() {
+ let handlerInfos = HandlerServiceTestUtils.getAllHandlerInfos();
+
+ // It's important that the MIME types we check here do not exist at the
+ // operating system level, otherwise the list of handlers and file extensions
+ // will be merged. The current implementation avoids duplicate entries.
+
+ HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), {
+ type: "example/type.handleinternally",
+ preferredAction: Ci.nsIHandlerInfo.handleInternally,
+ alwaysAskBeforeHandling: false,
+ fileExtensions: ["example_one"],
+ });
+
+ HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), {
+ type: "example/type.savetodisk",
+ preferredAction: Ci.nsIHandlerInfo.saveToDisk,
+ alwaysAskBeforeHandling: true,
+ preferredApplicationHandler: {
+ name: "Example Default Handler",
+ uriTemplate: "https://www.example.com/?url=%s",
+ },
+ possibleApplicationHandlers: [
+ {
+ name: "Example Default Handler",
+ uriTemplate: "https://www.example.com/?url=%s",
+ },
+ ],
+ fileExtensions: ["example_two", "example_three"],
+ });
+
+ HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), {
+ type: "example/type.usehelperapp",
+ preferredAction: Ci.nsIHandlerInfo.useHelperApp,
+ alwaysAskBeforeHandling: true,
+ preferredApplicationHandler: {
+ name: "Example Default Handler",
+ uriTemplate: "https://www.example.com/?url=%s",
+ },
+ possibleApplicationHandlers: [
+ {
+ name: "Example Default Handler",
+ uriTemplate: "https://www.example.com/?url=%s",
+ },
+ {
+ name: "Example Possible Handler One",
+ uriTemplate: "http://www.example.com/?id=1&url=%s",
+ },
+ {
+ name: "Example Possible Handler Two",
+ uriTemplate: "http://www.example.com/?id=2&url=%s",
+ },
+ ],
+ fileExtensions: ["example_two", "example_three"],
+ });
+
+ HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), {
+ type: "example/type.usesystemdefault",
+ preferredAction: Ci.nsIHandlerInfo.useSystemDefault,
+ alwaysAskBeforeHandling: false,
+ possibleApplicationHandlers: [
+ {
+ name: "Example Possible Handler",
+ uriTemplate: "http://www.example.com/?url=%s",
+ },
+ ],
+ });
+
+ HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), {
+ type: "examplescheme.usehelperapp",
+ preferredAction: Ci.nsIHandlerInfo.useHelperApp,
+ alwaysAskBeforeHandling: true,
+ preferredApplicationHandler: {
+ name: "Example Default Handler",
+ uriTemplate: "https://www.example.com/?url=%s",
+ },
+ possibleApplicationHandlers: [
+ {
+ name: "Example Default Handler",
+ uriTemplate: "https://www.example.com/?url=%s",
+ },
+ {
+ name: "Example Possible Handler One",
+ uriTemplate: "http://www.example.com/?id=1&url=%s",
+ },
+ {
+ name: "Example Possible Handler Two",
+ uriTemplate: "http://www.example.com/?id=2&url=%s",
+ },
+ ],
+ });
+
+ HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), {
+ type: "examplescheme.usesystemdefault",
+ preferredAction: Ci.nsIHandlerInfo.useSystemDefault,
+ alwaysAskBeforeHandling: false,
+ possibleApplicationHandlers: [
+ {
+ name: "Example Possible Handler",
+ uriTemplate: "http://www.example.com/?url=%s",
+ },
+ ],
+ });
+
+ Assert.equal(handlerInfos.length, 0);
+}
+
+/**
+ * Loads data from a file in a predefined format, verifying that the format is
+ * recognized and all the known properties are loaded and saved.
+ */
+add_task(async function test_store_fillHandlerInfo_predefined() {
+ // Test that the file format used in previous versions can be loaded.
+ await copyTestDataToHandlerStore();
+ await assertAllHandlerInfosMatchTestData();
+
+ // Keep a copy of the nsIHandlerInfo instances, then delete the handler store
+ // and populate it with the known data. Since the handler store is empty, the
+ // default handlers for the current locale are also injected, so we have to
+ // delete them manually before adding the other nsIHandlerInfo instances.
+ let testHandlerInfos = HandlerServiceTestUtils.getAllHandlerInfos();
+ await deleteHandlerStore();
+ for (let handlerInfo of HandlerServiceTestUtils.getAllHandlerInfos()) {
+ gHandlerService.remove(handlerInfo);
+ }
+ for (let handlerInfo of testHandlerInfos) {
+ gHandlerService.store(handlerInfo);
+ }
+
+ // Test that the known data still matches after saving it and reloading.
+ await unloadHandlerStore();
+ await assertAllHandlerInfosMatchTestData();
+});
+
+/**
+ * Check that "store" is able to add new instances, that "remove" and "exists"
+ * work, and that "fillHandlerInfo" throws when the instance does not exist.
+ */
+add_task(async function test_store_remove_exists() {
+ // Test both MIME types and protocols.
+ for (let type of [
+ "example/type.usehelperapp",
+ "examplescheme.usehelperapp",
+ ]) {
+ // Create new nsIHandlerInfo instances before loading the test data.
+ await deleteHandlerStore();
+ let handlerInfoPresent = HandlerServiceTestUtils.getHandlerInfo(type);
+ let handlerInfoAbsent = HandlerServiceTestUtils.getHandlerInfo(type + "2");
+
+ // Set up known properties that we can verify later.
+ handlerInfoAbsent.preferredAction = Ci.nsIHandlerInfo.saveToDisk;
+ handlerInfoAbsent.alwaysAskBeforeHandling = false;
+
+ await copyTestDataToHandlerStore();
+
+ Assert.ok(gHandlerService.exists(handlerInfoPresent));
+ Assert.ok(!gHandlerService.exists(handlerInfoAbsent));
+
+ gHandlerService.store(handlerInfoAbsent);
+ gHandlerService.remove(handlerInfoPresent);
+
+ await unloadHandlerStore();
+
+ Assert.ok(!gHandlerService.exists(handlerInfoPresent));
+ Assert.ok(gHandlerService.exists(handlerInfoAbsent));
+
+ Assert.throws(
+ () => gHandlerService.fillHandlerInfo(handlerInfoPresent, ""),
+ ex => ex.result == Cr.NS_ERROR_NOT_AVAILABLE
+ );
+
+ let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo(type + "2");
+ HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, {
+ type: type + "2",
+ preferredAction: Ci.nsIHandlerInfo.saveToDisk,
+ alwaysAskBeforeHandling: false,
+ });
+ }
+});
+
+/**
+ * Tests that it is possible to save an nsIHandlerInfo instance with a
+ * "preferredAction" that is either a valid or an unknown value, and the
+ * action always takes on an appropriate value when reloading.
+ */
+add_task(async function test_store_preferredAction() {
+ await deleteHandlerStore();
+
+ let handlerInfo = getKnownHandlerInfo("example/new");
+ // Valid action values should all remain unchanged across a refresh, except
+ // for alwaysAsk which may be overridden with useHelperApp depending on prefs.
+ // Invalid action values should always convert to useHelperApp.
+ const actions = [
+ {
+ preferred: Ci.nsIHandlerInfo.alwaysAsk,
+ expected: Ci.nsIHandlerInfo.alwaysAsk,
+ },
+ {
+ preferred: Ci.nsIHandlerInfo.handleInternally,
+ expected: Ci.nsIHandlerInfo.handleInternally,
+ },
+ { preferred: 999, expected: Ci.nsIHandlerInfo.useHelperApp },
+ ];
+
+ for (let action of actions) {
+ handlerInfo.preferredAction = action.preferred;
+ gHandlerService.store(handlerInfo);
+ gHandlerService.fillHandlerInfo(handlerInfo, "");
+ Assert.equal(handlerInfo.preferredAction, action.expected);
+ }
+});
+
+/**
+ * Tests that it is possible to save an nsIHandlerInfo instance containing an
+ * nsILocalHandlerApp instance pointing to an executable that doesn't exist, but
+ * this entry is ignored when reloading.
+ */
+add_task(async function test_store_localHandlerApp_missing() {
+ if (!("@mozilla.org/uriloader/dbus-handler-app;1" in Cc)) {
+ info("Skipping test because it does not apply to this platform.");
+ return;
+ }
+
+ let missingHandlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ missingHandlerApp.name = "Non-existing Handler";
+ missingHandlerApp.executable = FileUtils.getFile("TmpD", ["nonexisting"]);
+
+ await deleteHandlerStore();
+
+ let handlerInfo = getKnownHandlerInfo("example/new");
+ handlerInfo.preferredApplicationHandler = missingHandlerApp;
+ handlerInfo.possibleApplicationHandlers.appendElement(missingHandlerApp);
+ handlerInfo.possibleApplicationHandlers.appendElement(webHandlerApp);
+ gHandlerService.store(handlerInfo);
+
+ await unloadHandlerStore();
+
+ let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new");
+ HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, {
+ type: "example/new",
+ preferredAction: Ci.nsIHandlerInfo.saveToDisk,
+ alwaysAskBeforeHandling: false,
+ possibleApplicationHandlers: [expectedWebHandlerApp],
+ });
+});
+
+/**
+ * Test saving and reloading an instance of nsIDBusHandlerApp.
+ */
+add_task(async function test_store_dBusHandlerApp() {
+ if (!("@mozilla.org/uriloader/dbus-handler-app;1" in Cc)) {
+ info("Skipping test because it does not apply to this platform.");
+ return;
+ }
+
+ // Set up an nsIDBusHandlerApp instance for testing.
+ let dBusHandlerApp = Cc[
+ "@mozilla.org/uriloader/dbus-handler-app;1"
+ ].createInstance(Ci.nsIDBusHandlerApp);
+ dBusHandlerApp.name = "DBus Handler";
+ dBusHandlerApp.service = "test.method.server";
+ dBusHandlerApp.method = "Method";
+ dBusHandlerApp.dBusInterface = "test.method.Type";
+ dBusHandlerApp.objectPath = "/test/method/Object";
+ let expectedDBusHandlerApp = {
+ name: dBusHandlerApp.name,
+ service: dBusHandlerApp.service,
+ method: dBusHandlerApp.method,
+ dBusInterface: dBusHandlerApp.dBusInterface,
+ objectPath: dBusHandlerApp.objectPath,
+ };
+
+ await deleteHandlerStore();
+
+ let handlerInfo = getKnownHandlerInfo("example/new");
+ handlerInfo.preferredApplicationHandler = dBusHandlerApp;
+ handlerInfo.possibleApplicationHandlers.appendElement(dBusHandlerApp);
+ gHandlerService.store(handlerInfo);
+
+ await unloadHandlerStore();
+
+ let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new");
+ HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, {
+ type: "example/new",
+ preferredAction: Ci.nsIHandlerInfo.saveToDisk,
+ alwaysAskBeforeHandling: false,
+ preferredApplicationHandler: expectedDBusHandlerApp,
+ possibleApplicationHandlers: [expectedDBusHandlerApp],
+ });
+});
+
+/**
+ * Tests that it is possible to save an nsIHandlerInfo instance with a
+ * "preferredApplicationHandler" and no "possibleApplicationHandlers", but the
+ * former is always included in the latter list when reloading.
+ */
+add_task(
+ async function test_store_possibleApplicationHandlers_includes_preferred() {
+ await deleteHandlerStore();
+
+ let handlerInfo = getKnownHandlerInfo("example/new");
+ handlerInfo.preferredApplicationHandler = localHandlerApp;
+ gHandlerService.store(handlerInfo);
+
+ await unloadHandlerStore();
+
+ let actualHandlerInfo =
+ HandlerServiceTestUtils.getHandlerInfo("example/new");
+ HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, {
+ type: "example/new",
+ preferredAction: Ci.nsIHandlerInfo.saveToDisk,
+ alwaysAskBeforeHandling: false,
+ preferredApplicationHandler: expectedLocalHandlerApp,
+ possibleApplicationHandlers: [expectedLocalHandlerApp],
+ });
+ }
+);
+
+/**
+ * Tests that it is possible to save an nsIHandlerInfo instance with a
+ * "preferredApplicationHandler" that is not the first element in
+ * "possibleApplicationHandlers", but the former is always included as the first
+ * element of the latter list when reloading.
+ */
+add_task(
+ async function test_store_possibleApplicationHandlers_preferred_first() {
+ await deleteHandlerStore();
+
+ let handlerInfo = getKnownHandlerInfo("example/new");
+ handlerInfo.preferredApplicationHandler = webHandlerApp;
+ // The preferred handler is appended after the other one.
+ handlerInfo.possibleApplicationHandlers.appendElement(localHandlerApp);
+ handlerInfo.possibleApplicationHandlers.appendElement(webHandlerApp);
+ gHandlerService.store(handlerInfo);
+
+ await unloadHandlerStore();
+
+ let actualHandlerInfo =
+ HandlerServiceTestUtils.getHandlerInfo("example/new");
+ HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, {
+ type: "example/new",
+ preferredAction: Ci.nsIHandlerInfo.saveToDisk,
+ alwaysAskBeforeHandling: false,
+ preferredApplicationHandler: expectedWebHandlerApp,
+ possibleApplicationHandlers: [
+ expectedWebHandlerApp,
+ expectedLocalHandlerApp,
+ ],
+ });
+ }
+);
+
+/**
+ * Tests that it is possible to save an nsIHandlerInfo instance with an
+ * uppercase file extension, but it is converted to lowercase when reloading.
+ */
+add_task(async function test_store_fileExtensions_lowercase() {
+ await deleteHandlerStore();
+
+ let handlerInfo = getKnownHandlerInfo("example/new");
+ handlerInfo.appendExtension("extension_test1");
+ handlerInfo.appendExtension("EXTENSION_test2");
+ gHandlerService.store(handlerInfo);
+
+ await unloadHandlerStore();
+
+ let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new");
+ HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, {
+ type: "example/new",
+ preferredAction: Ci.nsIHandlerInfo.saveToDisk,
+ alwaysAskBeforeHandling: false,
+ fileExtensions: ["extension_test1", "extension_test2"],
+ });
+});
+
+/**
+ * Tests that appendExtension doesn't add duplicates, and that anyway duplicates
+ * from possibleApplicationHandlers are removed when saving and reloading.
+ */
+add_task(async function test_store_no_duplicates() {
+ await deleteHandlerStore();
+
+ let handlerInfo = getKnownHandlerInfo("example/new");
+ handlerInfo.preferredApplicationHandler = webHandlerApp;
+ handlerInfo.possibleApplicationHandlers.appendElement(webHandlerApp);
+ handlerInfo.possibleApplicationHandlers.appendElement(localHandlerApp);
+ handlerInfo.possibleApplicationHandlers.appendElement(localHandlerApp);
+ handlerInfo.possibleApplicationHandlers.appendElement(webHandlerApp);
+ handlerInfo.appendExtension("extension_test1");
+ handlerInfo.appendExtension("extension_test2");
+ handlerInfo.appendExtension("extension_test1");
+ handlerInfo.appendExtension("EXTENSION_test1");
+ Assert.deepEqual(Array.from(handlerInfo.getFileExtensions()), [
+ "extension_test1",
+ "extension_test2",
+ ]);
+ gHandlerService.store(handlerInfo);
+
+ await unloadHandlerStore();
+
+ let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new");
+ HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, {
+ type: "example/new",
+ preferredAction: Ci.nsIHandlerInfo.saveToDisk,
+ alwaysAskBeforeHandling: false,
+ preferredApplicationHandler: expectedWebHandlerApp,
+ possibleApplicationHandlers: [
+ expectedWebHandlerApp,
+ expectedLocalHandlerApp,
+ ],
+ fileExtensions: ["extension_test1", "extension_test2"],
+ });
+});
+
+/**
+ * Tests that setFileExtensions doesn't add duplicates.
+ */
+add_task(async function test_setFileExtensions_no_duplicates() {
+ await deleteHandlerStore();
+
+ let handlerInfo = getKnownHandlerInfo("example/new");
+ handlerInfo.setFileExtensions("a,b,A,b,c,a");
+ let expected = ["a", "b", "c"];
+ Assert.deepEqual(Array.from(handlerInfo.getFileExtensions()), expected);
+ // Test empty extensions, also at begin and end.
+ handlerInfo.setFileExtensions(",a,,b,A,c,");
+ Assert.deepEqual(Array.from(handlerInfo.getFileExtensions()), expected);
+});
+
+/**
+ * Tests that "store" deletes properties that have their default values from
+ * the data store.
+ *
+ * File extensions are never deleted once they have been associated.
+ */
+add_task(async function test_store_deletes_properties_except_extensions() {
+ await deleteHandlerStore();
+
+ // Prepare an nsIHandlerInfo instance with all the properties set to values
+ // that will result in deletions. The preferredAction is also set to a defined
+ // value so we can more easily verify it later.
+ let handlerInfo = HandlerServiceTestUtils.getBlankHandlerInfo(
+ "example/type.savetodisk"
+ );
+ handlerInfo.preferredAction = Ci.nsIHandlerInfo.saveToDisk;
+ handlerInfo.alwaysAskBeforeHandling = false;
+
+ // All the properties for "example/type.savetodisk" are present in the test
+ // data, so we load the data before overwriting their values.
+ await copyTestDataToHandlerStore();
+ gHandlerService.store(handlerInfo);
+
+ // Now we can reload the data and verify that no extra values have been kept.
+ await unloadHandlerStore();
+ let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo(
+ "example/type.savetodisk"
+ );
+ HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, {
+ type: "example/type.savetodisk",
+ preferredAction: Ci.nsIHandlerInfo.saveToDisk,
+ alwaysAskBeforeHandling: false,
+ fileExtensions: ["example_two", "example_three"],
+ });
+});
+
+/**
+ * Tests the "overrideType" argument of "fillHandlerInfo".
+ */
+add_task(async function test_fillHandlerInfo_overrideType() {
+ // Test both MIME types and protocols.
+ for (let type of [
+ "example/type.usesystemdefault",
+ "examplescheme.usesystemdefault",
+ ]) {
+ await deleteHandlerStore();
+
+ // Create new nsIHandlerInfo instances before loading the test data.
+ let handlerInfoAbsent = HandlerServiceTestUtils.getHandlerInfo(type + "2");
+
+ // Fill the nsIHandlerInfo instance using the type that actually exists.
+ await copyTestDataToHandlerStore();
+ gHandlerService.fillHandlerInfo(handlerInfoAbsent, type);
+ HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfoAbsent, {
+ // While the data is populated from another type, the type is unchanged.
+ type: type + "2",
+ preferredAction: Ci.nsIHandlerInfo.useSystemDefault,
+ alwaysAskBeforeHandling: false,
+ possibleApplicationHandlers: [
+ {
+ name: "Example Possible Handler",
+ uriTemplate: "http://www.example.com/?url=%s",
+ },
+ ],
+ });
+ }
+});
+
+/**
+ * Tests "getTypeFromExtension" including unknown extensions.
+ */
+add_task(async function test_getTypeFromExtension() {
+ await copyTestDataToHandlerStore();
+
+ Assert.equal(gHandlerService.getTypeFromExtension(""), "");
+ Assert.equal(gHandlerService.getTypeFromExtension("example_unknown"), "");
+ Assert.equal(
+ gHandlerService.getTypeFromExtension("example_one"),
+ "example/type.handleinternally"
+ );
+ Assert.equal(
+ gHandlerService.getTypeFromExtension("EXAMPLE_one"),
+ "example/type.handleinternally"
+ );
+});
+
+/**
+ * Checks that the information stored in the handler service instance under
+ * testing matches the default handlers for the English locale.
+ */
+function assertAllHandlerInfosMatchDefaultHandlers() {
+ let handlerInfos = HandlerServiceTestUtils.getAllHandlerInfos();
+
+ HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), {
+ type: "mailto",
+ preferredActionOSDependent: true,
+ possibleApplicationHandlers: [
+ {
+ name: "Gmail",
+ uriTemplate: "https://mail.google.com/mail/?extsrc=mailto&url=%s",
+ },
+ ],
+ });
+
+ Assert.equal(handlerInfos.length, 0);
+}
+
+/**
+ * Tests the default protocol handlers imported from the locale-specific data.
+ */
+add_task(
+ { skip_if: () => AppConstants.MOZ_APP_NAME == "thunderbird" },
+ async function test_default_protocol_handlers() {
+ if (
+ !Services.prefs.getPrefType("gecko.handlerService.defaultHandlersVersion")
+ ) {
+ info("This platform or locale does not have default handlers.");
+ return;
+ }
+
+ // This will inject the default protocol handlers for the current locale.
+ await deleteHandlerStore();
+
+ await assertAllHandlerInfosMatchDefaultHandlers();
+ }
+);
+
+/**
+ * Tests that the default protocol handlers are not imported again from the
+ * locale-specific data if they already exist.
+ */
+add_task(
+ { skip_if: () => AppConstants.MOZ_APP_NAME == "thunderbird" },
+ async function test_default_protocol_handlers_no_duplicates() {
+ if (
+ !Services.prefs.getPrefType("gecko.handlerService.defaultHandlersVersion")
+ ) {
+ info("This platform or locale does not have default handlers.");
+ return;
+ }
+
+ // This will inject the default protocol handlers for the current locale.
+ await deleteHandlerStore();
+
+ // Clear the preference to force injecting again.
+ Services.prefs.clearUserPref("gecko.handlerService.defaultHandlersVersion");
+
+ await unloadHandlerStore();
+
+ // There should be no duplicate handlers in the protocols.
+ assertAllHandlerInfosMatchDefaultHandlers();
+ }
+);
+
+/**
+ * Ensures forward compatibility by checking that the "store" method preserves
+ * unknown properties in the test data.
+ */
+add_task(async function test_store_keeps_unknown_properties() {
+ // Create a new nsIHandlerInfo instance before loading the test data.
+ await deleteHandlerStore();
+ let handlerInfo = HandlerServiceTestUtils.getHandlerInfo(
+ "example/type.handleinternally"
+ );
+
+ await copyTestDataToHandlerStore();
+ gHandlerService.store(handlerInfo);
+
+ await unloadHandlerStore();
+ let data = await IOUtils.readJSON(jsonPath);
+ Assert.equal(
+ data.mimeTypes["example/type.handleinternally"].unknownProperty,
+ "preserved"
+ );
+});
+
+/**
+ * Runs the asyncInit method, ensuring that it successfully inits the store
+ * and calls the handlersvc-store-initialized topic.
+ */
+add_task(async function test_async_init() {
+ await deleteHandlerStore();
+ await copyTestDataToHandlerStore();
+ gHandlerService.asyncInit();
+ await TestUtils.topicObserved("handlersvc-store-initialized");
+ await assertAllHandlerInfosMatchTestData();
+
+ await unloadHandlerStore();
+});
+
+/**
+ * Races the asyncInit method against the sync init (implicit in enumerate),
+ * to ensure that the store will be synchronously initialized without any
+ * ill effects.
+ */
+add_task(async function test_race_async_init() {
+ await deleteHandlerStore();
+ await copyTestDataToHandlerStore();
+ let storeInitialized = false;
+ // Pass a callback to synchronously observe the topic, as a promise would
+ // resolve asynchronously
+ TestUtils.topicObserved("handlersvc-store-initialized", () => {
+ storeInitialized = true;
+ return true;
+ });
+ gHandlerService.asyncInit();
+ Assert.ok(!storeInitialized);
+ gHandlerService.enumerate();
+ Assert.ok(storeInitialized);
+ await assertAllHandlerInfosMatchTestData();
+
+ await unloadHandlerStore();
+});
+
+/**
+ * Test saving and reloading an instance of nsIGIOMimeApp.
+ */
+add_task(async function test_store_gioHandlerApp() {
+ if (!("@mozilla.org/gio-service;1" in Cc)) {
+ info("Skipping test because it does not apply to this platform.");
+ return;
+ }
+
+ // Create dummy exec file that following won't fail because file not found error
+ let dummyHandlerFile = FileUtils.getFile("TmpD", ["dummyHandler"]);
+ dummyHandlerFile.createUnique(
+ Ci.nsIFile.NORMAL_FILE_TYPE,
+ parseInt("777", 8)
+ );
+
+ // Set up an nsIGIOMimeApp instance for testing.
+ let handlerApp = Cc["@mozilla.org/gio-service;1"]
+ .getService(Ci.nsIGIOService)
+ .createAppFromCommand(dummyHandlerFile.path, "Dummy GIO handler");
+ let expectedGIOMimeHandlerApp = {
+ name: handlerApp.name,
+ command: handlerApp.command,
+ };
+
+ await deleteHandlerStore();
+
+ let handlerInfo = getKnownHandlerInfo("example/new");
+ handlerInfo.preferredApplicationHandler = handlerApp;
+ handlerInfo.possibleApplicationHandlers.appendElement(handlerApp);
+ handlerInfo.possibleApplicationHandlers.appendElement(webHandlerApp);
+ gHandlerService.store(handlerInfo);
+
+ await unloadHandlerStore();
+
+ let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new");
+ HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, {
+ type: "example/new",
+ preferredAction: Ci.nsIHandlerInfo.saveToDisk,
+ alwaysAskBeforeHandling: false,
+ preferredApplicationHandler: expectedGIOMimeHandlerApp,
+ possibleApplicationHandlers: [expectedGIOMimeHandlerApp, webHandlerApp],
+ });
+
+ await IOUtils.remove(dummyHandlerFile.path);
+
+ // After removing dummyHandlerFile, the handler should disappear from the
+ // list of possibleApplicationHandlers and preferredAppHandler should be null.
+ actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new");
+ HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, {
+ type: "example/new",
+ preferredAction: Ci.nsIHandlerInfo.saveToDisk,
+ alwaysAskBeforeHandling: false,
+ preferredApplicationHandler: null,
+ possibleApplicationHandlers: [webHandlerApp],
+ });
+});
diff --git a/uriloader/exthandler/tests/unit/test_punycodeURIs.js b/uriloader/exthandler/tests/unit/test_punycodeURIs.js
new file mode 100644
index 0000000000..949c9914ec
--- /dev/null
+++ b/uriloader/exthandler/tests/unit/test_punycodeURIs.js
@@ -0,0 +1,126 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Encoded test URI to work on all platforms/independent of file encoding
+const kTestURI = "http://\u65e5\u672c\u8a93.jp/";
+const kExpectedURI = "http://xn--wgv71a309e.jp/";
+const kOutputFile = "result.txt";
+
+// Try several times in case the box we're running on is slow.
+const kMaxCheckExistAttempts = 30; // seconds
+var gCheckExistsAttempts = 0;
+
+const tempDir = do_get_tempdir();
+
+function checkFile() {
+ // This is where we expect the output
+ var tempFile = tempDir.clone();
+ tempFile.append(kOutputFile);
+
+ if (!tempFile.exists()) {
+ if (gCheckExistsAttempts >= kMaxCheckExistAttempts) {
+ do_throw(
+ "Expected File " +
+ tempFile.path +
+ " does not exist after " +
+ kMaxCheckExistAttempts +
+ " seconds"
+ );
+ } else {
+ ++gCheckExistsAttempts;
+ // Wait a bit longer then try again
+ do_timeout(1000, checkFile);
+ return;
+ }
+ }
+
+ // Now read it
+ var fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ var sstream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ fstream.init(tempFile, -1, 0, 0);
+ sstream.init(fstream);
+
+ // Read the first line only as that's the one we expect WriteArguments
+ // to be writing the argument to.
+ var data = sstream.read(4096);
+
+ sstream.close();
+ fstream.close();
+
+ // Now remove the old file
+ tempFile.remove(false);
+
+ // This currently fails on Mac with an argument like -psn_0_nnnnnn
+ // This seems to be to do with how the executable is called, but I couldn't
+ // find a way around it.
+ // Additionally the lack of OS detection in xpcshell tests sucks, so we'll
+ // have to check for the argument mac gives us.
+ if (data.substring(0, 7) != "-psn_0_") {
+ Assert.equal(data, kExpectedURI);
+ }
+
+ do_test_finished();
+}
+
+function run_test() {
+ if (mozinfo.os == "mac") {
+ dump("INFO | test_punycodeURIs.js | Skipping test on mac, bug 599475");
+ return;
+ }
+
+ // set up the uri to test with
+ var ioService = Services.io;
+
+ // set up the local handler object
+ var localHandler = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ localHandler.name = "Test Local Handler App";
+
+ // WriteArgument will just dump its arguments to a file for us.
+ var processDir = do_get_cwd();
+ var exe = processDir.clone();
+ exe.append("WriteArgument");
+
+ if (!exe.exists()) {
+ // Maybe we are on windows
+ exe.leafName = "WriteArgument.exe";
+ if (!exe.exists()) {
+ do_throw("Could not locate the WriteArgument tests executable\n");
+ }
+ }
+
+ var outFile = tempDir.clone();
+ outFile.append(kOutputFile);
+
+ // Set an environment variable for WriteArgument to pick up
+ // The Write Argument file needs to know where its libraries are, so
+ // just force the path variable
+ // For mac
+ var greDir = Services.dirsvc.get("GreD", Ci.nsIFile);
+
+ Services.env.set("DYLD_LIBRARY_PATH", greDir.path);
+ // For Linux
+ Services.env.set("LD_LIBRARY_PATH", greDir.path);
+ // XXX: handle windows
+
+ // Now tell it where we want the file.
+ Services.env.set("WRITE_ARGUMENT_FILE", outFile.path);
+
+ var uri = ioService.newURI(kTestURI);
+
+ // Just check we've got these matching, if we haven't there's a problem
+ // with ascii spec or our test case.
+ Assert.equal(uri.asciiSpec, kExpectedURI);
+
+ localHandler.executable = exe;
+ localHandler.launchWithURI(uri);
+
+ do_test_pending();
+ do_timeout(1000, checkFile);
+}
diff --git a/uriloader/exthandler/tests/unit/xpcshell.ini b/uriloader/exthandler/tests/unit/xpcshell.ini
new file mode 100644
index 0000000000..209cfb836a
--- /dev/null
+++ b/uriloader/exthandler/tests/unit/xpcshell.ini
@@ -0,0 +1,38 @@
+[DEFAULT]
+head = head.js
+run-sequentially = Bug 912235 - Intermittent failures
+firefox-appdir = browser
+
+[test_defaults_handlerService.js]
+# No default stored handlers on android given lack of support.
+# No default stored handlers on Thunderbird.
+skip-if =
+ os == "android"
+ appname == "thunderbird"
+[test_downloads_improvements_migration.js]
+# No default stored handlers on android given lack of support.
+# No default stored handlers on Thunderbird.
+skip-if =
+ os == "android"
+ appname == "thunderbird"
+[test_filename_sanitize.js]
+[test_getFromTypeAndExtension.js]
+[test_getMIMEInfo_pdf.js]
+[test_getMIMEInfo_unknown_mime_type.js]
+run-if = os == "win" # Windows only test
+[test_getTypeFromExtension_ext_to_type_mapping.js]
+[test_getTypeFromExtension_with_empty_Content_Type.js]
+run-if = os == "win" # Windows only test
+[test_badMIMEType.js]
+run-if = buildapp == "browser"
+[test_handlerService.js]
+support-files = mailcap
+# Bug 676997: test consistently fails on Android
+fail-if = os == "android"
+[test_handlerService_store.js]
+# Disabled for 1563343 -- the app should determine possible handlers in GV.
+fail-if = os == "android"
+support-files = handlers.json
+[test_punycodeURIs.js]
+skip-if =
+ os == 'win' && msix # https://bugzilla.mozilla.org/show_bug.cgi?id=1809485