diff options
Diffstat (limited to 'toolkit/components/jsdownloads/test')
20 files changed, 5850 insertions, 0 deletions
diff --git a/toolkit/components/jsdownloads/test/browser/.eslintrc.js b/toolkit/components/jsdownloads/test/browser/.eslintrc.js new file mode 100644 index 000000000..7c8021192 --- /dev/null +++ b/toolkit/components/jsdownloads/test/browser/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../../testing/mochitest/browser.eslintrc.js" + ] +}; diff --git a/toolkit/components/jsdownloads/test/browser/browser.ini b/toolkit/components/jsdownloads/test/browser/browser.ini new file mode 100644 index 000000000..131fc4ec8 --- /dev/null +++ b/toolkit/components/jsdownloads/test/browser/browser.ini @@ -0,0 +1,7 @@ +[DEFAULT] +support-files = + head.js + testFile.html + +[browser_DownloadPDFSaver.js] +skip-if = os != "win" diff --git a/toolkit/components/jsdownloads/test/browser/browser_DownloadPDFSaver.js b/toolkit/components/jsdownloads/test/browser/browser_DownloadPDFSaver.js new file mode 100644 index 000000000..80ed9665a --- /dev/null +++ b/toolkit/components/jsdownloads/test/browser/browser_DownloadPDFSaver.js @@ -0,0 +1,97 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the PDF download saver, and tests using a window as a + * source for the copy download saver. + */ + +"use strict"; + +/** + * Helper function to make sure a window reference exists on the download source. + */ +function* test_download_windowRef(aTab, aDownload) { + ok(aDownload.source.windowRef, "Download source had a window reference"); + ok(aDownload.source.windowRef instanceof Ci.xpcIJSWeakReference, "Download window reference is a weak ref"); + is(aDownload.source.windowRef.get(), aTab.linkedBrowser.contentWindow, "Download window exists during test"); +} + +/** + * Helper function to check the state of a completed download. + */ +function* test_download_state_complete(aTab, aDownload, aPrivate, aCanceled) { + ok(aDownload.source, "Download has a source"); + is(aDownload.source.url, aTab.linkedBrowser.contentWindow.location, "Download source has correct url"); + is(aDownload.source.isPrivate, aPrivate, "Download source has correct private state"); + ok(aDownload.stopped, "Download is stopped"); + is(aCanceled, aDownload.canceled, "Download has correct canceled state"); + is(!aCanceled, aDownload.succeeded, "Download has correct succeeded state"); + is(aDownload.error, null, "Download error is not defined"); +} + +function* test_createDownload_common(aPrivate, aType) { + let win = yield BrowserTestUtils.openNewBrowserWindow({ private : aPrivate}); + + let tab = yield BrowserTestUtils.openNewForegroundTab(win.gBrowser, getRootDirectory(gTestPath) + "testFile.html"); + let download = yield Downloads.createDownload({ + source: tab.linkedBrowser.contentWindow, + target: { path: getTempFile(TEST_TARGET_FILE_NAME_PDF).path }, + saver: { type: aType } + }); + + yield test_download_windowRef(tab, download); + yield download.start(); + + yield test_download_state_complete(tab, download, aPrivate, false); + if (aType == "pdf") { + let signature = yield OS.File.read(download.target.path, + { bytes: 4, encoding: "us-ascii" }); + is(signature, "%PDF", "File exists and signature matches"); + } else { + ok((yield OS.File.exists(download.target.path)), "File exists"); + } + + win.gBrowser.removeTab(tab); + win.close() +} + +add_task(function* test_createDownload_pdf_private() { + yield test_createDownload_common(true, "pdf"); +}); +add_task(function* test_createDownload_pdf_not_private() { + yield test_createDownload_common(false, "pdf"); +}); + +// Even for the copy saver, using a window should produce valid results +add_task(function* test_createDownload_copy_private() { + yield test_createDownload_common(true, "copy"); +}); +add_task(function* test_createDownload_copy_not_private() { + yield test_createDownload_common(false, "copy"); +}); + +add_task(function* test_cancel_pdf_download() { + let tab = gBrowser.addTab(getRootDirectory(gTestPath) + "testFile.html"); + yield promiseBrowserLoaded(tab.linkedBrowser); + + let download = yield Downloads.createDownload({ + source: tab.linkedBrowser.contentWindow, + target: { path: getTempFile(TEST_TARGET_FILE_NAME_PDF).path }, + saver: "pdf", + }); + + yield test_download_windowRef(tab, download); + download.start().catch(() => {}); + + // Immediately cancel the download to test that it is erased correctly. + yield download.cancel(); + yield test_download_state_complete(tab, download, false, true); + + let exists = yield OS.File.exists(download.target.path) + ok(!exists, "Target file does not exist"); + + gBrowser.removeTab(tab); +}); diff --git a/toolkit/components/jsdownloads/test/browser/head.js b/toolkit/components/jsdownloads/test/browser/head.js new file mode 100644 index 000000000..769aaacb3 --- /dev/null +++ b/toolkit/components/jsdownloads/test/browser/head.js @@ -0,0 +1,87 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Provides infrastructure for automated download components tests. + */ + +"use strict"; + +// Globals + +var Cc = Components.classes; +var Ci = Components.interfaces; +var Cu = Components.utils; +var Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "DownloadPaths", + "resource://gre/modules/DownloadPaths.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Downloads", + "resource://gre/modules/Downloads.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "HttpServer", + "resource://testing-common/httpd.js"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); + +const TEST_TARGET_FILE_NAME_PDF = "test-download.pdf"; + +// Support functions + +// While the previous test file should have deleted all the temporary files it +// used, on Windows these might still be pending deletion on the physical file +// system. Thus, start from a new base number every time, to make a collision +// with a file that is still pending deletion highly unlikely. +var gFileCounter = Math.floor(Math.random() * 1000000); + +/** + * Returns a reference to a temporary file, that is guaranteed not to exist, and + * to have never been created before. + * + * @param aLeafName + * Suggested leaf name for the file to be created. + * + * @return nsIFile pointing to a non-existent file in a temporary directory. + * + * @note It is not enough to delete the file if it exists, or to delete the file + * after calling nsIFile.createUnique, because on Windows the delete + * operation in the file system may still be pending, preventing a new + * file with the same name to be created. + */ +function getTempFile(aLeafName) +{ + // Prepend a serial number to the extension in the suggested leaf name. + let [base, ext] = DownloadPaths.splitBaseNameAndExtension(aLeafName); + let leafName = base + "-" + gFileCounter + ext; + gFileCounter++; + + // Get a file reference under the temporary directory for this test file. + let file = FileUtils.getFile("TmpD", [leafName]); + ok(!file.exists(), "Temp file does not exist"); + + registerCleanupFunction(function () { + if (file.exists()) { + file.remove(false); + } + }); + + return file; +} + +function promiseBrowserLoaded(browser) { + return new Promise(resolve => { + browser.addEventListener("load", function onLoad(event) { + if (event.target == browser.contentDocument) { + browser.removeEventListener("load", onLoad, true); + resolve(); + } + }, true); + }); +} diff --git a/toolkit/components/jsdownloads/test/browser/testFile.html b/toolkit/components/jsdownloads/test/browser/testFile.html new file mode 100644 index 000000000..ee413514b --- /dev/null +++ b/toolkit/components/jsdownloads/test/browser/testFile.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> + <head> + <title>Test Save as PDF</title> + </head> + <body> + <p>Save me as a PDF!</p> + </body> +</html> diff --git a/toolkit/components/jsdownloads/test/data/.eslintrc.js b/toolkit/components/jsdownloads/test/data/.eslintrc.js new file mode 100644 index 000000000..d35787cd2 --- /dev/null +++ b/toolkit/components/jsdownloads/test/data/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../../testing/xpcshell/xpcshell.eslintrc.js" + ] +}; diff --git a/toolkit/components/jsdownloads/test/data/empty.txt b/toolkit/components/jsdownloads/test/data/empty.txt new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/toolkit/components/jsdownloads/test/data/empty.txt diff --git a/toolkit/components/jsdownloads/test/data/source.txt b/toolkit/components/jsdownloads/test/data/source.txt new file mode 100644 index 000000000..2156cb8c0 --- /dev/null +++ b/toolkit/components/jsdownloads/test/data/source.txt @@ -0,0 +1 @@ +This test string is downloaded.
\ No newline at end of file diff --git a/toolkit/components/jsdownloads/test/unit/.eslintrc.js b/toolkit/components/jsdownloads/test/unit/.eslintrc.js new file mode 100644 index 000000000..d35787cd2 --- /dev/null +++ b/toolkit/components/jsdownloads/test/unit/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../../testing/xpcshell/xpcshell.eslintrc.js" + ] +}; diff --git a/toolkit/components/jsdownloads/test/unit/common_test_Download.js b/toolkit/components/jsdownloads/test/unit/common_test_Download.js new file mode 100644 index 000000000..42d4c5682 --- /dev/null +++ b/toolkit/components/jsdownloads/test/unit/common_test_Download.js @@ -0,0 +1,2432 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This script is loaded by "test_DownloadCore.js" and "test_DownloadLegacy.js" + * with different values of the gUseLegacySaver variable, to apply tests to both + * the "copy" and "legacy" saver implementations. + */ + +"use strict"; + +// Globals + +const kDeleteTempFileOnExit = "browser.helperApps.deleteTempFileOnExit"; + +/** + * Creates and starts a new download, using either DownloadCopySaver or + * DownloadLegacySaver based on the current test run. + * + * @return {Promise} + * @resolves The newly created Download object. The download may be in progress + * or already finished. The promiseDownloadStopped function can be + * used to wait for completion. + * @rejects JavaScript exception. + */ +function promiseStartDownload(aSourceUrl) { + if (gUseLegacySaver) { + return promiseStartLegacyDownload(aSourceUrl); + } + + return promiseNewDownload(aSourceUrl).then(download => { + download.start().catch(() => {}); + return download; + }); +} + +/** + * Creates and starts a new download, configured to keep partial data, and + * returns only when the first part of "interruptible_resumable.txt" has been + * saved to disk. You must call "continueResponses" to allow the interruptible + * request to continue. + * + * This function uses either DownloadCopySaver or DownloadLegacySaver based on + * the current test run. + * + * @return {Promise} + * @resolves The newly created Download object, still in progress. + * @rejects JavaScript exception. + */ +function promiseStartDownload_tryToKeepPartialData() { + return Task.spawn(function* () { + mustInterruptResponses(); + + // Start a new download and configure it to keep partially downloaded data. + let download; + if (!gUseLegacySaver) { + let targetFilePath = getTempFile(TEST_TARGET_FILE_NAME).path; + download = yield Downloads.createDownload({ + source: httpUrl("interruptible_resumable.txt"), + target: { path: targetFilePath, + partFilePath: targetFilePath + ".part" }, + }); + download.tryToKeepPartialData = true; + download.start().catch(() => {}); + } else { + // Start a download using nsIExternalHelperAppService, that is configured + // to keep partially downloaded data by default. + download = yield promiseStartExternalHelperAppServiceDownload(); + } + + yield promiseDownloadMidway(download); + yield promisePartFileReady(download); + + return download; + }); +} + +/** + * This function should be called after the progress notification for a download + * is received, and waits for the worker thread of BackgroundFileSaver to + * receive the data to be written to the ".part" file on disk. + * + * @return {Promise} + * @resolves When the ".part" file has been written to disk. + * @rejects JavaScript exception. + */ +function promisePartFileReady(aDownload) { + return Task.spawn(function* () { + // We don't have control over the file output code in BackgroundFileSaver. + // After we receive the download progress notification, we may only check + // that the ".part" file has been created, while its size cannot be + // determined because the file is currently open. + try { + do { + yield promiseTimeout(50); + } while (!(yield OS.File.exists(aDownload.target.partFilePath))); + } catch (ex) { + if (!(ex instanceof OS.File.Error)) { + throw ex; + } + // This indicates that the file has been created and cannot be accessed. + // The specific error might vary with the platform. + do_print("Expected exception while checking existence: " + ex.toString()); + // Wait some more time to allow the write to complete. + yield promiseTimeout(100); + } + }); +} + +/** + * Checks that the actual data written to disk matches the expected data as well + * as the properties of the given DownloadTarget object. + * + * @param downloadTarget + * The DownloadTarget object whose details have to be verified. + * @param expectedContents + * String containing the octets that are expected in the file. + * + * @return {Promise} + * @resolves When the properties have been verified. + * @rejects JavaScript exception. + */ +var promiseVerifyTarget = Task.async(function* (downloadTarget, + expectedContents) { + yield promiseVerifyContents(downloadTarget.path, expectedContents); + do_check_true(downloadTarget.exists); + do_check_eq(downloadTarget.size, expectedContents.length); +}); + +/** + * Waits for an attempt to launch a file, and returns the nsIMIMEInfo used for + * the launch, or null if the file was launched with the default handler. + */ +function waitForFileLaunched() { + return new Promise(resolve => { + let waitFn = base => ({ + launchFile(file, mimeInfo) { + Integration.downloads.unregister(waitFn); + if (!mimeInfo || + mimeInfo.preferredAction == Ci.nsIMIMEInfo.useSystemDefault) { + resolve(null); + } else { + resolve(mimeInfo); + } + return Promise.resolve(); + }, + }); + Integration.downloads.register(waitFn); + }); +} + +/** + * Waits for an attempt to show the directory where a file is located, and + * returns the path of the file. + */ +function waitForDirectoryShown() { + return new Promise(resolve => { + let waitFn = base => ({ + showContainingDirectory(path) { + Integration.downloads.unregister(waitFn); + resolve(path); + return Promise.resolve(); + }, + }); + Integration.downloads.register(waitFn); + }); +} + +// Tests + +/** + * Executes a download and checks its basic properties after construction. + * The download is started by constructing the simplest Download object with + * the "copy" saver, or using the legacy nsITransfer interface. + */ +add_task(function* test_basic() +{ + let targetFile = getTempFile(TEST_TARGET_FILE_NAME); + + let download; + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we have control over the download, thus + // we can check its basic properties before it starts. + download = yield Downloads.createDownload({ + source: { url: httpUrl("source.txt") }, + target: { path: targetFile.path }, + saver: { type: "copy" }, + }); + + do_check_eq(download.source.url, httpUrl("source.txt")); + do_check_eq(download.target.path, targetFile.path); + + yield download.start(); + } else { + // When testing DownloadLegacySaver, the download is already started when it + // is created, thus we must check its basic properties while in progress. + download = yield promiseStartLegacyDownload(null, + { targetFile: targetFile }); + + do_check_eq(download.source.url, httpUrl("source.txt")); + do_check_eq(download.target.path, targetFile.path); + + yield promiseDownloadStopped(download); + } + + // Check additional properties on the finished download. + do_check_true(download.source.referrer === null); + + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT); +}); + +/** + * Executes a download with the tryToKeepPartialData property set, and ensures + * that the file is saved correctly. When testing DownloadLegacySaver, the + * download is executed using the nsIExternalHelperAppService component. + */ +add_task(function* test_basic_tryToKeepPartialData() +{ + let download = yield promiseStartDownload_tryToKeepPartialData(); + continueResponses(); + yield promiseDownloadStopped(download); + + // The target file should now have been created, and the ".part" file deleted. + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); + do_check_false(yield OS.File.exists(download.target.partFilePath)); + do_check_eq(32, download.saver.getSha256Hash().length); +}); + +/** + * Tests the permissions of the final target file once the download finished. + */ +add_task(function* test_unix_permissions() +{ + // This test is only executed on some Desktop systems. + if (Services.appinfo.OS != "Darwin" && Services.appinfo.OS != "Linux" && + Services.appinfo.OS != "WINNT") { + do_print("Skipping test."); + return; + } + + let launcherPath = getTempFile("app-launcher").path; + + for (let autoDelete of [false, true]) { + for (let isPrivate of [false, true]) { + for (let launchWhenSucceeded of [false, true]) { + do_print("Checking " + JSON.stringify({ autoDelete, + isPrivate, + launchWhenSucceeded })); + + Services.prefs.setBoolPref(kDeleteTempFileOnExit, autoDelete); + + let download; + if (!gUseLegacySaver) { + download = yield Downloads.createDownload({ + source: { url: httpUrl("source.txt"), isPrivate }, + target: getTempFile(TEST_TARGET_FILE_NAME).path, + launchWhenSucceeded, + launcherPath, + }); + yield download.start(); + } else { + download = yield promiseStartLegacyDownload(httpUrl("source.txt"), { + isPrivate, + launchWhenSucceeded, + launcherPath: launchWhenSucceeded && launcherPath, + }); + yield promiseDownloadStopped(download); + } + + let isTemporary = launchWhenSucceeded && (autoDelete || isPrivate); + let stat = yield OS.File.stat(download.target.path); + if (Services.appinfo.OS == "WINNT") { + // On Windows + // Temporary downloads should be read-only + do_check_eq(stat.winAttributes.readOnly, isTemporary ? true : false); + } else { + // On Linux, Mac + // Temporary downloads should be read-only and not accessible to other + // users, while permanently downloaded files should be readable and + // writable as specified by the system umask. + do_check_eq(stat.unixMode, + isTemporary ? 0o400 : (0o666 & ~OS.Constants.Sys.umask)); + } + } + } + } + + // Clean up the changes to the preference. + Services.prefs.clearUserPref(kDeleteTempFileOnExit); +}); + +/** + * Checks the referrer for downloads. + */ +add_task(function* test_referrer() +{ + let sourcePath = "/test_referrer.txt"; + let sourceUrl = httpUrl("test_referrer.txt"); + let targetPath = getTempFile(TEST_TARGET_FILE_NAME).path; + + function cleanup() { + gHttpServer.registerPathHandler(sourcePath, null); + } + do_register_cleanup(cleanup); + + gHttpServer.registerPathHandler(sourcePath, function (aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + + do_check_true(aRequest.hasHeader("Referer")); + do_check_eq(aRequest.getHeader("Referer"), TEST_REFERRER_URL); + }); + let download = yield Downloads.createDownload({ + source: { url: sourceUrl, referrer: TEST_REFERRER_URL }, + target: targetPath, + }); + do_check_eq(download.source.referrer, TEST_REFERRER_URL); + yield download.start(); + + download = yield Downloads.createDownload({ + source: { url: sourceUrl, referrer: TEST_REFERRER_URL, + isPrivate: true }, + target: targetPath, + }); + do_check_eq(download.source.referrer, TEST_REFERRER_URL); + yield download.start(); + + // Test the download still works for non-HTTP channel with referrer. + sourceUrl = "data:text/html,<html><body></body></html>"; + download = yield Downloads.createDownload({ + source: { url: sourceUrl, referrer: TEST_REFERRER_URL }, + target: targetPath, + }); + do_check_eq(download.source.referrer, TEST_REFERRER_URL); + yield download.start(); + + cleanup(); +}); + +/** + * Checks the adjustChannel callback for downloads. + */ +add_task(function* test_adjustChannel() +{ + const sourcePath = "/test_post.txt"; + const sourceUrl = httpUrl("test_post.txt"); + const targetPath = getTempFile(TEST_TARGET_FILE_NAME).path; + const customHeader = { name: "X-Answer", value: "42" }; + const postData = "Don't Panic"; + + function cleanup() { + gHttpServer.registerPathHandler(sourcePath, null); + } + do_register_cleanup(cleanup); + + gHttpServer.registerPathHandler(sourcePath, aRequest => { + do_check_eq(aRequest.method, "POST"); + + do_check_true(aRequest.hasHeader(customHeader.name)); + do_check_eq(aRequest.getHeader(customHeader.name), customHeader.value); + + const stream = aRequest.bodyInputStream; + const body = NetUtil.readInputStreamToString(stream, stream.available()); + do_check_eq(body, postData); + }); + + function adjustChannel(channel) { + channel.QueryInterface(Ci.nsIHttpChannel); + channel.setRequestHeader(customHeader.name, customHeader.value, false); + + const stream = Cc["@mozilla.org/io/string-input-stream;1"] + .createInstance(Ci.nsIStringInputStream); + stream.setData(postData, postData.length); + + channel.QueryInterface(Ci.nsIUploadChannel2); + channel.explicitSetUploadStream(stream, null, -1, "POST", false); + + return Promise.resolve(); + } + + const download = yield Downloads.createDownload({ + source: { url: sourceUrl, adjustChannel }, + target: targetPath, + }); + do_check_eq(download.source.adjustChannel, adjustChannel); + do_check_eq(download.toSerializable(), null); + yield download.start(); + + cleanup(); +}); + +/** + * Checks initial and final state and progress for a successful download. + */ +add_task(function* test_initial_final_state() +{ + let download; + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we have control over the download, thus + // we can check its state before it starts. + download = yield promiseNewDownload(); + + do_check_true(download.stopped); + do_check_false(download.succeeded); + do_check_false(download.canceled); + do_check_true(download.error === null); + do_check_eq(download.progress, 0); + do_check_true(download.startTime === null); + do_check_false(download.target.exists); + do_check_eq(download.target.size, 0); + + yield download.start(); + } else { + // When testing DownloadLegacySaver, the download is already started when it + // is created, thus we cannot check its initial state. + download = yield promiseStartLegacyDownload(); + yield promiseDownloadStopped(download); + } + + do_check_true(download.stopped); + do_check_true(download.succeeded); + do_check_false(download.canceled); + do_check_true(download.error === null); + do_check_eq(download.progress, 100); + do_check_true(isValidDate(download.startTime)); + do_check_true(download.target.exists); + do_check_eq(download.target.size, TEST_DATA_SHORT.length); +}); + +/** + * Checks the notification of the final download state. + */ +add_task(function* test_final_state_notified() +{ + mustInterruptResponses(); + + let download = yield promiseStartDownload(httpUrl("interruptible.txt")); + + let onchangeNotified = false; + let lastNotifiedStopped; + let lastNotifiedProgress; + download.onchange = function () { + onchangeNotified = true; + lastNotifiedStopped = download.stopped; + lastNotifiedProgress = download.progress; + }; + + // Allow the download to complete. + let promiseAttempt = download.start(); + continueResponses(); + yield promiseAttempt; + + // The view should have been notified before the download completes. + do_check_true(onchangeNotified); + do_check_true(lastNotifiedStopped); + do_check_eq(lastNotifiedProgress, 100); +}); + +/** + * Checks intermediate progress for a successful download. + */ +add_task(function* test_intermediate_progress() +{ + mustInterruptResponses(); + + let download = yield promiseStartDownload(httpUrl("interruptible.txt")); + + yield promiseDownloadMidway(download); + + do_check_true(download.hasProgress); + do_check_eq(download.currentBytes, TEST_DATA_SHORT.length); + do_check_eq(download.totalBytes, TEST_DATA_SHORT.length * 2); + + // The final file size should not be computed for in-progress downloads. + do_check_false(download.target.exists); + do_check_eq(download.target.size, 0); + + // Continue after the first chunk of data is fully received. + continueResponses(); + yield promiseDownloadStopped(download); + + do_check_true(download.stopped); + do_check_eq(download.progress, 100); + + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); +}); + +/** + * Downloads a file with a "Content-Length" of 0 and checks the progress. + */ +add_task(function* test_empty_progress() +{ + let download = yield promiseStartDownload(httpUrl("empty.txt")); + yield promiseDownloadStopped(download); + + do_check_true(download.stopped); + do_check_true(download.hasProgress); + do_check_eq(download.progress, 100); + do_check_eq(download.currentBytes, 0); + do_check_eq(download.totalBytes, 0); + + // We should have received the content type even for an empty file. + do_check_eq(download.contentType, "text/plain"); + + do_check_eq((yield OS.File.stat(download.target.path)).size, 0); + do_check_true(download.target.exists); + do_check_eq(download.target.size, 0); +}); + +/** + * Downloads a file with a "Content-Length" of 0 with the tryToKeepPartialData + * property set, and ensures that the file is saved correctly. + */ +add_task(function* test_empty_progress_tryToKeepPartialData() +{ + // Start a new download and configure it to keep partially downloaded data. + let download; + if (!gUseLegacySaver) { + let targetFilePath = getTempFile(TEST_TARGET_FILE_NAME).path; + download = yield Downloads.createDownload({ + source: httpUrl("empty.txt"), + target: { path: targetFilePath, + partFilePath: targetFilePath + ".part" }, + }); + download.tryToKeepPartialData = true; + download.start().catch(() => {}); + } else { + // Start a download using nsIExternalHelperAppService, that is configured + // to keep partially downloaded data by default. + download = yield promiseStartExternalHelperAppServiceDownload( + httpUrl("empty.txt")); + } + yield promiseDownloadStopped(download); + + // The target file should now have been created, and the ".part" file deleted. + do_check_eq((yield OS.File.stat(download.target.path)).size, 0); + do_check_true(download.target.exists); + do_check_eq(download.target.size, 0); + + do_check_false(yield OS.File.exists(download.target.partFilePath)); + do_check_eq(32, download.saver.getSha256Hash().length); +}); + +/** + * Downloads an empty file with no "Content-Length" and checks the progress. + */ +add_task(function* test_empty_noprogress() +{ + let sourcePath = "/test_empty_noprogress.txt"; + let sourceUrl = httpUrl("test_empty_noprogress.txt"); + let deferRequestReceived = Promise.defer(); + + // Register an interruptible handler that notifies us when the request occurs. + function cleanup() { + gHttpServer.registerPathHandler(sourcePath, null); + } + do_register_cleanup(cleanup); + + registerInterruptibleHandler(sourcePath, + function firstPart(aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + deferRequestReceived.resolve(); + }, function secondPart(aRequest, aResponse) { }); + + // Start the download, without allowing the request to finish. + mustInterruptResponses(); + let download; + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we have control over the download, thus + // we can hook its onchange callback that will be notified when the + // download starts. + download = yield promiseNewDownload(sourceUrl); + + download.onchange = function () { + if (!download.stopped) { + do_check_false(download.hasProgress); + do_check_eq(download.currentBytes, 0); + do_check_eq(download.totalBytes, 0); + } + }; + + download.start().catch(() => {}); + } else { + // When testing DownloadLegacySaver, the download is already started when it + // is created, and it may have already made all needed property change + // notifications, thus there is no point in checking the onchange callback. + download = yield promiseStartLegacyDownload(sourceUrl); + } + + // Wait for the request to be received by the HTTP server, but don't allow the + // request to finish yet. Before checking the download state, wait for the + // events to be processed by the client. + yield deferRequestReceived.promise; + yield promiseExecuteSoon(); + + // Check that this download has no progress report. + do_check_false(download.stopped); + do_check_false(download.hasProgress); + do_check_eq(download.currentBytes, 0); + do_check_eq(download.totalBytes, 0); + + // Now allow the response to finish. + continueResponses(); + yield promiseDownloadStopped(download); + + // We should have received the content type even if no progress is reported. + do_check_eq(download.contentType, "text/plain"); + + // Verify the state of the completed download. + do_check_true(download.stopped); + do_check_false(download.hasProgress); + do_check_eq(download.progress, 100); + do_check_eq(download.currentBytes, 0); + do_check_eq(download.totalBytes, 0); + do_check_true(download.target.exists); + do_check_eq(download.target.size, 0); + + do_check_eq((yield OS.File.stat(download.target.path)).size, 0); +}); + +/** + * Calls the "start" method two times before the download is finished. + */ +add_task(function* test_start_twice() +{ + mustInterruptResponses(); + + let download; + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we have control over the download, thus + // we can start the download later during the test. + download = yield promiseNewDownload(httpUrl("interruptible.txt")); + } else { + // When testing DownloadLegacySaver, the download is already started when it + // is created. Effectively, we are starting the download three times. + download = yield promiseStartLegacyDownload(httpUrl("interruptible.txt")); + } + + // Call the start method two times. + let promiseAttempt1 = download.start(); + let promiseAttempt2 = download.start(); + + // Allow the download to finish. + continueResponses(); + + // Both promises should now be resolved. + yield promiseAttempt1; + yield promiseAttempt2; + + do_check_true(download.stopped); + do_check_true(download.succeeded); + do_check_false(download.canceled); + do_check_true(download.error === null); + + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); +}); + +/** + * Cancels a download and verifies that its state is reported correctly. + */ +add_task(function* test_cancel_midway() +{ + mustInterruptResponses(); + + // In this test case, we execute different checks that are only possible with + // DownloadCopySaver or DownloadLegacySaver respectively. + let download; + let options = {}; + if (!gUseLegacySaver) { + download = yield promiseNewDownload(httpUrl("interruptible.txt")); + } else { + download = yield promiseStartLegacyDownload(httpUrl("interruptible.txt"), + options); + } + + // Cancel the download after receiving the first part of the response. + let deferCancel = Promise.defer(); + let onchange = function () { + if (!download.stopped && !download.canceled && download.progress == 50) { + // Cancel the download immediately during the notification. + deferCancel.resolve(download.cancel()); + + // The state change happens immediately after calling "cancel", but + // temporary files or part files may still exist at this point. + do_check_true(download.canceled); + } + }; + + // Register for the notification, but also call the function directly in + // case the download already reached the expected progress. This may happen + // when using DownloadLegacySaver. + download.onchange = onchange; + onchange(); + + let promiseAttempt; + if (!gUseLegacySaver) { + promiseAttempt = download.start(); + } + + // Wait on the promise returned by the "cancel" method to ensure that the + // cancellation process finished and temporary files were removed. + yield deferCancel.promise; + + if (gUseLegacySaver) { + // The nsIWebBrowserPersist instance should have been canceled now. + do_check_eq(options.outPersist.result, Cr.NS_ERROR_ABORT); + } + + do_check_true(download.stopped); + do_check_true(download.canceled); + do_check_true(download.error === null); + do_check_false(download.target.exists); + do_check_eq(download.target.size, 0); + + do_check_false(yield OS.File.exists(download.target.path)); + + // Progress properties are not reset by canceling. + do_check_eq(download.progress, 50); + do_check_eq(download.totalBytes, TEST_DATA_SHORT.length * 2); + do_check_eq(download.currentBytes, TEST_DATA_SHORT.length); + + if (!gUseLegacySaver) { + // The promise returned by "start" should have been rejected meanwhile. + try { + yield promiseAttempt; + do_throw("The download should have been canceled."); + } catch (ex) { + if (!(ex instanceof Downloads.Error)) { + throw ex; + } + do_check_false(ex.becauseSourceFailed); + do_check_false(ex.becauseTargetFailed); + } + } +}); + +/** + * Cancels a download while keeping partially downloaded data, and verifies that + * both the target file and the ".part" file are deleted. + */ +add_task(function* test_cancel_midway_tryToKeepPartialData() +{ + let download = yield promiseStartDownload_tryToKeepPartialData(); + + do_check_true(yield OS.File.exists(download.target.path)); + do_check_true(yield OS.File.exists(download.target.partFilePath)); + + yield download.cancel(); + yield download.removePartialData(); + + do_check_true(download.stopped); + do_check_true(download.canceled); + do_check_true(download.error === null); + + do_check_false(yield OS.File.exists(download.target.path)); + do_check_false(yield OS.File.exists(download.target.partFilePath)); +}); + +/** + * Cancels a download right after starting it. + */ +add_task(function* test_cancel_immediately() +{ + mustInterruptResponses(); + + let download = yield promiseStartDownload(httpUrl("interruptible.txt")); + + let promiseAttempt = download.start(); + do_check_false(download.stopped); + + let promiseCancel = download.cancel(); + do_check_true(download.canceled); + + // At this point, we don't know whether the download has already stopped or + // is still waiting for cancellation. We can wait on the promise returned + // by the "start" method to know for sure. + try { + yield promiseAttempt; + do_throw("The download should have been canceled."); + } catch (ex) { + if (!(ex instanceof Downloads.Error)) { + throw ex; + } + do_check_false(ex.becauseSourceFailed); + do_check_false(ex.becauseTargetFailed); + } + + do_check_true(download.stopped); + do_check_true(download.canceled); + do_check_true(download.error === null); + + do_check_false(yield OS.File.exists(download.target.path)); + + // Check that the promise returned by the "cancel" method has been resolved. + yield promiseCancel; +}); + +/** + * Cancels and restarts a download sequentially. + */ +add_task(function* test_cancel_midway_restart() +{ + mustInterruptResponses(); + + let download = yield promiseStartDownload(httpUrl("interruptible.txt")); + + // The first time, cancel the download midway. + yield promiseDownloadMidway(download); + yield download.cancel(); + + do_check_true(download.stopped); + + // The second time, we'll provide the entire interruptible response. + continueResponses(); + download.onchange = null; + let promiseAttempt = download.start(); + + // Download state should have already been reset. + do_check_false(download.stopped); + do_check_false(download.canceled); + do_check_true(download.error === null); + + // For the following test, we rely on the network layer reporting its progress + // asynchronously. Otherwise, there is nothing stopping the restarted + // download from reaching the same progress as the first request already. + do_check_eq(download.progress, 0); + do_check_eq(download.totalBytes, 0); + do_check_eq(download.currentBytes, 0); + + yield promiseAttempt; + + do_check_true(download.stopped); + do_check_true(download.succeeded); + do_check_false(download.canceled); + do_check_true(download.error === null); + + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); +}); + +/** + * Cancels a download and restarts it from where it stopped. + */ +add_task(function* test_cancel_midway_restart_tryToKeepPartialData() +{ + let download = yield promiseStartDownload_tryToKeepPartialData(); + yield download.cancel(); + + do_check_true(download.stopped); + do_check_true(download.hasPartialData); + + // The target file should not exist, but we should have kept the partial data. + do_check_false(yield OS.File.exists(download.target.path)); + yield promiseVerifyContents(download.target.partFilePath, TEST_DATA_SHORT); + do_check_false(download.target.exists); + do_check_eq(download.target.size, 0); + + // Verify that the server sent the response from the start. + do_check_eq(gMostRecentFirstBytePos, 0); + + // The second time, we'll request and obtain the second part of the response, + // but we still stop when half of the remaining progress is reached. + let deferMidway = Promise.defer(); + download.onchange = function () { + if (!download.stopped && !download.canceled && + download.currentBytes == Math.floor(TEST_DATA_SHORT.length * 3 / 2)) { + download.onchange = null; + deferMidway.resolve(); + } + }; + + mustInterruptResponses(); + let promiseAttempt = download.start(); + + // Continue when the number of bytes we received is correct, then check that + // progress is at about 75 percent. The exact figure may vary because of + // rounding issues, since the total number of bytes in the response might not + // be a multiple of four. + yield deferMidway.promise; + do_check_true(download.progress > 72 && download.progress < 78); + + // Now we allow the download to finish. + continueResponses(); + yield promiseAttempt; + + // Check that the server now sent the second part only. + do_check_eq(gMostRecentFirstBytePos, TEST_DATA_SHORT.length); + + // The target file should now have been created, and the ".part" file deleted. + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); + do_check_false(yield OS.File.exists(download.target.partFilePath)); +}); + +/** + * Cancels a download while keeping partially downloaded data, then removes the + * data and restarts the download from the beginning. + */ +add_task(function* test_cancel_midway_restart_removePartialData() +{ + let download = yield promiseStartDownload_tryToKeepPartialData(); + yield download.cancel(); + + do_check_true(download.hasPartialData); + yield promiseVerifyContents(download.target.partFilePath, TEST_DATA_SHORT); + do_check_false(download.target.exists); + do_check_eq(download.target.size, 0); + + yield download.removePartialData(); + + do_check_false(download.hasPartialData); + do_check_false(yield OS.File.exists(download.target.partFilePath)); + do_check_false(download.target.exists); + do_check_eq(download.target.size, 0); + + // The second time, we'll request and obtain the entire response again. + continueResponses(); + yield download.start(); + + // Verify that the server sent the response from the start. + do_check_eq(gMostRecentFirstBytePos, 0); + + // The target file should now have been created, and the ".part" file deleted. + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); + do_check_false(yield OS.File.exists(download.target.partFilePath)); +}); + +/** + * Cancels a download while keeping partially downloaded data, then removes the + * data and restarts the download from the beginning without keeping the partial + * data anymore. + */ +add_task(function* test_cancel_midway_restart_tryToKeepPartialData_false() +{ + let download = yield promiseStartDownload_tryToKeepPartialData(); + yield download.cancel(); + + download.tryToKeepPartialData = false; + + // The above property change does not affect existing partial data. + do_check_true(download.hasPartialData); + yield promiseVerifyContents(download.target.partFilePath, TEST_DATA_SHORT); + + yield download.removePartialData(); + do_check_false(yield OS.File.exists(download.target.partFilePath)); + + // Restart the download from the beginning. + mustInterruptResponses(); + download.start().catch(() => {}); + + yield promiseDownloadMidway(download); + yield promisePartFileReady(download); + + // While the download is in progress, we should still have a ".part" file. + do_check_false(download.hasPartialData); + do_check_true(yield OS.File.exists(download.target.partFilePath)); + + // On Unix, verify that the file with the partially downloaded data is not + // accessible by other users on the system. + if (Services.appinfo.OS == "Darwin" || Services.appinfo.OS == "Linux") { + do_check_eq((yield OS.File.stat(download.target.partFilePath)).unixMode, + 0o600); + } + + yield download.cancel(); + + // The ".part" file should be deleted now that the download is canceled. + do_check_false(download.hasPartialData); + do_check_false(yield OS.File.exists(download.target.partFilePath)); + + // The third time, we'll request and obtain the entire response again. + continueResponses(); + yield download.start(); + + // Verify that the server sent the response from the start. + do_check_eq(gMostRecentFirstBytePos, 0); + + // The target file should now have been created, and the ".part" file deleted. + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); + do_check_false(yield OS.File.exists(download.target.partFilePath)); +}); + +/** + * Cancels a download right after starting it, then restarts it immediately. + */ +add_task(function* test_cancel_immediately_restart_immediately() +{ + mustInterruptResponses(); + + let download = yield promiseStartDownload(httpUrl("interruptible.txt")); + let promiseAttempt = download.start(); + + do_check_false(download.stopped); + + download.cancel(); + do_check_true(download.canceled); + + let promiseRestarted = download.start(); + do_check_false(download.stopped); + do_check_false(download.canceled); + do_check_true(download.error === null); + + // For the following test, we rely on the network layer reporting its progress + // asynchronously. Otherwise, there is nothing stopping the restarted + // download from reaching the same progress as the first request already. + do_check_eq(download.hasProgress, false); + do_check_eq(download.progress, 0); + do_check_eq(download.totalBytes, 0); + do_check_eq(download.currentBytes, 0); + + // Ensure the next request is now allowed to complete, regardless of whether + // the canceled request was received by the server or not. + continueResponses(); + try { + yield promiseAttempt; + // If we get here, it means that the first attempt actually succeeded. In + // fact, this could be a valid outcome, because the cancellation request may + // not have been processed in time before the download finished. + do_print("The download should have been canceled."); + } catch (ex) { + if (!(ex instanceof Downloads.Error)) { + throw ex; + } + do_check_false(ex.becauseSourceFailed); + do_check_false(ex.becauseTargetFailed); + } + + yield promiseRestarted; + + do_check_true(download.stopped); + do_check_true(download.succeeded); + do_check_false(download.canceled); + do_check_true(download.error === null); + + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); +}); + +/** + * Cancels a download midway, then restarts it immediately. + */ +add_task(function* test_cancel_midway_restart_immediately() +{ + mustInterruptResponses(); + + let download = yield promiseStartDownload(httpUrl("interruptible.txt")); + let promiseAttempt = download.start(); + + // The first time, cancel the download midway. + yield promiseDownloadMidway(download); + download.cancel(); + do_check_true(download.canceled); + + let promiseRestarted = download.start(); + do_check_false(download.stopped); + do_check_false(download.canceled); + do_check_true(download.error === null); + + // For the following test, we rely on the network layer reporting its progress + // asynchronously. Otherwise, there is nothing stopping the restarted + // download from reaching the same progress as the first request already. + do_check_eq(download.hasProgress, false); + do_check_eq(download.progress, 0); + do_check_eq(download.totalBytes, 0); + do_check_eq(download.currentBytes, 0); + + // The second request is allowed to complete. + continueResponses(); + try { + yield promiseAttempt; + do_throw("The download should have been canceled."); + } catch (ex) { + if (!(ex instanceof Downloads.Error)) { + throw ex; + } + do_check_false(ex.becauseSourceFailed); + do_check_false(ex.becauseTargetFailed); + } + + yield promiseRestarted; + + do_check_true(download.stopped); + do_check_true(download.succeeded); + do_check_false(download.canceled); + do_check_true(download.error === null); + + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); +}); + +/** + * Calls the "cancel" method on a successful download. + */ +add_task(function* test_cancel_successful() +{ + let download = yield promiseStartDownload(); + yield promiseDownloadStopped(download); + + // The cancel method should succeed with no effect. + yield download.cancel(); + + do_check_true(download.stopped); + do_check_true(download.succeeded); + do_check_false(download.canceled); + do_check_true(download.error === null); + + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT); +}); + +/** + * Calls the "cancel" method two times in a row. + */ +add_task(function* test_cancel_twice() +{ + mustInterruptResponses(); + + let download = yield promiseStartDownload(httpUrl("interruptible.txt")); + + let promiseAttempt = download.start(); + do_check_false(download.stopped); + + let promiseCancel1 = download.cancel(); + do_check_true(download.canceled); + let promiseCancel2 = download.cancel(); + + try { + yield promiseAttempt; + do_throw("The download should have been canceled."); + } catch (ex) { + if (!(ex instanceof Downloads.Error)) { + throw ex; + } + do_check_false(ex.becauseSourceFailed); + do_check_false(ex.becauseTargetFailed); + } + + // Both promises should now be resolved. + yield promiseCancel1; + yield promiseCancel2; + + do_check_true(download.stopped); + do_check_false(download.succeeded); + do_check_true(download.canceled); + do_check_true(download.error === null); + + do_check_false(yield OS.File.exists(download.target.path)); +}); + +/** + * Checks the "refresh" method for succeeded downloads. + */ +add_task(function* test_refresh_succeeded() +{ + let download = yield promiseStartDownload(); + yield promiseDownloadStopped(download); + + // The DownloadTarget properties should be the same after calling "refresh". + yield download.refresh(); + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT); + + // If the file is removed, only the "exists" property should change, and the + // "size" property should keep its previous value. + yield OS.File.move(download.target.path, download.target.path + ".old"); + yield download.refresh(); + do_check_false(download.target.exists); + do_check_eq(download.target.size, TEST_DATA_SHORT.length); + + // The DownloadTarget properties should be restored when the file is put back. + yield OS.File.move(download.target.path + ".old", download.target.path); + yield download.refresh(); + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT); +}); + +/** + * Checks that a download cannot be restarted after the "finalize" method. + */ +add_task(function* test_finalize() +{ + mustInterruptResponses(); + + let download = yield promiseStartDownload(httpUrl("interruptible.txt")); + + let promiseFinalized = download.finalize(); + + try { + yield download.start(); + do_throw("It should not be possible to restart after finalization."); + } catch (ex) { } + + yield promiseFinalized; + + do_check_true(download.stopped); + do_check_false(download.succeeded); + do_check_true(download.canceled); + do_check_true(download.error === null); + + do_check_false(yield OS.File.exists(download.target.path)); +}); + +/** + * Checks that the "finalize" method can remove partially downloaded data. + */ +add_task(function* test_finalize_tryToKeepPartialData() +{ + // Check finalization without removing partial data. + let download = yield promiseStartDownload_tryToKeepPartialData(); + yield download.finalize(); + + do_check_true(download.hasPartialData); + do_check_true(yield OS.File.exists(download.target.partFilePath)); + + // Clean up. + yield download.removePartialData(); + + // Check finalization while removing partial data. + download = yield promiseStartDownload_tryToKeepPartialData(); + yield download.finalize(true); + + do_check_false(download.hasPartialData); + do_check_false(yield OS.File.exists(download.target.partFilePath)); +}); + +/** + * Checks that whenSucceeded returns a promise that is resolved after a restart. + */ +add_task(function* test_whenSucceeded_after_restart() +{ + mustInterruptResponses(); + + let promiseSucceeded; + + let download; + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we have control over the download, thus + // we can verify getting a reference before the first download attempt. + download = yield promiseNewDownload(httpUrl("interruptible.txt")); + promiseSucceeded = download.whenSucceeded(); + download.start().catch(() => {}); + } else { + // When testing DownloadLegacySaver, the download is already started when it + // is created, thus we cannot get the reference before the first attempt. + download = yield promiseStartLegacyDownload(httpUrl("interruptible.txt")); + promiseSucceeded = download.whenSucceeded(); + } + + // Cancel the first download attempt. + yield download.cancel(); + + // The second request is allowed to complete. + continueResponses(); + download.start().catch(() => {}); + + // Wait for the download to finish by waiting on the whenSucceeded promise. + yield promiseSucceeded; + + do_check_true(download.stopped); + do_check_true(download.succeeded); + do_check_false(download.canceled); + do_check_true(download.error === null); + + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); +}); + +/** + * Ensures download error details are reported on network failures. + */ +add_task(function* test_error_source() +{ + let serverSocket = startFakeServer(); + try { + let sourceUrl = "http://localhost:" + serverSocket.port + "/source.txt"; + + let download; + try { + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we want to check that the promise + // returned by the "start" method is rejected. + download = yield promiseNewDownload(sourceUrl); + + do_check_true(download.error === null); + + yield download.start(); + } else { + // When testing DownloadLegacySaver, we cannot be sure whether we are + // testing the promise returned by the "start" method or we are testing + // the "error" property checked by promiseDownloadStopped. This happens + // because we don't have control over when the download is started. + download = yield promiseStartLegacyDownload(sourceUrl); + yield promiseDownloadStopped(download); + } + do_throw("The download should have failed."); + } catch (ex) { + if (!(ex instanceof Downloads.Error) || !ex.becauseSourceFailed) { + throw ex; + } + // A specific error object is thrown when reading from the source fails. + } + + // Check the properties now that the download stopped. + do_check_true(download.stopped); + do_check_false(download.canceled); + do_check_true(download.error !== null); + do_check_true(download.error.becauseSourceFailed); + do_check_false(download.error.becauseTargetFailed); + + do_check_false(yield OS.File.exists(download.target.path)); + do_check_false(download.target.exists); + do_check_eq(download.target.size, 0); + } finally { + serverSocket.close(); + } +}); + +/** + * Ensures a download error is reported when receiving less bytes than what was + * specified in the Content-Length header. + */ +add_task(function* test_error_source_partial() +{ + let sourceUrl = httpUrl("shorter-than-content-length-http-1-1.txt"); + + let enforcePref = Services.prefs.getBoolPref("network.http.enforce-framing.http1"); + Services.prefs.setBoolPref("network.http.enforce-framing.http1", true); + + function cleanup() { + Services.prefs.setBoolPref("network.http.enforce-framing.http1", enforcePref); + } + do_register_cleanup(cleanup); + + let download; + try { + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we want to check that the promise + // returned by the "start" method is rejected. + download = yield promiseNewDownload(sourceUrl); + + do_check_true(download.error === null); + + yield download.start(); + } else { + // When testing DownloadLegacySaver, we cannot be sure whether we are + // testing the promise returned by the "start" method or we are testing + // the "error" property checked by promiseDownloadStopped. This happens + // because we don't have control over when the download is started. + download = yield promiseStartLegacyDownload(sourceUrl); + yield promiseDownloadStopped(download); + } + do_throw("The download should have failed."); + } catch (ex) { + if (!(ex instanceof Downloads.Error) || !ex.becauseSourceFailed) { + throw ex; + } + // A specific error object is thrown when reading from the source fails. + } + + // Check the properties now that the download stopped. + do_check_true(download.stopped); + do_check_false(download.canceled); + do_check_true(download.error !== null); + do_check_true(download.error.becauseSourceFailed); + do_check_false(download.error.becauseTargetFailed); + do_check_eq(download.error.result, Cr.NS_ERROR_NET_PARTIAL_TRANSFER); + + do_check_false(yield OS.File.exists(download.target.path)); + do_check_false(download.target.exists); + do_check_eq(download.target.size, 0); +}); + +/** + * Ensures download error details are reported on local writing failures. + */ +add_task(function* test_error_target() +{ + // Create a file without write access permissions before downloading. + let targetFile = getTempFile(TEST_TARGET_FILE_NAME); + targetFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0); + try { + let download; + try { + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we want to check that the promise + // returned by the "start" method is rejected. + download = yield Downloads.createDownload({ + source: httpUrl("source.txt"), + target: targetFile, + }); + yield download.start(); + } else { + // When testing DownloadLegacySaver, we cannot be sure whether we are + // testing the promise returned by the "start" method or we are testing + // the "error" property checked by promiseDownloadStopped. This happens + // because we don't have control over when the download is started. + download = yield promiseStartLegacyDownload(null, + { targetFile: targetFile }); + yield promiseDownloadStopped(download); + } + do_throw("The download should have failed."); + } catch (ex) { + if (!(ex instanceof Downloads.Error) || !ex.becauseTargetFailed) { + throw ex; + } + // A specific error object is thrown when writing to the target fails. + } + + // Check the properties now that the download stopped. + do_check_true(download.stopped); + do_check_false(download.canceled); + do_check_true(download.error !== null); + do_check_true(download.error.becauseTargetFailed); + do_check_false(download.error.becauseSourceFailed); + } finally { + // Restore the default permissions to allow deleting the file on Windows. + if (targetFile.exists()) { + targetFile.permissions = FileUtils.PERMS_FILE; + targetFile.remove(false); + } + } +}); + +/** + * Restarts a failed download. + */ +add_task(function* test_error_restart() +{ + let download; + + // Create a file without write access permissions before downloading. + let targetFile = getTempFile(TEST_TARGET_FILE_NAME); + targetFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0); + try { + // Use DownloadCopySaver or DownloadLegacySaver based on the test run, + // specifying the target file we created. + if (!gUseLegacySaver) { + download = yield Downloads.createDownload({ + source: httpUrl("source.txt"), + target: targetFile, + }); + download.start().catch(() => {}); + } else { + download = yield promiseStartLegacyDownload(null, + { targetFile: targetFile }); + } + yield promiseDownloadStopped(download); + do_throw("The download should have failed."); + } catch (ex) { + if (!(ex instanceof Downloads.Error) || !ex.becauseTargetFailed) { + throw ex; + } + // A specific error object is thrown when writing to the target fails. + } finally { + // Restore the default permissions to allow deleting the file on Windows. + if (targetFile.exists()) { + targetFile.permissions = FileUtils.PERMS_FILE; + + // Also for Windows, rename the file before deleting. This makes the + // current file name available immediately for a new file, while deleting + // in place prevents creation of a file with the same name for some time. + targetFile.moveTo(null, targetFile.leafName + ".delete.tmp"); + targetFile.remove(false); + } + } + + // Restart the download and wait for completion. + yield download.start(); + + do_check_true(download.stopped); + do_check_true(download.succeeded); + do_check_false(download.canceled); + do_check_true(download.error === null); + do_check_eq(download.progress, 100); + + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT); +}); + +/** + * Executes download in both public and private modes. + */ +add_task(function* test_public_and_private() +{ + let sourcePath = "/test_public_and_private.txt"; + let sourceUrl = httpUrl("test_public_and_private.txt"); + let testCount = 0; + + // Apply pref to allow all cookies. + Services.prefs.setIntPref("network.cookie.cookieBehavior", 0); + + function cleanup() { + Services.prefs.clearUserPref("network.cookie.cookieBehavior"); + Services.cookies.removeAll(); + gHttpServer.registerPathHandler(sourcePath, null); + } + do_register_cleanup(cleanup); + + gHttpServer.registerPathHandler(sourcePath, function (aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + + if (testCount == 0) { + // No cookies should exist for first public download. + do_check_false(aRequest.hasHeader("Cookie")); + aResponse.setHeader("Set-Cookie", "foobar=1", false); + testCount++; + } else if (testCount == 1) { + // The cookie should exists for second public download. + do_check_true(aRequest.hasHeader("Cookie")); + do_check_eq(aRequest.getHeader("Cookie"), "foobar=1"); + testCount++; + } else if (testCount == 2) { + // No cookies should exist for first private download. + do_check_false(aRequest.hasHeader("Cookie")); + } + }); + + let targetFile = getTempFile(TEST_TARGET_FILE_NAME); + yield Downloads.fetch(sourceUrl, targetFile); + yield Downloads.fetch(sourceUrl, targetFile); + + if (!gUseLegacySaver) { + let download = yield Downloads.createDownload({ + source: { url: sourceUrl, isPrivate: true }, + target: targetFile, + }); + yield download.start(); + } else { + let download = yield promiseStartLegacyDownload(sourceUrl, + { isPrivate: true }); + yield promiseDownloadStopped(download); + } + + cleanup(); +}); + +/** + * Checks the startTime gets updated even after a restart. + */ +add_task(function* test_cancel_immediately_restart_and_check_startTime() +{ + let download = yield promiseStartDownload(); + + let startTime = download.startTime; + do_check_true(isValidDate(download.startTime)); + + yield download.cancel(); + do_check_eq(download.startTime.getTime(), startTime.getTime()); + + // Wait for a timeout. + yield promiseTimeout(10); + + yield download.start(); + do_check_true(download.startTime.getTime() > startTime.getTime()); +}); + +/** + * Executes download with content-encoding. + */ +add_task(function* test_with_content_encoding() +{ + let sourcePath = "/test_with_content_encoding.txt"; + let sourceUrl = httpUrl("test_with_content_encoding.txt"); + + function cleanup() { + gHttpServer.registerPathHandler(sourcePath, null); + } + do_register_cleanup(cleanup); + + gHttpServer.registerPathHandler(sourcePath, function (aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + aResponse.setHeader("Content-Encoding", "gzip", false); + aResponse.setHeader("Content-Length", + "" + TEST_DATA_SHORT_GZIP_ENCODED.length, false); + + let bos = new BinaryOutputStream(aResponse.bodyOutputStream); + bos.writeByteArray(TEST_DATA_SHORT_GZIP_ENCODED, + TEST_DATA_SHORT_GZIP_ENCODED.length); + }); + + let download = yield promiseStartDownload(sourceUrl); + yield promiseDownloadStopped(download); + + do_check_eq(download.progress, 100); + do_check_eq(download.totalBytes, TEST_DATA_SHORT_GZIP_ENCODED.length); + + // Ensure the content matches the decoded test data. + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT); + + cleanup(); +}); + +/** + * Checks that the file is not decoded if the extension matches the encoding. + */ +add_task(function* test_with_content_encoding_ignore_extension() +{ + let sourcePath = "/test_with_content_encoding_ignore_extension.gz"; + let sourceUrl = httpUrl("test_with_content_encoding_ignore_extension.gz"); + + function cleanup() { + gHttpServer.registerPathHandler(sourcePath, null); + } + do_register_cleanup(cleanup); + + gHttpServer.registerPathHandler(sourcePath, function (aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + aResponse.setHeader("Content-Encoding", "gzip", false); + aResponse.setHeader("Content-Length", + "" + TEST_DATA_SHORT_GZIP_ENCODED.length, false); + + let bos = new BinaryOutputStream(aResponse.bodyOutputStream); + bos.writeByteArray(TEST_DATA_SHORT_GZIP_ENCODED, + TEST_DATA_SHORT_GZIP_ENCODED.length); + }); + + let download = yield promiseStartDownload(sourceUrl); + yield promiseDownloadStopped(download); + + do_check_eq(download.progress, 100); + do_check_eq(download.totalBytes, TEST_DATA_SHORT_GZIP_ENCODED.length); + do_check_eq(download.target.size, TEST_DATA_SHORT_GZIP_ENCODED.length); + + // Ensure the content matches the encoded test data. We convert the data to a + // string before executing the content check. + yield promiseVerifyTarget(download.target, + String.fromCharCode.apply(String, TEST_DATA_SHORT_GZIP_ENCODED)); + + cleanup(); +}); + +/** + * Cancels and restarts a download sequentially with content-encoding. + */ +add_task(function* test_cancel_midway_restart_with_content_encoding() +{ + mustInterruptResponses(); + + let download = yield promiseStartDownload(httpUrl("interruptible_gzip.txt")); + + // The first time, cancel the download midway. + let deferCancel = Promise.defer(); + let onchange = function () { + if (!download.stopped && !download.canceled && + download.currentBytes == TEST_DATA_SHORT_GZIP_ENCODED_FIRST.length) { + deferCancel.resolve(download.cancel()); + } + }; + + // Register for the notification, but also call the function directly in + // case the download already reached the expected progress. + download.onchange = onchange; + onchange(); + + yield deferCancel.promise; + + do_check_true(download.stopped); + + // The second time, we'll provide the entire interruptible response. + continueResponses(); + download.onchange = null; + yield download.start(); + + do_check_eq(download.progress, 100); + do_check_eq(download.totalBytes, TEST_DATA_SHORT_GZIP_ENCODED.length); + + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT); +}); + +/** + * Download with parental controls enabled. + */ +add_task(function* test_blocked_parental_controls() +{ + let blockFn = base => ({ + shouldBlockForParentalControls: () => Promise.resolve(true), + }); + + Integration.downloads.register(blockFn); + function cleanup() { + Integration.downloads.unregister(blockFn); + } + do_register_cleanup(cleanup); + + let download; + try { + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we want to check that the promise + // returned by the "start" method is rejected. + download = yield promiseNewDownload(); + yield download.start(); + } else { + // When testing DownloadLegacySaver, we cannot be sure whether we are + // testing the promise returned by the "start" method or we are testing + // the "error" property checked by promiseDownloadStopped. This happens + // because we don't have control over when the download is started. + download = yield promiseStartLegacyDownload(); + yield promiseDownloadStopped(download); + } + do_throw("The download should have blocked."); + } catch (ex) { + if (!(ex instanceof Downloads.Error) || !ex.becauseBlocked) { + throw ex; + } + do_check_true(ex.becauseBlockedByParentalControls); + do_check_true(download.error.becauseBlockedByParentalControls); + } + + // Now that the download stopped, the target file should not exist. + do_check_false(yield OS.File.exists(download.target.path)); + + cleanup(); +}); + +/** + * Test a download that will be blocked by Windows parental controls by + * resulting in an HTTP status code of 450. + */ +add_task(function* test_blocked_parental_controls_httpstatus450() +{ + let download; + try { + if (!gUseLegacySaver) { + download = yield promiseNewDownload(httpUrl("parentalblocked.zip")); + yield download.start(); + } + else { + download = yield promiseStartLegacyDownload(httpUrl("parentalblocked.zip")); + yield promiseDownloadStopped(download); + } + do_throw("The download should have blocked."); + } catch (ex) { + if (!(ex instanceof Downloads.Error) || !ex.becauseBlocked) { + throw ex; + } + do_check_true(ex.becauseBlockedByParentalControls); + do_check_true(download.error.becauseBlockedByParentalControls); + do_check_true(download.stopped); + } + + do_check_false(yield OS.File.exists(download.target.path)); +}); + +/** + * Download with runtime permissions + */ +add_task(function* test_blocked_runtime_permissions() +{ + let blockFn = base => ({ + shouldBlockForRuntimePermissions: () => Promise.resolve(true), + }); + + Integration.downloads.register(blockFn); + function cleanup() { + Integration.downloads.unregister(blockFn); + } + do_register_cleanup(cleanup); + + let download; + try { + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we want to check that the promise + // returned by the "start" method is rejected. + download = yield promiseNewDownload(); + yield download.start(); + } else { + // When testing DownloadLegacySaver, we cannot be sure whether we are + // testing the promise returned by the "start" method or we are testing + // the "error" property checked by promiseDownloadStopped. This happens + // because we don't have control over when the download is started. + download = yield promiseStartLegacyDownload(); + yield promiseDownloadStopped(download); + } + do_throw("The download should have blocked."); + } catch (ex) { + if (!(ex instanceof Downloads.Error) || !ex.becauseBlocked) { + throw ex; + } + do_check_true(ex.becauseBlockedByRuntimePermissions); + do_check_true(download.error.becauseBlockedByRuntimePermissions); + } + + // Now that the download stopped, the target file should not exist. + do_check_false(yield OS.File.exists(download.target.path)); + + cleanup(); +}); + +/** + * Check that DownloadCopySaver can always retrieve the hash. + * DownloadLegacySaver can only retrieve the hash when + * nsIExternalHelperAppService is invoked. + */ +add_task(function* test_getSha256Hash() +{ + if (!gUseLegacySaver) { + let download = yield promiseStartDownload(httpUrl("source.txt")); + yield promiseDownloadStopped(download); + do_check_true(download.stopped); + do_check_eq(32, download.saver.getSha256Hash().length); + } +}); + +/** + * Create a download which will be reputation blocked. + * + * @param options + * { + * keepPartialData: bool, + * keepBlockedData: bool, + * } + * @return {Promise} + * @resolves The reputation blocked download. + * @rejects JavaScript exception. + */ +var promiseBlockedDownload = Task.async(function* (options) { + let blockFn = base => ({ + shouldBlockForReputationCheck: () => Promise.resolve({ + shouldBlock: true, + verdict: Downloads.Error.BLOCK_VERDICT_UNCOMMON, + }), + shouldKeepBlockedData: () => Promise.resolve(options.keepBlockedData), + }); + + Integration.downloads.register(blockFn); + function cleanup() { + Integration.downloads.unregister(blockFn); + } + do_register_cleanup(cleanup); + + let download; + + try { + if (options.keepPartialData) { + download = yield promiseStartDownload_tryToKeepPartialData(); + continueResponses(); + } else if (gUseLegacySaver) { + download = yield promiseStartLegacyDownload(); + } else { + download = yield promiseNewDownload(); + yield download.start(); + do_throw("The download should have blocked."); + } + + yield promiseDownloadStopped(download); + do_throw("The download should have blocked."); + } catch (ex) { + if (!(ex instanceof Downloads.Error) || !ex.becauseBlocked) { + throw ex; + } + do_check_true(ex.becauseBlockedByReputationCheck); + do_check_eq(ex.reputationCheckVerdict, + Downloads.Error.BLOCK_VERDICT_UNCOMMON); + do_check_true(download.error.becauseBlockedByReputationCheck); + do_check_eq(download.error.reputationCheckVerdict, + Downloads.Error.BLOCK_VERDICT_UNCOMMON); + } + + do_check_true(download.stopped); + do_check_false(download.succeeded); + do_check_false(yield OS.File.exists(download.target.path)); + + cleanup(); + return download; +}); + +/** + * Checks that application reputation blocks the download and the target file + * does not exist. + */ +add_task(function* test_blocked_applicationReputation() +{ + let download = yield promiseBlockedDownload({ + keepPartialData: false, + keepBlockedData: false, + }); + + // Now that the download is blocked, the target file should not exist. + do_check_false(yield OS.File.exists(download.target.path)); + do_check_false(download.target.exists); + do_check_eq(download.target.size, 0); + + // There should also be no blocked data in this case + do_check_false(download.hasBlockedData); +}); + +/** + * Checks that if a download restarts while processing an application reputation + * request, the status is handled correctly. + */ +add_task(function* test_blocked_applicationReputation_race() +{ + let isFirstShouldBlockCall = true; + + let blockFn = base => ({ + shouldBlockForReputationCheck(download) { + if (isFirstShouldBlockCall) { + isFirstShouldBlockCall = false; + + // 2. Cancel and restart the download before the first attempt has a + // chance to finish. + download.cancel(); + download.removePartialData(); + download.start(); + + // 3. Allow the first attempt to finish with a blocked response. + return Promise.resolve({ + shouldBlock: true, + verdict: Downloads.Error.BLOCK_VERDICT_UNCOMMON, + }); + } + + // 4/5. Don't block the download the second time. The race condition would + // occur with the first attempt regardless of whether the second one + // is blocked, but not blocking here makes the test simpler. + return Promise.resolve({ + shouldBlock: false, + verdict: "", + }); + }, + shouldKeepBlockedData: () => Promise.resolve(true), + }); + + Integration.downloads.register(blockFn); + function cleanup() { + Integration.downloads.unregister(blockFn); + } + do_register_cleanup(cleanup); + + let download; + + try { + // 1. Start the download and get a reference to the promise asociated with + // the first attempt, before allowing the response to continue. + download = yield promiseStartDownload_tryToKeepPartialData(); + let firstAttempt = promiseDownloadStopped(download); + continueResponses(); + + // 4/5. Wait for the first attempt to be completed. The result of this + // should appear as a cancellation. + yield firstAttempt; + + do_throw("The first attempt should have been canceled."); + } catch (ex) { + // The "becauseBlocked" property should be false. + if (!(ex instanceof Downloads.Error) || ex.becauseBlocked) { + throw ex; + } + } + + // 6. Wait for the second attempt to be completed. + yield promiseDownloadStopped(download); + + // 7. At this point, "hasBlockedData" should be false. + do_check_false(download.hasBlockedData); + + cleanup(); +}); + +/** + * Checks that application reputation blocks the download but maintains the + * blocked data, which will be deleted when the block is confirmed. + */ +add_task(function* test_blocked_applicationReputation_confirmBlock() +{ + let download = yield promiseBlockedDownload({ + keepPartialData: true, + keepBlockedData: true, + }); + + do_check_true(download.hasBlockedData); + do_check_true(yield OS.File.exists(download.target.partFilePath)); + + yield download.confirmBlock(); + + // After confirming the block the download should be in a failed state and + // have no downloaded data left on disk. + do_check_true(download.stopped); + do_check_false(download.succeeded); + do_check_false(download.hasBlockedData); + do_check_false(yield OS.File.exists(download.target.partFilePath)); + do_check_false(yield OS.File.exists(download.target.path)); + do_check_false(download.target.exists); + do_check_eq(download.target.size, 0); +}); + +/** + * Checks that application reputation blocks the download but maintains the + * blocked data, which will be used to complete the download when unblocking. + */ +add_task(function* test_blocked_applicationReputation_unblock() +{ + let download = yield promiseBlockedDownload({ + keepPartialData: true, + keepBlockedData: true, + }); + + do_check_true(download.hasBlockedData); + do_check_true(yield OS.File.exists(download.target.partFilePath)); + + yield download.unblock(); + + // After unblocking the download should have succeeded and be + // present at the final path. + do_check_true(download.stopped); + do_check_true(download.succeeded); + do_check_false(download.hasBlockedData); + do_check_false(yield OS.File.exists(download.target.partFilePath)); + yield promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); + + // The only indication the download was previously blocked is the + // existence of the error, so we make sure it's still set. + do_check_true(download.error instanceof Downloads.Error); + do_check_true(download.error.becauseBlocked); + do_check_true(download.error.becauseBlockedByReputationCheck); +}); + +/** + * Check that calling cancel on a blocked download will not cause errors + */ +add_task(function* test_blocked_applicationReputation_cancel() +{ + let download = yield promiseBlockedDownload({ + keepPartialData: true, + keepBlockedData: true, + }); + + // This call should succeed on a blocked download. + yield download.cancel(); + + // Calling cancel should not have changed the current state, the download + // should still be blocked. + do_check_true(download.error.becauseBlockedByReputationCheck); + do_check_true(download.stopped); + do_check_false(download.succeeded); + do_check_true(download.hasBlockedData); +}); + +/** + * Checks that unblock and confirmBlock cannot race on a blocked download + */ +add_task(function* test_blocked_applicationReputation_decisionRace() +{ + let download = yield promiseBlockedDownload({ + keepPartialData: true, + keepBlockedData: true, + }); + + let unblockPromise = download.unblock(); + let confirmBlockPromise = download.confirmBlock(); + + yield confirmBlockPromise.then(() => { + do_throw("confirmBlock should have failed."); + }, () => {}); + + yield unblockPromise; + + // After unblocking the download should have succeeded and be + // present at the final path. + do_check_true(download.stopped); + do_check_true(download.succeeded); + do_check_false(download.hasBlockedData); + do_check_false(yield OS.File.exists(download.target.partFilePath)); + do_check_true(yield OS.File.exists(download.target.path)); + + download = yield promiseBlockedDownload({ + keepPartialData: true, + keepBlockedData: true, + }); + + confirmBlockPromise = download.confirmBlock(); + unblockPromise = download.unblock(); + + yield unblockPromise.then(() => { + do_throw("unblock should have failed."); + }, () => {}); + + yield confirmBlockPromise; + + // After confirming the block the download should be in a failed state and + // have no downloaded data left on disk. + do_check_true(download.stopped); + do_check_false(download.succeeded); + do_check_false(download.hasBlockedData); + do_check_false(yield OS.File.exists(download.target.partFilePath)); + do_check_false(yield OS.File.exists(download.target.path)); + do_check_false(download.target.exists); + do_check_eq(download.target.size, 0); +}); + +/** + * Checks that unblocking a blocked download fails if the blocked data has been + * removed. + */ +add_task(function* test_blocked_applicationReputation_unblock() +{ + let download = yield promiseBlockedDownload({ + keepPartialData: true, + keepBlockedData: true, + }); + + do_check_true(download.hasBlockedData); + do_check_true(yield OS.File.exists(download.target.partFilePath)); + + // Remove the blocked data without telling the download. + yield OS.File.remove(download.target.partFilePath); + + let unblockPromise = download.unblock(); + yield unblockPromise.then(() => { + do_throw("unblock should have failed."); + }, () => {}); + + // Even though unblocking failed the download state should have been updated + // to reflect the lack of blocked data. + do_check_false(download.hasBlockedData); + do_check_true(download.stopped); + do_check_false(download.succeeded); + do_check_false(download.target.exists); + do_check_eq(download.target.size, 0); +}); + +/** + * download.showContainingDirectory() action + */ +add_task(function* test_showContainingDirectory() { + let targetPath = getTempFile(TEST_TARGET_FILE_NAME).path; + + let download = yield Downloads.createDownload({ + source: { url: httpUrl("source.txt") }, + target: "" + }); + + let promiseDirectoryShown = waitForDirectoryShown(); + yield download.showContainingDirectory(); + let path = yield promiseDirectoryShown; + try { + new FileUtils.File(path); + do_throw("Should have failed because of an invalid path."); + } catch (ex) { + if (!(ex instanceof Components.Exception)) { + throw ex; + } + // Invalid paths on Windows are reported with NS_ERROR_FAILURE, + // but with NS_ERROR_FILE_UNRECOGNIZED_PATH on Mac/Linux + let validResult = ex.result == Cr.NS_ERROR_FILE_UNRECOGNIZED_PATH || + ex.result == Cr.NS_ERROR_FAILURE; + do_check_true(validResult); + } + + download = yield Downloads.createDownload({ + source: { url: httpUrl("source.txt") }, + target: targetPath + }); + + promiseDirectoryShown = waitForDirectoryShown(); + download.showContainingDirectory(); + yield promiseDirectoryShown; +}); + +/** + * download.launch() action + */ +add_task(function* test_launch() { + let customLauncher = getTempFile("app-launcher"); + + // Test both with and without setting a custom application. + for (let launcherPath of [null, customLauncher.path]) { + let download; + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we have control over the download, thus + // we can test that file is not launched if download.succeeded is not set. + download = yield Downloads.createDownload({ + source: httpUrl("source.txt"), + target: getTempFile(TEST_TARGET_FILE_NAME).path, + launcherPath: launcherPath, + launchWhenSucceeded: true + }); + + try { + yield download.launch(); + do_throw("Can't launch download file as it has not completed yet"); + } catch (ex) { + do_check_eq(ex.message, + "launch can only be called if the download succeeded"); + } + + yield download.start(); + } else { + // When testing DownloadLegacySaver, the download is already started when + // it is created, thus we don't test calling "launch" before starting. + download = yield promiseStartLegacyDownload( + httpUrl("source.txt"), + { launcherPath: launcherPath, + launchWhenSucceeded: true }); + yield promiseDownloadStopped(download); + } + + do_check_true(download.launchWhenSucceeded); + + let promiseFileLaunched = waitForFileLaunched(); + download.launch(); + let result = yield promiseFileLaunched; + + // Verify that the results match the test case. + if (!launcherPath) { + // This indicates that the default handler has been chosen. + do_check_true(result === null); + } else { + // Check the nsIMIMEInfo instance that would have been used for launching. + do_check_eq(result.preferredAction, Ci.nsIMIMEInfo.useHelperApp); + do_check_true(result.preferredApplicationHandler + .QueryInterface(Ci.nsILocalHandlerApp) + .executable.equals(customLauncher)); + } + } +}); + +/** + * Test passing an invalid path as the launcherPath property. + */ +add_task(function* test_launcherPath_invalid() { + let download = yield Downloads.createDownload({ + source: { url: httpUrl("source.txt") }, + target: { path: getTempFile(TEST_TARGET_FILE_NAME).path }, + launcherPath: " " + }); + + let promiseDownloadLaunched = new Promise(resolve => { + let waitFn = base => ({ + __proto__: base, + launchDownload() { + Integration.downloads.unregister(waitFn); + let superPromise = super.launchDownload(...arguments); + resolve(superPromise); + return superPromise; + }, + }); + Integration.downloads.register(waitFn); + }); + + yield download.start(); + try { + download.launch(); + yield promiseDownloadLaunched; + do_throw("Can't launch file with invalid custom launcher") + } catch (ex) { + if (!(ex instanceof Components.Exception)) { + throw ex; + } + // Invalid paths on Windows are reported with NS_ERROR_FAILURE, + // but with NS_ERROR_FILE_UNRECOGNIZED_PATH on Mac/Linux + let validResult = ex.result == Cr.NS_ERROR_FILE_UNRECOGNIZED_PATH || + ex.result == Cr.NS_ERROR_FAILURE; + do_check_true(validResult); + } +}); + +/** + * Tests that download.launch() is automatically called after + * the download finishes if download.launchWhenSucceeded = true + */ +add_task(function* test_launchWhenSucceeded() { + let customLauncher = getTempFile("app-launcher"); + + // Test both with and without setting a custom application. + for (let launcherPath of [null, customLauncher.path]) { + let promiseFileLaunched = waitForFileLaunched(); + + if (!gUseLegacySaver) { + let download = yield Downloads.createDownload({ + source: httpUrl("source.txt"), + target: getTempFile(TEST_TARGET_FILE_NAME).path, + launchWhenSucceeded: true, + launcherPath: launcherPath, + }); + yield download.start(); + } else { + let download = yield promiseStartLegacyDownload( + httpUrl("source.txt"), + { launcherPath: launcherPath, + launchWhenSucceeded: true }); + yield promiseDownloadStopped(download); + } + + let result = yield promiseFileLaunched; + + // Verify that the results match the test case. + if (!launcherPath) { + // This indicates that the default handler has been chosen. + do_check_true(result === null); + } else { + // Check the nsIMIMEInfo instance that would have been used for launching. + do_check_eq(result.preferredAction, Ci.nsIMIMEInfo.useHelperApp); + do_check_true(result.preferredApplicationHandler + .QueryInterface(Ci.nsILocalHandlerApp) + .executable.equals(customLauncher)); + } + } +}); + +/** + * Tests that the proper content type is set for a normal download. + */ +add_task(function* test_contentType() { + let download = yield promiseStartDownload(httpUrl("source.txt")); + yield promiseDownloadStopped(download); + + do_check_eq("text/plain", download.contentType); +}); + +/** + * Tests that the serialization/deserialization of the startTime Date + * object works correctly. + */ +add_task(function* test_toSerializable_startTime() +{ + let download1 = yield promiseStartDownload(httpUrl("source.txt")); + yield promiseDownloadStopped(download1); + + let serializable = download1.toSerializable(); + let reserialized = JSON.parse(JSON.stringify(serializable)); + + let download2 = yield Downloads.createDownload(reserialized); + + do_check_eq(download1.startTime.constructor.name, "Date"); + do_check_eq(download2.startTime.constructor.name, "Date"); + do_check_eq(download1.startTime.toJSON(), download2.startTime.toJSON()); +}); + +/** + * Checks that downloads are added to browsing history when they start. + */ +add_task(function* test_history() +{ + mustInterruptResponses(); + + // We will wait for the visit to be notified during the download. + yield PlacesTestUtils.clearHistory(); + let promiseVisit = promiseWaitForVisit(httpUrl("interruptible.txt")); + + // Start a download that is not allowed to finish yet. + let download = yield promiseStartDownload(httpUrl("interruptible.txt")); + + // The history notifications should be received before the download completes. + let [time, transitionType] = yield promiseVisit; + do_check_eq(time, download.startTime.getTime() * 1000); + do_check_eq(transitionType, Ci.nsINavHistoryService.TRANSITION_DOWNLOAD); + + // Restart and complete the download after clearing history. + yield PlacesTestUtils.clearHistory(); + download.cancel(); + continueResponses(); + yield download.start(); + + // The restart should not have added a new history visit. + do_check_false(yield promiseIsURIVisited(httpUrl("interruptible.txt"))); +}); + +/** + * Checks that downloads started by nsIHelperAppService are added to the + * browsing history when they start. + */ +add_task(function* test_history_tryToKeepPartialData() +{ + // We will wait for the visit to be notified during the download. + yield PlacesTestUtils.clearHistory(); + let promiseVisit = + promiseWaitForVisit(httpUrl("interruptible_resumable.txt")); + + // Start a download that is not allowed to finish yet. + let beforeStartTimeMs = Date.now(); + let download = yield promiseStartDownload_tryToKeepPartialData(); + + // The history notifications should be received before the download completes. + let [time, transitionType] = yield promiseVisit; + do_check_eq(transitionType, Ci.nsINavHistoryService.TRANSITION_DOWNLOAD); + + // The time set by nsIHelperAppService may be different than the start time in + // the download object, thus we only check that it is a meaningful time. Note + // that we subtract one second from the earliest time to account for rounding. + do_check_true(time >= beforeStartTimeMs * 1000 - 1000000); + + // Complete the download before finishing the test. + continueResponses(); + yield promiseDownloadStopped(download); +}); + +/** + * Tests that the temp download files are removed on exit and exiting private + * mode after they have been launched. + */ +add_task(function* test_launchWhenSucceeded_deleteTempFileOnExit() { + let customLauncherPath = getTempFile("app-launcher").path; + let autoDeleteTargetPathOne = getTempFile(TEST_TARGET_FILE_NAME).path; + let autoDeleteTargetPathTwo = getTempFile(TEST_TARGET_FILE_NAME).path; + let noAutoDeleteTargetPath = getTempFile(TEST_TARGET_FILE_NAME).path; + + let autoDeleteDownloadOne = yield Downloads.createDownload({ + source: { url: httpUrl("source.txt"), isPrivate: true }, + target: autoDeleteTargetPathOne, + launchWhenSucceeded: true, + launcherPath: customLauncherPath, + }); + yield autoDeleteDownloadOne.start(); + + Services.prefs.setBoolPref(kDeleteTempFileOnExit, true); + let autoDeleteDownloadTwo = yield Downloads.createDownload({ + source: httpUrl("source.txt"), + target: autoDeleteTargetPathTwo, + launchWhenSucceeded: true, + launcherPath: customLauncherPath, + }); + yield autoDeleteDownloadTwo.start(); + + Services.prefs.setBoolPref(kDeleteTempFileOnExit, false); + let noAutoDeleteDownload = yield Downloads.createDownload({ + source: httpUrl("source.txt"), + target: noAutoDeleteTargetPath, + launchWhenSucceeded: true, + launcherPath: customLauncherPath, + }); + yield noAutoDeleteDownload.start(); + + Services.prefs.clearUserPref(kDeleteTempFileOnExit); + + do_check_true(yield OS.File.exists(autoDeleteTargetPathOne)); + do_check_true(yield OS.File.exists(autoDeleteTargetPathTwo)); + do_check_true(yield OS.File.exists(noAutoDeleteTargetPath)); + + // Simulate leaving private browsing mode + Services.obs.notifyObservers(null, "last-pb-context-exited", null); + do_check_false(yield OS.File.exists(autoDeleteTargetPathOne)); + + // Simulate browser shutdown + let expire = Cc["@mozilla.org/uriloader/external-helper-app-service;1"] + .getService(Ci.nsIObserver); + expire.observe(null, "profile-before-change", null); + do_check_false(yield OS.File.exists(autoDeleteTargetPathTwo)); + do_check_true(yield OS.File.exists(noAutoDeleteTargetPath)); +}); diff --git a/toolkit/components/jsdownloads/test/unit/head.js b/toolkit/components/jsdownloads/test/unit/head.js new file mode 100644 index 000000000..f322244c4 --- /dev/null +++ b/toolkit/components/jsdownloads/test/unit/head.js @@ -0,0 +1,843 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Provides infrastructure for automated download components tests. + */ + +"use strict"; + +// Globals + +var Cc = Components.classes; +var Ci = Components.interfaces; +var Cu = Components.utils; +var Cr = Components.results; + +Cu.import("resource://gre/modules/Integration.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "DownloadPaths", + "resource://gre/modules/DownloadPaths.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Downloads", + "resource://gre/modules/Downloads.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "HttpServer", + "resource://testing-common/httpd.js"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PlacesTestUtils", + "resource://testing-common/PlacesTestUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "MockRegistrar", + "resource://testing-common/MockRegistrar.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "gExternalHelperAppService", + "@mozilla.org/uriloader/external-helper-app-service;1", + Ci.nsIExternalHelperAppService); + +Integration.downloads.defineModuleGetter(this, "DownloadIntegration", + "resource://gre/modules/DownloadIntegration.jsm"); + +const ServerSocket = Components.Constructor( + "@mozilla.org/network/server-socket;1", + "nsIServerSocket", + "init"); +const BinaryOutputStream = Components.Constructor( + "@mozilla.org/binaryoutputstream;1", + "nsIBinaryOutputStream", + "setOutputStream") + +XPCOMUtils.defineLazyServiceGetter(this, "gMIMEService", + "@mozilla.org/mime;1", + "nsIMIMEService"); + +const TEST_TARGET_FILE_NAME = "test-download.txt"; +const TEST_STORE_FILE_NAME = "test-downloads.json"; + +const TEST_REFERRER_URL = "http://www.example.com/referrer.html"; + +const TEST_DATA_SHORT = "This test string is downloaded."; +// Generate using gzipCompressString in TelemetryController.jsm. +const TEST_DATA_SHORT_GZIP_ENCODED_FIRST = [ + 31, 139, 8, 0, 0, 0, 0, 0, 0, 3, 11, 201, 200, 44, 86, 40, 73, 45, 46, 81, 40, 46, 41, 202, 204 +]; +const TEST_DATA_SHORT_GZIP_ENCODED_SECOND = [ + 75, 87, 0, 114, 83, 242, 203, 243, 114, 242, 19, 83, 82, 83, 244, 0, 151, 222, 109, 43, 31, 0, 0, 0 +]; +const TEST_DATA_SHORT_GZIP_ENCODED = + TEST_DATA_SHORT_GZIP_ENCODED_FIRST.concat(TEST_DATA_SHORT_GZIP_ENCODED_SECOND); + +/** + * All the tests are implemented with add_task, this starts them automatically. + */ +function run_test() +{ + do_get_profile(); + run_next_test(); +} + +// Support functions + +/** + * HttpServer object initialized before tests start. + */ +var gHttpServer; + +/** + * Given a file name, returns a string containing an URI that points to the file + * on the currently running instance of the test HTTP server. + */ +function httpUrl(aFileName) { + return "http://localhost:" + gHttpServer.identity.primaryPort + "/" + + aFileName; +} + +// While the previous test file should have deleted all the temporary files it +// used, on Windows these might still be pending deletion on the physical file +// system. Thus, start from a new base number every time, to make a collision +// with a file that is still pending deletion highly unlikely. +var gFileCounter = Math.floor(Math.random() * 1000000); + +/** + * Returns a reference to a temporary file, that is guaranteed not to exist, and + * to have never been created before. + * + * @param aLeafName + * Suggested leaf name for the file to be created. + * + * @return nsIFile pointing to a non-existent file in a temporary directory. + * + * @note It is not enough to delete the file if it exists, or to delete the file + * after calling nsIFile.createUnique, because on Windows the delete + * operation in the file system may still be pending, preventing a new + * file with the same name to be created. + */ +function getTempFile(aLeafName) +{ + // Prepend a serial number to the extension in the suggested leaf name. + let [base, ext] = DownloadPaths.splitBaseNameAndExtension(aLeafName); + let leafName = base + "-" + gFileCounter + ext; + gFileCounter++; + + // Get a file reference under the temporary directory for this test file. + let file = FileUtils.getFile("TmpD", [leafName]); + do_check_false(file.exists()); + + do_register_cleanup(function () { + try { + file.remove(false) + } catch (e) { + if (!(e instanceof Components.Exception && + (e.result == Cr.NS_ERROR_FILE_ACCESS_DENIED || + e.result == Cr.NS_ERROR_FILE_TARGET_DOES_NOT_EXIST || + e.result == Cr.NS_ERROR_FILE_NOT_FOUND))) { + throw e; + } + // On Windows, we may get an access denied error if the file existed before, + // and was recently deleted. + // Don't bother checking file.exists() as that may also cause an access + // denied error. + } + }); + + return file; +} + +/** + * Waits for pending events to be processed. + * + * @return {Promise} + * @resolves When pending events have been processed. + * @rejects Never. + */ +function promiseExecuteSoon() +{ + let deferred = Promise.defer(); + do_execute_soon(deferred.resolve); + return deferred.promise; +} + +/** + * Waits for a pending events to be processed after a timeout. + * + * @return {Promise} + * @resolves When pending events have been processed. + * @rejects Never. + */ +function promiseTimeout(aTime) +{ + let deferred = Promise.defer(); + do_timeout(aTime, deferred.resolve); + return deferred.promise; +} + +/** + * Waits for a new history visit to be notified for the specified URI. + * + * @param aUrl + * String containing the URI that will be visited. + * + * @return {Promise} + * @resolves Array [aTime, aTransitionType] from nsINavHistoryObserver.onVisit. + * @rejects Never. + */ +function promiseWaitForVisit(aUrl) +{ + let deferred = Promise.defer(); + + let uri = NetUtil.newURI(aUrl); + + PlacesUtils.history.addObserver({ + QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryObserver]), + onBeginUpdateBatch: function () {}, + onEndUpdateBatch: function () {}, + onVisit: function (aURI, aVisitID, aTime, aSessionID, aReferringID, + aTransitionType, aGUID, aHidden) { + if (aURI.equals(uri)) { + PlacesUtils.history.removeObserver(this); + deferred.resolve([aTime, aTransitionType]); + } + }, + onTitleChanged: function () {}, + onDeleteURI: function () {}, + onClearHistory: function () {}, + onPageChanged: function () {}, + onDeleteVisits: function () {}, + }, false); + + return deferred.promise; +} + +/** + * Check browsing history to see whether the given URI has been visited. + * + * @param aUrl + * String containing the URI that will be visited. + * + * @return {Promise} + * @resolves Boolean indicating whether the URI has been visited. + * @rejects JavaScript exception. + */ +function promiseIsURIVisited(aUrl) { + let deferred = Promise.defer(); + + PlacesUtils.asyncHistory.isURIVisited(NetUtil.newURI(aUrl), + function (aURI, aIsVisited) { + deferred.resolve(aIsVisited); + }); + + return deferred.promise; +} + +/** + * Creates a new Download object, setting a temporary file as the target. + * + * @param aSourceUrl + * String containing the URI for the download source, or null to use + * httpUrl("source.txt"). + * + * @return {Promise} + * @resolves The newly created Download object. + * @rejects JavaScript exception. + */ +function promiseNewDownload(aSourceUrl) { + return Downloads.createDownload({ + source: aSourceUrl || httpUrl("source.txt"), + target: getTempFile(TEST_TARGET_FILE_NAME), + }); +} + +/** + * Starts a new download using the nsIWebBrowserPersist interface, and controls + * it using the legacy nsITransfer interface. + * + * @param aSourceUrl + * String containing the URI for the download source, or null to use + * httpUrl("source.txt"). + * @param aOptions + * An optional object used to control the behavior of this function. + * You may pass an object with a subset of the following fields: + * { + * isPrivate: Boolean indicating whether the download originated from a + * private window. + * targetFile: nsIFile for the target, or null to use a temporary file. + * outPersist: Receives a reference to the created nsIWebBrowserPersist + * instance. + * launchWhenSucceeded: Boolean indicating whether the target should + * be launched when it has completed successfully. + * launcherPath: String containing the path of the custom executable to + * use to launch the target of the download. + * } + * + * @return {Promise} + * @resolves The Download object created as a consequence of controlling the + * download through the legacy nsITransfer interface. + * @rejects Never. The current test fails in case of exceptions. + */ +function promiseStartLegacyDownload(aSourceUrl, aOptions) { + let sourceURI = NetUtil.newURI(aSourceUrl || httpUrl("source.txt")); + let targetFile = (aOptions && aOptions.targetFile) + || getTempFile(TEST_TARGET_FILE_NAME); + + let persist = Cc["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"] + .createInstance(Ci.nsIWebBrowserPersist); + if (aOptions) { + aOptions.outPersist = persist; + } + + let fileExtension = null, mimeInfo = null; + let match = sourceURI.path.match(/\.([^.\/]+)$/); + if (match) { + fileExtension = match[1]; + } + + if (fileExtension) { + try { + mimeInfo = gMIMEService.getFromTypeAndExtension(null, fileExtension); + mimeInfo.preferredAction = Ci.nsIMIMEInfo.saveToDisk; + } catch (ex) { } + } + + if (aOptions && aOptions.launcherPath) { + do_check_true(mimeInfo != null); + + let localHandlerApp = Cc["@mozilla.org/uriloader/local-handler-app;1"] + .createInstance(Ci.nsILocalHandlerApp); + localHandlerApp.executable = new FileUtils.File(aOptions.launcherPath); + + mimeInfo.preferredApplicationHandler = localHandlerApp; + mimeInfo.preferredAction = Ci.nsIMIMEInfo.useHelperApp; + } + + if (aOptions && aOptions.launchWhenSucceeded) { + do_check_true(mimeInfo != null); + + mimeInfo.preferredAction = Ci.nsIMIMEInfo.useHelperApp; + } + + // Apply decoding if required by the "Content-Encoding" header. + persist.persistFlags &= ~Ci.nsIWebBrowserPersist.PERSIST_FLAGS_NO_CONVERSION; + persist.persistFlags |= + Ci.nsIWebBrowserPersist.PERSIST_FLAGS_AUTODETECT_APPLY_CONVERSION; + + let transfer = Cc["@mozilla.org/transfer;1"].createInstance(Ci.nsITransfer); + + let deferred = Promise.defer(); + + Downloads.getList(Downloads.ALL).then(function (aList) { + // Temporarily register a view that will get notified when the download we + // are controlling becomes visible in the list of downloads. + aList.addView({ + onDownloadAdded: function (aDownload) { + aList.removeView(this).then(null, do_report_unexpected_exception); + + // Remove the download to keep the list empty for the next test. This + // also allows the caller to register the "onchange" event directly. + let promise = aList.remove(aDownload); + + // When the download object is ready, make it available to the caller. + promise.then(() => deferred.resolve(aDownload), + do_report_unexpected_exception); + }, + }).then(null, do_report_unexpected_exception); + + let isPrivate = aOptions && aOptions.isPrivate; + + // Initialize the components so they reference each other. This will cause + // the Download object to be created and added to the public downloads. + transfer.init(sourceURI, NetUtil.newURI(targetFile), null, mimeInfo, null, + null, persist, isPrivate); + persist.progressListener = transfer; + + // Start the actual download process. + persist.savePrivacyAwareURI(sourceURI, null, null, 0, null, null, targetFile, + isPrivate); + }.bind(this)).then(null, do_report_unexpected_exception); + + return deferred.promise; +} + +/** + * Starts a new download using the nsIHelperAppService interface, and controls + * it using the legacy nsITransfer interface. The source of the download will + * be "interruptible_resumable.txt" and partially downloaded data will be kept. + * + * @param aSourceUrl + * String containing the URI for the download source, or null to use + * httpUrl("interruptible_resumable.txt"). + * + * @return {Promise} + * @resolves The Download object created as a consequence of controlling the + * download through the legacy nsITransfer interface. + * @rejects Never. The current test fails in case of exceptions. + */ +function promiseStartExternalHelperAppServiceDownload(aSourceUrl) { + let sourceURI = NetUtil.newURI(aSourceUrl || + httpUrl("interruptible_resumable.txt")); + + let deferred = Promise.defer(); + + Downloads.getList(Downloads.PUBLIC).then(function (aList) { + // Temporarily register a view that will get notified when the download we + // are controlling becomes visible in the list of downloads. + aList.addView({ + onDownloadAdded: function (aDownload) { + aList.removeView(this).then(null, do_report_unexpected_exception); + + // Remove the download to keep the list empty for the next test. This + // also allows the caller to register the "onchange" event directly. + let promise = aList.remove(aDownload); + + // When the download object is ready, make it available to the caller. + promise.then(() => deferred.resolve(aDownload), + do_report_unexpected_exception); + }, + }).then(null, do_report_unexpected_exception); + + let channel = NetUtil.newChannel({ + uri: sourceURI, + loadUsingSystemPrincipal: true + }); + + // Start the actual download process. + channel.asyncOpen2({ + contentListener: null, + + onStartRequest: function (aRequest, aContext) + { + let requestChannel = aRequest.QueryInterface(Ci.nsIChannel); + this.contentListener = gExternalHelperAppService.doContent( + requestChannel.contentType, aRequest, null, true); + this.contentListener.onStartRequest(aRequest, aContext); + }, + + onStopRequest: function (aRequest, aContext, aStatusCode) + { + this.contentListener.onStopRequest(aRequest, aContext, aStatusCode); + }, + + onDataAvailable: function (aRequest, aContext, aInputStream, aOffset, + aCount) + { + this.contentListener.onDataAvailable(aRequest, aContext, aInputStream, + aOffset, aCount); + }, + }); + }.bind(this)).then(null, do_report_unexpected_exception); + + return deferred.promise; +} + +/** + * Waits for a download to reach half of its progress, in case it has not + * reached the expected progress already. + * + * @param aDownload + * The Download object to wait upon. + * + * @return {Promise} + * @resolves When the download has reached half of its progress. + * @rejects Never. + */ +function promiseDownloadMidway(aDownload) { + let deferred = Promise.defer(); + + // Wait for the download to reach half of its progress. + let onchange = function () { + if (!aDownload.stopped && !aDownload.canceled && aDownload.progress == 50) { + aDownload.onchange = null; + deferred.resolve(); + } + }; + + // Register for the notification, but also call the function directly in + // case the download already reached the expected progress. + aDownload.onchange = onchange; + onchange(); + + return deferred.promise; +} + +/** + * Waits for a download to finish, in case it has not finished already. + * + * @param aDownload + * The Download object to wait upon. + * + * @return {Promise} + * @resolves When the download has finished successfully. + * @rejects JavaScript exception if the download failed. + */ +function promiseDownloadStopped(aDownload) { + if (!aDownload.stopped) { + // The download is in progress, wait for the current attempt to finish and + // report any errors that may occur. + return aDownload.start(); + } + + if (aDownload.succeeded) { + return Promise.resolve(); + } + + // The download failed or was canceled. + return Promise.reject(aDownload.error || new Error("Download canceled.")); +} + +/** + * Returns a new public or private DownloadList object. + * + * @param aIsPrivate + * True for the private list, false or undefined for the public list. + * + * @return {Promise} + * @resolves The newly created DownloadList object. + * @rejects JavaScript exception. + */ +function promiseNewList(aIsPrivate) +{ + // We need to clear all the internal state for the list and summary objects, + // since all the objects are interdependent internally. + Downloads._promiseListsInitialized = null; + Downloads._lists = {}; + Downloads._summaries = {}; + + return Downloads.getList(aIsPrivate ? Downloads.PRIVATE : Downloads.PUBLIC); +} + +/** + * Ensures that the given file contents are equal to the given string. + * + * @param aPath + * String containing the path of the file whose contents should be + * verified. + * @param aExpectedContents + * String containing the octets that are expected in the file. + * + * @return {Promise} + * @resolves When the operation completes. + * @rejects Never. + */ +function promiseVerifyContents(aPath, aExpectedContents) +{ + return Task.spawn(function* () { + let file = new FileUtils.File(aPath); + + if (!(yield OS.File.exists(aPath))) { + do_throw("File does not exist: " + aPath); + } + + if ((yield OS.File.stat(aPath)).size == 0) { + do_throw("File is empty: " + aPath); + } + + let deferred = Promise.defer(); + NetUtil.asyncFetch( + { uri: NetUtil.newURI(file), loadUsingSystemPrincipal: true }, + function(aInputStream, aStatus) { + do_check_true(Components.isSuccessCode(aStatus)); + let contents = NetUtil.readInputStreamToString(aInputStream, + aInputStream.available()); + if (contents.length > TEST_DATA_SHORT.length * 2 || + /[^\x20-\x7E]/.test(contents)) { + // Do not print the entire content string to the test log. + do_check_eq(contents.length, aExpectedContents.length); + do_check_true(contents == aExpectedContents); + } else { + // Print the string if it is short and made of printable characters. + do_check_eq(contents, aExpectedContents); + } + deferred.resolve(); + }); + + yield deferred.promise; + }); +} + +/** + * Starts a socket listener that closes each incoming connection. + * + * @returns nsIServerSocket that listens for connections. Call its "close" + * method to stop listening and free the server port. + */ +function startFakeServer() +{ + let serverSocket = new ServerSocket(-1, true, -1); + serverSocket.asyncListen({ + onSocketAccepted: function (aServ, aTransport) { + aTransport.close(Cr.NS_BINDING_ABORTED); + }, + onStopListening: function () { }, + }); + return serverSocket; +} + +/** + * This is an internal reference that should not be used directly by tests. + */ +var _gDeferResponses = Promise.defer(); + +/** + * Ensures that all the interruptible requests started after this function is + * called won't complete until the continueResponses function is called. + * + * Normally, the internal HTTP server returns all the available data as soon as + * a request is received. In order for some requests to be served one part at a + * time, special interruptible handlers are registered on the HTTP server. This + * allows testing events or actions that need to happen in the middle of a + * download. + * + * For example, the handler accessible at the httpUri("interruptible.txt") + * address returns the TEST_DATA_SHORT text, then it may block until the + * continueResponses method is called. At this point, the handler sends the + * TEST_DATA_SHORT text again to complete the response. + * + * If an interruptible request is started before the function is called, it may + * or may not be blocked depending on the actual sequence of events. + */ +function mustInterruptResponses() +{ + // If there are pending blocked requests, allow them to complete. This is + // done to prevent requests from being blocked forever, but should not affect + // the test logic, since previously started requests should not be monitored + // on the client side anymore. + _gDeferResponses.resolve(); + + do_print("Interruptible responses will be blocked midway."); + _gDeferResponses = Promise.defer(); +} + +/** + * Allows all the current and future interruptible requests to complete. + */ +function continueResponses() +{ + do_print("Interruptible responses are now allowed to continue."); + _gDeferResponses.resolve(); +} + +/** + * Registers an interruptible response handler. + * + * @param aPath + * Path passed to nsIHttpServer.registerPathHandler. + * @param aFirstPartFn + * This function is called when the response is received, with the + * aRequest and aResponse arguments of the server. + * @param aSecondPartFn + * This function is called with the aRequest and aResponse arguments of + * the server, when the continueResponses function is called. + */ +function registerInterruptibleHandler(aPath, aFirstPartFn, aSecondPartFn) +{ + gHttpServer.registerPathHandler(aPath, function (aRequest, aResponse) { + do_print("Interruptible request started."); + + // Process the first part of the response. + aResponse.processAsync(); + aFirstPartFn(aRequest, aResponse); + + // Wait on the current deferred object, then finish the request. + _gDeferResponses.promise.then(function RIH_onSuccess() { + aSecondPartFn(aRequest, aResponse); + aResponse.finish(); + do_print("Interruptible request finished."); + }).then(null, Cu.reportError); + }); +} + +/** + * Ensure the given date object is valid. + * + * @param aDate + * The date object to be checked. This value can be null. + */ +function isValidDate(aDate) { + return aDate && aDate.getTime && !isNaN(aDate.getTime()); +} + +/** + * Position of the first byte served by the "interruptible_resumable.txt" + * handler during the most recent response. + */ +var gMostRecentFirstBytePos; + +// Initialization functions common to all tests + +add_task(function test_common_initialize() +{ + // Start the HTTP server. + gHttpServer = new HttpServer(); + gHttpServer.registerDirectory("/", do_get_file("../data")); + gHttpServer.start(-1); + do_register_cleanup(() => { + return new Promise(resolve => { + // Ensure all the pending HTTP requests have a chance to finish. + continueResponses(); + // Stop the HTTP server, calling resolve when it's done. + gHttpServer.stop(resolve); + }); + }); + + // Cache locks might prevent concurrent requests to the same resource, and + // this may block tests that use the interruptible handlers. + Services.prefs.setBoolPref("browser.cache.disk.enable", false); + Services.prefs.setBoolPref("browser.cache.memory.enable", false); + do_register_cleanup(function () { + Services.prefs.clearUserPref("browser.cache.disk.enable"); + Services.prefs.clearUserPref("browser.cache.memory.enable"); + }); + + registerInterruptibleHandler("/interruptible.txt", + function firstPart(aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + aResponse.setHeader("Content-Length", "" + (TEST_DATA_SHORT.length * 2), + false); + aResponse.write(TEST_DATA_SHORT); + }, function secondPart(aRequest, aResponse) { + aResponse.write(TEST_DATA_SHORT); + }); + + registerInterruptibleHandler("/interruptible_resumable.txt", + function firstPart(aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + + // Determine if only part of the data should be sent. + let data = TEST_DATA_SHORT + TEST_DATA_SHORT; + if (aRequest.hasHeader("Range")) { + var matches = aRequest.getHeader("Range") + .match(/^\s*bytes=(\d+)?-(\d+)?\s*$/); + var firstBytePos = (matches[1] === undefined) ? 0 : matches[1]; + var lastBytePos = (matches[2] === undefined) ? data.length - 1 + : matches[2]; + if (firstBytePos >= data.length) { + aResponse.setStatusLine(aRequest.httpVersion, 416, + "Requested Range Not Satisfiable"); + aResponse.setHeader("Content-Range", "*/" + data.length, false); + aResponse.finish(); + return; + } + + aResponse.setStatusLine(aRequest.httpVersion, 206, "Partial Content"); + aResponse.setHeader("Content-Range", firstBytePos + "-" + + lastBytePos + "/" + + data.length, false); + + data = data.substring(firstBytePos, lastBytePos + 1); + + gMostRecentFirstBytePos = firstBytePos; + } else { + gMostRecentFirstBytePos = 0; + } + + aResponse.setHeader("Content-Length", "" + data.length, false); + + aResponse.write(data.substring(0, data.length / 2)); + + // Store the second part of the data on the response object, so that it + // can be used by the secondPart function. + aResponse.secondPartData = data.substring(data.length / 2); + }, function secondPart(aRequest, aResponse) { + aResponse.write(aResponse.secondPartData); + }); + + registerInterruptibleHandler("/interruptible_gzip.txt", + function firstPart(aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + aResponse.setHeader("Content-Encoding", "gzip", false); + aResponse.setHeader("Content-Length", "" + TEST_DATA_SHORT_GZIP_ENCODED.length); + + let bos = new BinaryOutputStream(aResponse.bodyOutputStream); + bos.writeByteArray(TEST_DATA_SHORT_GZIP_ENCODED_FIRST, + TEST_DATA_SHORT_GZIP_ENCODED_FIRST.length); + }, function secondPart(aRequest, aResponse) { + let bos = new BinaryOutputStream(aResponse.bodyOutputStream); + bos.writeByteArray(TEST_DATA_SHORT_GZIP_ENCODED_SECOND, + TEST_DATA_SHORT_GZIP_ENCODED_SECOND.length); + }); + + gHttpServer.registerPathHandler("/shorter-than-content-length-http-1-1.txt", + function (aRequest, aResponse) { + aResponse.processAsync(); + aResponse.setStatusLine("1.1", 200, "OK"); + aResponse.setHeader("Content-Type", "text/plain", false); + aResponse.setHeader("Content-Length", "" + (TEST_DATA_SHORT.length * 2), + false); + aResponse.write(TEST_DATA_SHORT); + aResponse.finish(); + }); + + // This URL will emulate being blocked by Windows Parental controls + gHttpServer.registerPathHandler("/parentalblocked.zip", + function (aRequest, aResponse) { + aResponse.setStatusLine(aRequest.httpVersion, 450, + "Blocked by Windows Parental Controls"); + }); + + // During unit tests, most of the functions that require profile access or + // operating system features will be disabled. Individual tests may override + // them again to check for specific behaviors. + Integration.downloads.register(base => ({ + __proto__: base, + loadPublicDownloadListFromStore: () => Promise.resolve(), + shouldKeepBlockedData: () => Promise.resolve(false), + shouldBlockForParentalControls: () => Promise.resolve(false), + shouldBlockForRuntimePermissions: () => Promise.resolve(false), + shouldBlockForReputationCheck: () => Promise.resolve({ + shouldBlock: false, + verdict: "", + }), + confirmLaunchExecutable: () => Promise.resolve(), + launchFile: () => Promise.resolve(), + showContainingDirectory: () => Promise.resolve(), + // This flag allows re-enabling the default observers during their tests. + allowObservers: false, + addListObservers() { + return this.allowObservers ? super.addListObservers(...arguments) + : Promise.resolve(); + }, + // This flag allows re-enabling the download directory logic for its tests. + _allowDirectories: false, + set allowDirectories(value) { + this._allowDirectories = value; + // We have to invalidate the previously computed directory path. + this._downloadsDirectory = null; + }, + _getDirectory(name) { + return super._getDirectory(this._allowDirectories ? name : "TmpD"); + }, + })); + + // Make sure that downloads started using nsIExternalHelperAppService are + // saved to disk without asking for a destination interactively. + let mock = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIHelperAppLauncherDialog]), + promptForSaveToFileAsync(aLauncher, + aWindowContext, + aDefaultFileName, + aSuggestedFileExtension, + aForcePrompt) { + // The dialog should create the empty placeholder file. + let file = getTempFile(TEST_TARGET_FILE_NAME); + file.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + aLauncher.saveDestinationAvailable(file); + }, + }; + + let cid = MockRegistrar.register("@mozilla.org/helperapplauncherdialog;1", mock); + do_register_cleanup(() => { + MockRegistrar.unregister(cid); + }); +}); diff --git a/toolkit/components/jsdownloads/test/unit/test_DownloadCore.js b/toolkit/components/jsdownloads/test/unit/test_DownloadCore.js new file mode 100644 index 000000000..6e32c63d3 --- /dev/null +++ b/toolkit/components/jsdownloads/test/unit/test_DownloadCore.js @@ -0,0 +1,87 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the main download interfaces using DownloadCopySaver. + */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "DownloadError", + "resource://gre/modules/DownloadCore.jsm"); + +// Execution of common tests + +var gUseLegacySaver = false; + +var scriptFile = do_get_file("common_test_Download.js"); +Services.scriptloader.loadSubScript(NetUtil.newURI(scriptFile).spec); + +// Tests + +/** + * Tests the DownloadError object. + */ +add_task(function test_DownloadError() +{ + let error = new DownloadError({ result: Cr.NS_ERROR_NOT_RESUMABLE, + message: "Not resumable."}); + do_check_eq(error.result, Cr.NS_ERROR_NOT_RESUMABLE); + do_check_eq(error.message, "Not resumable."); + do_check_false(error.becauseSourceFailed); + do_check_false(error.becauseTargetFailed); + do_check_false(error.becauseBlocked); + do_check_false(error.becauseBlockedByParentalControls); + + error = new DownloadError({ message: "Unknown error."}); + do_check_eq(error.result, Cr.NS_ERROR_FAILURE); + do_check_eq(error.message, "Unknown error."); + + error = new DownloadError({ result: Cr.NS_ERROR_NOT_RESUMABLE }); + do_check_eq(error.result, Cr.NS_ERROR_NOT_RESUMABLE); + do_check_true(error.message.indexOf("Exception") > 0); + + // becauseSourceFailed will be set, but not the unknown property. + error = new DownloadError({ message: "Unknown error.", + becauseSourceFailed: true, + becauseUnknown: true }); + do_check_true(error.becauseSourceFailed); + do_check_false("becauseUnknown" in error); + + error = new DownloadError({ result: Cr.NS_ERROR_MALFORMED_URI, + inferCause: true }); + do_check_eq(error.result, Cr.NS_ERROR_MALFORMED_URI); + do_check_true(error.becauseSourceFailed); + do_check_false(error.becauseTargetFailed); + do_check_false(error.becauseBlocked); + do_check_false(error.becauseBlockedByParentalControls); + + // This test does not set inferCause, so becauseSourceFailed will not be set. + error = new DownloadError({ result: Cr.NS_ERROR_MALFORMED_URI }); + do_check_eq(error.result, Cr.NS_ERROR_MALFORMED_URI); + do_check_false(error.becauseSourceFailed); + + error = new DownloadError({ result: Cr.NS_ERROR_FILE_INVALID_PATH, + inferCause: true }); + do_check_eq(error.result, Cr.NS_ERROR_FILE_INVALID_PATH); + do_check_false(error.becauseSourceFailed); + do_check_true(error.becauseTargetFailed); + do_check_false(error.becauseBlocked); + do_check_false(error.becauseBlockedByParentalControls); + + error = new DownloadError({ becauseBlocked: true }); + do_check_eq(error.message, "Download blocked."); + do_check_false(error.becauseSourceFailed); + do_check_false(error.becauseTargetFailed); + do_check_true(error.becauseBlocked); + do_check_false(error.becauseBlockedByParentalControls); + + error = new DownloadError({ becauseBlockedByParentalControls: true }); + do_check_eq(error.message, "Download blocked."); + do_check_false(error.becauseSourceFailed); + do_check_false(error.becauseTargetFailed); + do_check_true(error.becauseBlocked); + do_check_true(error.becauseBlockedByParentalControls); +}); diff --git a/toolkit/components/jsdownloads/test/unit/test_DownloadImport.js b/toolkit/components/jsdownloads/test/unit/test_DownloadImport.js new file mode 100644 index 000000000..388870f00 --- /dev/null +++ b/toolkit/components/jsdownloads/test/unit/test_DownloadImport.js @@ -0,0 +1,701 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the DownloadImport object. + */ + +"use strict"; + +// Globals + +XPCOMUtils.defineLazyModuleGetter(this, "Sqlite", + "resource://gre/modules/Sqlite.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "DownloadImport", + "resource://gre/modules/DownloadImport.jsm"); + +// Importable states +const DOWNLOAD_NOTSTARTED = -1; +const DOWNLOAD_DOWNLOADING = 0; +const DOWNLOAD_PAUSED = 4; +const DOWNLOAD_QUEUED = 5; + +// Non importable states +const DOWNLOAD_FAILED = 2; +const DOWNLOAD_CANCELED = 3; +const DOWNLOAD_BLOCKED_PARENTAL = 6; +const DOWNLOAD_SCANNING = 7; +const DOWNLOAD_DIRTY = 8; +const DOWNLOAD_BLOCKED_POLICY = 9; + +// The TEST_DATA_TAINTED const is a version of TEST_DATA_SHORT in which the +// beginning of the data was changed (with the TEST_DATA_REPLACEMENT value). +// We use this to test that the entityID is properly imported and the download +// can be resumed from where it was paused. +// For simplification purposes, the test requires that TEST_DATA_SHORT and +// TEST_DATA_TAINTED have the same length. +const TEST_DATA_REPLACEMENT = "-changed- "; +const TEST_DATA_TAINTED = TEST_DATA_REPLACEMENT + + TEST_DATA_SHORT.substr(TEST_DATA_REPLACEMENT.length); +const TEST_DATA_LENGTH = TEST_DATA_SHORT.length; + +// The length of the partial file that we'll write to disk as an existing +// ongoing download. +const TEST_DATA_PARTIAL_LENGTH = TEST_DATA_REPLACEMENT.length; + +// The value of the "maxBytes" column stored in the DB about the downloads. +// It's intentionally different than TEST_DATA_LENGTH to test that each value +// is seen when expected. +const MAXBYTES_IN_DB = TEST_DATA_LENGTH - 10; + +var gDownloadsRowToImport; +var gDownloadsRowNonImportable; + +/** + * Creates a database with an empty moz_downloads table and leaves an + * open connection to it. + * + * @param aPath + * String containing the path of the database file to be created. + * @param aSchemaVersion + * Number with the version of the database schema to set. + * + * @return {Promise} + * @resolves The open connection to the database. + * @rejects If an error occurred during the database creation. + */ +function promiseEmptyDatabaseConnection({aPath, aSchemaVersion}) { + return Task.spawn(function* () { + let connection = yield Sqlite.openConnection({ path: aPath }); + + yield connection.execute("CREATE TABLE moz_downloads (" + + "id INTEGER PRIMARY KEY," + + "name TEXT," + + "source TEXT," + + "target TEXT," + + "tempPath TEXT," + + "startTime INTEGER," + + "endTime INTEGER," + + "state INTEGER," + + "referrer TEXT," + + "entityID TEXT," + + "currBytes INTEGER NOT NULL DEFAULT 0," + + "maxBytes INTEGER NOT NULL DEFAULT -1," + + "mimeType TEXT," + + "preferredApplication TEXT," + + "preferredAction INTEGER NOT NULL DEFAULT 0," + + "autoResume INTEGER NOT NULL DEFAULT 0," + + "guid TEXT)"); + + yield connection.setSchemaVersion(aSchemaVersion); + + return connection; + }); +} + +/** + * Inserts a new entry in the database with the given columns' values. + * + * @param aConnection + * The database connection. + * @param aDownloadRow + * An object representing the values for each column of the row + * being inserted. + * + * @return {Promise} + * @resolves When the operation completes. + * @rejects If there's an error inserting the row. + */ +function promiseInsertRow(aConnection, aDownloadRow) { + // We can't use the aDownloadRow obj directly in the execute statement + // because the obj bind code in Sqlite.jsm doesn't allow objects + // with extra properties beyond those being binded. So we might as well + // use an array as it is simpler. + let values = [ + aDownloadRow.source, aDownloadRow.target, aDownloadRow.tempPath, + aDownloadRow.startTime.getTime() * 1000, aDownloadRow.state, + aDownloadRow.referrer, aDownloadRow.entityID, aDownloadRow.maxBytes, + aDownloadRow.mimeType, aDownloadRow.preferredApplication, + aDownloadRow.preferredAction, aDownloadRow.autoResume + ]; + + return aConnection.execute("INSERT INTO moz_downloads (" + + "name, source, target, tempPath, startTime," + + "endTime, state, referrer, entityID, currBytes," + + "maxBytes, mimeType, preferredApplication," + + "preferredAction, autoResume, guid)" + + "VALUES (" + + "'', ?, ?, ?, ?, " // name, + + "0, ?, ?, ?, 0, " // endTime, currBytes + + " ?, ?, ?, " // + + " ?, ?, '')", // and guid are not imported + values); +} + +/** + * Retrieves the number of rows in the moz_downloads table of the + * database. + * + * @param aConnection + * The database connection. + * + * @return {Promise} + * @resolves With the number of rows. + * @rejects Never. + */ +function promiseTableCount(aConnection) { + return aConnection.execute("SELECT COUNT(*) FROM moz_downloads") + .then(res => res[0].getResultByName("COUNT(*)")) + .then(null, Cu.reportError); +} + +/** + * Briefly opens a network channel to a given URL to retrieve + * the entityID of this url, as generated by the network code. + * + * @param aUrl + * The URL to retrieve the entityID. + * + * @return {Promise} + * @resolves The EntityID of the given URL. + * @rejects When there's a problem accessing the URL. + */ +function promiseEntityID(aUrl) { + let deferred = Promise.defer(); + let entityID = ""; + let channel = NetUtil.newChannel({ + uri: NetUtil.newURI(aUrl), + loadUsingSystemPrincipal: true + }); + + channel.asyncOpen2({ + onStartRequest: function (aRequest) { + if (aRequest instanceof Ci.nsIResumableChannel) { + entityID = aRequest.entityID; + } + aRequest.cancel(Cr.NS_BINDING_ABORTED); + }, + + onStopRequest: function (aRequest, aContext, aStatusCode) { + if (aStatusCode == Cr.NS_BINDING_ABORTED) { + deferred.resolve(entityID); + } else { + deferred.reject("Unexpected status code received"); + } + }, + + onDataAvailable: function () {} + }); + + return deferred.promise; +} + +/** + * Gets a file path to a temporary writeable download target, in the + * correct format as expected to be stored in the downloads database, + * which is file:///absolute/path/to/file + * + * @param aLeafName + * A hint leaf name for the file. + * + * @return String The path to the download target. + */ +function getDownloadTarget(aLeafName) { + return NetUtil.newURI(getTempFile(aLeafName)).spec; +} + +/** + * Generates a temporary partial file to use as an in-progress + * download. The file is written to disk with a part of the total expected + * download content pre-written. + * + * @param aLeafName + * A hint leaf name for the file. + * @param aTainted + * A boolean value. When true, the partial content of the file + * will be different from the expected content of the original source + * file. See the declaration of TEST_DATA_TAINTED for more information. + * + * @return {Promise} + * @resolves When the operation completes, and returns a string with the path + * to the generated file. + * @rejects If there's an error writing the file. + */ +function getPartialFile(aLeafName, aTainted = false) { + let tempDownload = getTempFile(aLeafName); + let partialContent = aTainted + ? TEST_DATA_TAINTED.substr(0, TEST_DATA_PARTIAL_LENGTH) + : TEST_DATA_SHORT.substr(0, TEST_DATA_PARTIAL_LENGTH); + + return OS.File.writeAtomic(tempDownload.path, partialContent, + { tmpPath: tempDownload.path + ".tmp", + flush: true }) + .then(() => tempDownload.path); +} + +/** + * Generates a Date object to be used as the startTime for the download rows + * in the DB. A date that is obviously different from the current time is + * generated to make sure this stored data and a `new Date()` can't collide. + * + * @param aOffset + * A offset from the base generated date is used to differentiate each + * row in the database. + * + * @return A Date object. + */ +function getStartTime(aOffset) { + return new Date(1000000 + (aOffset * 10000)); +} + +/** + * Performs various checks on an imported Download object to make sure + * all properties are properly set as expected from the import procedure. + * + * @param aDownload + * The Download object to be checked. + * @param aDownloadRow + * An object that represents a row from the original database table, + * with extra properties describing expected values that are not + * explictly part of the database. + * + * @return {Promise} + * @resolves When the operation completes + * @rejects Never + */ +function checkDownload(aDownload, aDownloadRow) { + return Task.spawn(function*() { + do_check_eq(aDownload.source.url, aDownloadRow.source); + do_check_eq(aDownload.source.referrer, aDownloadRow.referrer); + + do_check_eq(aDownload.target.path, + NetUtil.newURI(aDownloadRow.target) + .QueryInterface(Ci.nsIFileURL).file.path); + + do_check_eq(aDownload.target.partFilePath, aDownloadRow.tempPath); + + if (aDownloadRow.expectedResume) { + do_check_true(!aDownload.stopped || aDownload.succeeded); + yield promiseDownloadStopped(aDownload); + + do_check_true(aDownload.succeeded); + do_check_eq(aDownload.progress, 100); + // If the download has resumed, a new startTime will be set. + // By calling toJSON we're also testing that startTime is a Date object. + do_check_neq(aDownload.startTime.toJSON(), + aDownloadRow.startTime.toJSON()); + } else { + do_check_false(aDownload.succeeded); + do_check_eq(aDownload.startTime.toJSON(), + aDownloadRow.startTime.toJSON()); + } + + do_check_eq(aDownload.stopped, true); + + let serializedSaver = aDownload.saver.toSerializable(); + if (typeof(serializedSaver) == "object") { + do_check_eq(serializedSaver.type, "copy"); + } else { + do_check_eq(serializedSaver, "copy"); + } + + if (aDownloadRow.entityID) { + do_check_eq(aDownload.saver.entityID, aDownloadRow.entityID); + } + + do_check_eq(aDownload.currentBytes, aDownloadRow.expectedCurrentBytes); + do_check_eq(aDownload.totalBytes, aDownloadRow.expectedTotalBytes); + + if (aDownloadRow.expectedContent) { + let fileToCheck = aDownloadRow.expectedResume + ? aDownload.target.path + : aDownload.target.partFilePath; + yield promiseVerifyContents(fileToCheck, aDownloadRow.expectedContent); + } + + do_check_eq(aDownload.contentType, aDownloadRow.expectedContentType); + do_check_eq(aDownload.launcherPath, aDownloadRow.preferredApplication); + + do_check_eq(aDownload.launchWhenSucceeded, + aDownloadRow.preferredAction != Ci.nsIMIMEInfo.saveToDisk); + }); +} + +// Preparation tasks + +/** + * Prepares the list of downloads to be added to the database that should + * be imported by the import procedure. + */ +add_task(function* prepareDownloadsToImport() { + + let sourceUrl = httpUrl("source.txt"); + let sourceEntityId = yield promiseEntityID(sourceUrl); + + gDownloadsRowToImport = [ + // Paused download with autoResume and a partial file. By + // setting the correct entityID the download can resume from + // where it stopped, and to test that this works properly we + // intentionally set different data in the beginning of the + // partial file to make sure it was not replaced. + { + source: sourceUrl, + target: getDownloadTarget("inprogress1.txt"), + tempPath: yield getPartialFile("inprogress1.txt.part", true), + startTime: getStartTime(1), + state: DOWNLOAD_PAUSED, + referrer: httpUrl("referrer1"), + entityID: sourceEntityId, + maxBytes: MAXBYTES_IN_DB, + mimeType: "mimeType1", + preferredAction: Ci.nsIMIMEInfo.saveToDisk, + preferredApplication: "prerredApplication1", + autoResume: 1, + + // Even though the information stored in the DB said + // maxBytes was MAXBYTES_IN_DB, the download turned out to be + // a different length. Here we make sure the totalBytes property + // was correctly set with the actual value. The same consideration + // applies to the contentType. + expectedCurrentBytes: TEST_DATA_LENGTH, + expectedTotalBytes: TEST_DATA_LENGTH, + expectedResume: true, + expectedContentType: "text/plain", + expectedContent: TEST_DATA_TAINTED, + }, + + // Paused download with autoResume and a partial file, + // but missing entityID. This means that the download will + // start from beginning, and the entire original content of the + // source file should replace the different data that was stored + // in the partial file. + { + source: sourceUrl, + target: getDownloadTarget("inprogress2.txt"), + tempPath: yield getPartialFile("inprogress2.txt.part", true), + startTime: getStartTime(2), + state: DOWNLOAD_PAUSED, + referrer: httpUrl("referrer2"), + entityID: "", + maxBytes: MAXBYTES_IN_DB, + mimeType: "mimeType2", + preferredAction: Ci.nsIMIMEInfo.saveToDisk, + preferredApplication: "prerredApplication2", + autoResume: 1, + + expectedCurrentBytes: TEST_DATA_LENGTH, + expectedTotalBytes: TEST_DATA_LENGTH, + expectedResume: true, + expectedContentType: "text/plain", + expectedContent: TEST_DATA_SHORT + }, + + // Paused download with no autoResume and a partial file. + { + source: sourceUrl, + target: getDownloadTarget("inprogress3.txt"), + tempPath: yield getPartialFile("inprogress3.txt.part"), + startTime: getStartTime(3), + state: DOWNLOAD_PAUSED, + referrer: httpUrl("referrer3"), + entityID: "", + maxBytes: MAXBYTES_IN_DB, + mimeType: "mimeType3", + preferredAction: Ci.nsIMIMEInfo.saveToDisk, + preferredApplication: "prerredApplication3", + autoResume: 0, + + // Since this download has not been resumed, the actual data + // about its total size and content type is not known. + // Therefore, we're going by the information imported from the DB. + expectedCurrentBytes: TEST_DATA_PARTIAL_LENGTH, + expectedTotalBytes: MAXBYTES_IN_DB, + expectedResume: false, + expectedContentType: "mimeType3", + expectedContent: TEST_DATA_SHORT.substr(0, TEST_DATA_PARTIAL_LENGTH), + }, + + // Paused download with autoResume and no partial file. + { + source: sourceUrl, + target: getDownloadTarget("inprogress4.txt"), + tempPath: "", + startTime: getStartTime(4), + state: DOWNLOAD_PAUSED, + referrer: httpUrl("referrer4"), + entityID: "", + maxBytes: MAXBYTES_IN_DB, + mimeType: "text/plain", + preferredAction: Ci.nsIMIMEInfo.useHelperApp, + preferredApplication: "prerredApplication4", + autoResume: 1, + + expectedCurrentBytes: TEST_DATA_LENGTH, + expectedTotalBytes: TEST_DATA_LENGTH, + expectedResume: true, + expectedContentType: "text/plain", + expectedContent: TEST_DATA_SHORT + }, + + // Paused download with no autoResume and no partial file. + { + source: sourceUrl, + target: getDownloadTarget("inprogress5.txt"), + tempPath: "", + startTime: getStartTime(5), + state: DOWNLOAD_PAUSED, + referrer: httpUrl("referrer4"), + entityID: "", + maxBytes: MAXBYTES_IN_DB, + mimeType: "text/plain", + preferredAction: Ci.nsIMIMEInfo.useSystemDefault, + preferredApplication: "prerredApplication5", + autoResume: 0, + + expectedCurrentBytes: 0, + expectedTotalBytes: MAXBYTES_IN_DB, + expectedResume: false, + expectedContentType: "text/plain", + }, + + // Queued download with no autoResume and no partial file. + // Even though autoResume=0, queued downloads always autoResume. + { + source: sourceUrl, + target: getDownloadTarget("inprogress6.txt"), + tempPath: "", + startTime: getStartTime(6), + state: DOWNLOAD_QUEUED, + referrer: httpUrl("referrer6"), + entityID: "", + maxBytes: MAXBYTES_IN_DB, + mimeType: "text/plain", + preferredAction: Ci.nsIMIMEInfo.useHelperApp, + preferredApplication: "prerredApplication6", + autoResume: 0, + + expectedCurrentBytes: TEST_DATA_LENGTH, + expectedTotalBytes: TEST_DATA_LENGTH, + expectedResume: true, + expectedContentType: "text/plain", + expectedContent: TEST_DATA_SHORT + }, + + // Notstarted download with no autoResume and no partial file. + // Even though autoResume=0, notstarted downloads always autoResume. + { + source: sourceUrl, + target: getDownloadTarget("inprogress7.txt"), + tempPath: "", + startTime: getStartTime(7), + state: DOWNLOAD_NOTSTARTED, + referrer: httpUrl("referrer7"), + entityID: "", + maxBytes: MAXBYTES_IN_DB, + mimeType: "text/plain", + preferredAction: Ci.nsIMIMEInfo.useHelperApp, + preferredApplication: "prerredApplication7", + autoResume: 0, + + expectedCurrentBytes: TEST_DATA_LENGTH, + expectedTotalBytes: TEST_DATA_LENGTH, + expectedResume: true, + expectedContentType: "text/plain", + expectedContent: TEST_DATA_SHORT + }, + + // Downloading download with no autoResume and a partial file. + // Even though autoResume=0, downloading downloads always autoResume. + { + source: sourceUrl, + target: getDownloadTarget("inprogress8.txt"), + tempPath: yield getPartialFile("inprogress8.txt.part", true), + startTime: getStartTime(8), + state: DOWNLOAD_DOWNLOADING, + referrer: httpUrl("referrer8"), + entityID: sourceEntityId, + maxBytes: MAXBYTES_IN_DB, + mimeType: "text/plain", + preferredAction: Ci.nsIMIMEInfo.saveToDisk, + preferredApplication: "prerredApplication8", + autoResume: 0, + + expectedCurrentBytes: TEST_DATA_LENGTH, + expectedTotalBytes: TEST_DATA_LENGTH, + expectedResume: true, + expectedContentType: "text/plain", + expectedContent: TEST_DATA_TAINTED + }, + ]; +}); + +/** + * Prepares the list of downloads to be added to the database that should + * *not* be imported by the import procedure. + */ +add_task(function* prepareNonImportableDownloads() +{ + gDownloadsRowNonImportable = [ + // Download with no source (should never happen in normal circumstances). + { + source: "", + target: "nonimportable1.txt", + tempPath: "", + startTime: getStartTime(1), + state: DOWNLOAD_PAUSED, + referrer: "", + entityID: "", + maxBytes: MAXBYTES_IN_DB, + mimeType: "mimeType1", + preferredAction: Ci.nsIMIMEInfo.saveToDisk, + preferredApplication: "prerredApplication1", + autoResume: 1 + }, + + // state = DOWNLOAD_FAILED + { + source: httpUrl("source.txt"), + target: "nonimportable2.txt", + tempPath: "", + startTime: getStartTime(2), + state: DOWNLOAD_FAILED, + referrer: "", + entityID: "", + maxBytes: MAXBYTES_IN_DB, + mimeType: "mimeType2", + preferredAction: Ci.nsIMIMEInfo.saveToDisk, + preferredApplication: "prerredApplication2", + autoResume: 1 + }, + + // state = DOWNLOAD_CANCELED + { + source: httpUrl("source.txt"), + target: "nonimportable3.txt", + tempPath: "", + startTime: getStartTime(3), + state: DOWNLOAD_CANCELED, + referrer: "", + entityID: "", + maxBytes: MAXBYTES_IN_DB, + mimeType: "mimeType3", + preferredAction: Ci.nsIMIMEInfo.saveToDisk, + preferredApplication: "prerredApplication3", + autoResume: 1 + }, + + // state = DOWNLOAD_BLOCKED_PARENTAL + { + source: httpUrl("source.txt"), + target: "nonimportable4.txt", + tempPath: "", + startTime: getStartTime(4), + state: DOWNLOAD_BLOCKED_PARENTAL, + referrer: "", + entityID: "", + maxBytes: MAXBYTES_IN_DB, + mimeType: "mimeType4", + preferredAction: Ci.nsIMIMEInfo.saveToDisk, + preferredApplication: "prerredApplication4", + autoResume: 1 + }, + + // state = DOWNLOAD_SCANNING + { + source: httpUrl("source.txt"), + target: "nonimportable5.txt", + tempPath: "", + startTime: getStartTime(5), + state: DOWNLOAD_SCANNING, + referrer: "", + entityID: "", + maxBytes: MAXBYTES_IN_DB, + mimeType: "mimeType5", + preferredAction: Ci.nsIMIMEInfo.saveToDisk, + preferredApplication: "prerredApplication5", + autoResume: 1 + }, + + // state = DOWNLOAD_DIRTY + { + source: httpUrl("source.txt"), + target: "nonimportable6.txt", + tempPath: "", + startTime: getStartTime(6), + state: DOWNLOAD_DIRTY, + referrer: "", + entityID: "", + maxBytes: MAXBYTES_IN_DB, + mimeType: "mimeType6", + preferredAction: Ci.nsIMIMEInfo.saveToDisk, + preferredApplication: "prerredApplication6", + autoResume: 1 + }, + + // state = DOWNLOAD_BLOCKED_POLICY + { + source: httpUrl("source.txt"), + target: "nonimportable7.txt", + tempPath: "", + startTime: getStartTime(7), + state: DOWNLOAD_BLOCKED_POLICY, + referrer: "", + entityID: "", + maxBytes: MAXBYTES_IN_DB, + mimeType: "mimeType7", + preferredAction: Ci.nsIMIMEInfo.saveToDisk, + preferredApplication: "prerredApplication7", + autoResume: 1 + }, + ]; +}); + +// Test + +/** + * Creates a temporary Sqlite database with download data and perform an + * import of that data to the new Downloads API to verify that the import + * worked correctly. + */ +add_task(function* test_downloadImport() +{ + let connection = null; + let downloadsSqlite = getTempFile("downloads.sqlite").path; + + try { + // Set up the database. + connection = yield promiseEmptyDatabaseConnection({ + aPath: downloadsSqlite, + aSchemaVersion: 9 + }); + + // Insert both the importable and non-importable + // downloads together. + for (let downloadRow of gDownloadsRowToImport) { + yield promiseInsertRow(connection, downloadRow); + } + + for (let downloadRow of gDownloadsRowNonImportable) { + yield promiseInsertRow(connection, downloadRow); + } + + // Check that every item was inserted. + do_check_eq((yield promiseTableCount(connection)), + gDownloadsRowToImport.length + + gDownloadsRowNonImportable.length); + } finally { + // Close the connection so that DownloadImport can open it. + yield connection.close(); + } + + // Import items. + let list = yield promiseNewList(false); + yield new DownloadImport(list, downloadsSqlite).import(); + let items = yield list.getAll(); + + do_check_eq(items.length, gDownloadsRowToImport.length); + + for (let i = 0; i < gDownloadsRowToImport.length; i++) { + yield checkDownload(items[i], gDownloadsRowToImport[i]); + } +}) diff --git a/toolkit/components/jsdownloads/test/unit/test_DownloadIntegration.js b/toolkit/components/jsdownloads/test/unit/test_DownloadIntegration.js new file mode 100644 index 000000000..31dd7c7a4 --- /dev/null +++ b/toolkit/components/jsdownloads/test/unit/test_DownloadIntegration.js @@ -0,0 +1,432 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the DownloadIntegration object. + */ + +"use strict"; + +// Globals + +/** + * Notifies the prompt observers and verify the expected downloads count. + * + * @param aIsPrivate + * Flag to know is test private observers. + * @param aExpectedCount + * the expected downloads count for quit and offline observers. + * @param aExpectedPBCount + * the expected downloads count for private browsing observer. + */ +function notifyPromptObservers(aIsPrivate, aExpectedCount, aExpectedPBCount) { + let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"]. + createInstance(Ci.nsISupportsPRBool); + + // Notify quit application requested observer. + DownloadIntegration._testPromptDownloads = -1; + Services.obs.notifyObservers(cancelQuit, "quit-application-requested", null); + do_check_eq(DownloadIntegration._testPromptDownloads, aExpectedCount); + + // Notify offline requested observer. + DownloadIntegration._testPromptDownloads = -1; + Services.obs.notifyObservers(cancelQuit, "offline-requested", null); + do_check_eq(DownloadIntegration._testPromptDownloads, aExpectedCount); + + if (aIsPrivate) { + // Notify last private browsing requested observer. + DownloadIntegration._testPromptDownloads = -1; + Services.obs.notifyObservers(cancelQuit, "last-pb-context-exiting", null); + do_check_eq(DownloadIntegration._testPromptDownloads, aExpectedPBCount); + } + + delete DownloadIntegration._testPromptDownloads; +} + +// Tests + +/** + * Allows re-enabling the real download directory logic during one test. + */ +function allowDirectoriesInTest() { + DownloadIntegration.allowDirectories = true; + function cleanup() { + DownloadIntegration.allowDirectories = false; + } + do_register_cleanup(cleanup); + return cleanup; +} + +XPCOMUtils.defineLazyGetter(this, "gStringBundle", function() { + return Services.strings. + createBundle("chrome://mozapps/locale/downloads/downloads.properties"); +}); + +/** + * Tests that getSystemDownloadsDirectory returns an existing directory or + * creates a new directory depending on the platform. Instead of the real + * directory, this test is executed in the temporary directory so we can safely + * delete the created folder to check whether it is created again. + */ +add_task(function* test_getSystemDownloadsDirectory_exists_or_creates() +{ + let tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + let downloadDir; + + // OSX / Linux / Windows but not XP/2k + if (Services.appinfo.OS == "Darwin" || + Services.appinfo.OS == "Linux" || + (Services.appinfo.OS == "WINNT" && + parseFloat(Services.sysinfo.getProperty("version")) >= 6)) { + downloadDir = yield DownloadIntegration.getSystemDownloadsDirectory(); + do_check_eq(downloadDir, tempDir.path); + do_check_true(yield OS.File.exists(downloadDir)); + + let info = yield OS.File.stat(downloadDir); + do_check_true(info.isDir); + } else { + let targetPath = OS.Path.join(tempDir.path, + gStringBundle.GetStringFromName("downloadsFolder")); + try { + yield OS.File.removeEmptyDir(targetPath); + } catch (e) {} + downloadDir = yield DownloadIntegration.getSystemDownloadsDirectory(); + do_check_eq(downloadDir, targetPath); + do_check_true(yield OS.File.exists(downloadDir)); + + let info = yield OS.File.stat(downloadDir); + do_check_true(info.isDir); + yield OS.File.removeEmptyDir(targetPath); + } +}); + +/** + * Tests that the real directory returned by getSystemDownloadsDirectory is not + * the one that is used during unit tests. Since this is the actual downloads + * directory of the operating system, we don't try to delete it afterwards. + */ +add_task(function* test_getSystemDownloadsDirectory_real() +{ + let fakeDownloadDir = yield DownloadIntegration.getSystemDownloadsDirectory(); + + let cleanup = allowDirectoriesInTest(); + let realDownloadDir = yield DownloadIntegration.getSystemDownloadsDirectory(); + cleanup(); + + do_check_neq(fakeDownloadDir, realDownloadDir); +}); + +/** + * Tests that the getPreferredDownloadsDirectory returns a valid download + * directory string path. + */ +add_task(function* test_getPreferredDownloadsDirectory() +{ + let cleanupDirectories = allowDirectoriesInTest(); + + let folderListPrefName = "browser.download.folderList"; + let dirPrefName = "browser.download.dir"; + function cleanupPrefs() { + Services.prefs.clearUserPref(folderListPrefName); + Services.prefs.clearUserPref(dirPrefName); + } + do_register_cleanup(cleanupPrefs); + + // Should return the system downloads directory. + Services.prefs.setIntPref(folderListPrefName, 1); + let systemDir = yield DownloadIntegration.getSystemDownloadsDirectory(); + let downloadDir = yield DownloadIntegration.getPreferredDownloadsDirectory(); + do_check_neq(downloadDir, ""); + do_check_eq(downloadDir, systemDir); + + // Should return the desktop directory. + Services.prefs.setIntPref(folderListPrefName, 0); + downloadDir = yield DownloadIntegration.getPreferredDownloadsDirectory(); + do_check_neq(downloadDir, ""); + do_check_eq(downloadDir, Services.dirsvc.get("Desk", Ci.nsIFile).path); + + // Should return the system downloads directory because the dir preference + // is not set. + Services.prefs.setIntPref(folderListPrefName, 2); + downloadDir = yield DownloadIntegration.getPreferredDownloadsDirectory(); + do_check_neq(downloadDir, ""); + do_check_eq(downloadDir, systemDir); + + // Should return the directory which is listed in the dir preference. + 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); + downloadDir = yield DownloadIntegration.getPreferredDownloadsDirectory(); + do_check_neq(downloadDir, ""); + do_check_eq(downloadDir, tempDir.path); + do_check_true(yield OS.File.exists(downloadDir)); + yield OS.File.removeEmptyDir(tempDir.path); + + // Should return the system downloads directory beacause the path is invalid + // in the dir preference. + tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + tempDir.append("dir_not_exist"); + tempDir.append(time); + Services.prefs.setComplexValue("browser.download.dir", Ci.nsIFile, tempDir); + downloadDir = yield DownloadIntegration.getPreferredDownloadsDirectory(); + do_check_eq(downloadDir, systemDir); + + // Should return the system downloads directory because the folderList + // preference is invalid + Services.prefs.setIntPref(folderListPrefName, 999); + downloadDir = yield DownloadIntegration.getPreferredDownloadsDirectory(); + do_check_eq(downloadDir, systemDir); + + cleanupPrefs(); + cleanupDirectories(); +}); + +/** + * Tests that the getTemporaryDownloadsDirectory returns a valid download + * directory string path. + */ +add_task(function* test_getTemporaryDownloadsDirectory() +{ + let cleanup = allowDirectoriesInTest(); + + let downloadDir = yield DownloadIntegration.getTemporaryDownloadsDirectory(); + do_check_neq(downloadDir, ""); + + if ("nsILocalFileMac" in Ci) { + let preferredDownloadDir = yield DownloadIntegration.getPreferredDownloadsDirectory(); + do_check_eq(downloadDir, preferredDownloadDir); + } else { + let tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + do_check_eq(downloadDir, tempDir.path); + } + + cleanup(); +}); + +// Tests DownloadObserver + +/** + * Re-enables the default observers for the following tests. + * + * This takes effect the first time a DownloadList object is created, and lasts + * until this test file has completed. + */ +add_task(function* test_observers_setup() +{ + DownloadIntegration.allowObservers = true; + do_register_cleanup(function () { + DownloadIntegration.allowObservers = false; + }); +}); + +/** + * Tests notifications prompts when observers are notified if there are public + * and private active downloads. + */ +add_task(function* test_notifications() +{ + for (let isPrivate of [false, true]) { + mustInterruptResponses(); + + let list = yield promiseNewList(isPrivate); + let download1 = yield promiseNewDownload(httpUrl("interruptible.txt")); + let download2 = yield promiseNewDownload(httpUrl("interruptible.txt")); + let download3 = yield promiseNewDownload(httpUrl("interruptible.txt")); + let promiseAttempt1 = download1.start(); + let promiseAttempt2 = download2.start(); + download3.start().catch(() => {}); + + // Add downloads to list. + yield list.add(download1); + yield list.add(download2); + yield list.add(download3); + // Cancel third download + yield download3.cancel(); + + notifyPromptObservers(isPrivate, 2, 2); + + // Allow the downloads to complete. + continueResponses(); + yield promiseAttempt1; + yield promiseAttempt2; + + // Clean up. + yield list.remove(download1); + yield list.remove(download2); + yield list.remove(download3); + } +}); + +/** + * Tests that notifications prompts observers are not notified if there are no + * public or private active downloads. + */ +add_task(function* test_no_notifications() +{ + for (let isPrivate of [false, true]) { + let list = yield promiseNewList(isPrivate); + let download1 = yield promiseNewDownload(httpUrl("interruptible.txt")); + let download2 = yield promiseNewDownload(httpUrl("interruptible.txt")); + download1.start().catch(() => {}); + download2.start().catch(() => {}); + + // Add downloads to list. + yield list.add(download1); + yield list.add(download2); + + yield download1.cancel(); + yield download2.cancel(); + + notifyPromptObservers(isPrivate, 0, 0); + + // Clean up. + yield list.remove(download1); + yield list.remove(download2); + } +}); + +/** + * Tests notifications prompts when observers are notified if there are public + * and private active downloads at the same time. + */ +add_task(function* test_mix_notifications() +{ + mustInterruptResponses(); + + let publicList = yield promiseNewList(); + let privateList = yield Downloads.getList(Downloads.PRIVATE); + let download1 = yield promiseNewDownload(httpUrl("interruptible.txt")); + let download2 = yield promiseNewDownload(httpUrl("interruptible.txt")); + let promiseAttempt1 = download1.start(); + let promiseAttempt2 = download2.start(); + + // Add downloads to lists. + yield publicList.add(download1); + yield privateList.add(download2); + + notifyPromptObservers(true, 2, 1); + + // Allow the downloads to complete. + continueResponses(); + yield promiseAttempt1; + yield promiseAttempt2; + + // Clean up. + yield publicList.remove(download1); + yield privateList.remove(download2); +}); + +/** + * Tests suspending and resuming as well as going offline and then online again. + * The downloads should stop when suspending and start again when resuming. + */ +add_task(function* test_suspend_resume() +{ + // The default wake delay is 10 seconds, so set the wake delay to be much + // faster for these tests. + Services.prefs.setIntPref("browser.download.manager.resumeOnWakeDelay", 5); + + let addDownload = function(list) + { + return Task.spawn(function* () { + let download = yield promiseNewDownload(httpUrl("interruptible.txt")); + download.start().catch(() => {}); + list.add(download); + return download; + }); + } + + let publicList = yield promiseNewList(); + let privateList = yield promiseNewList(true); + + let download1 = yield addDownload(publicList); + let download2 = yield addDownload(publicList); + let download3 = yield addDownload(privateList); + let download4 = yield addDownload(privateList); + let download5 = yield addDownload(publicList); + + // First, check that the downloads are all canceled when going to sleep. + Services.obs.notifyObservers(null, "sleep_notification", null); + do_check_true(download1.canceled); + do_check_true(download2.canceled); + do_check_true(download3.canceled); + do_check_true(download4.canceled); + do_check_true(download5.canceled); + + // Remove a download. It should not be started again. + publicList.remove(download5); + do_check_true(download5.canceled); + + // When waking up again, the downloads start again after the wake delay. To be + // more robust, don't check after a delay but instead just wait for the + // downloads to finish. + Services.obs.notifyObservers(null, "wake_notification", null); + yield download1.whenSucceeded(); + yield download2.whenSucceeded(); + yield download3.whenSucceeded(); + yield download4.whenSucceeded(); + + // Downloads should no longer be canceled. However, as download5 was removed + // from the public list, it will not be restarted. + do_check_false(download1.canceled); + do_check_true(download5.canceled); + + // Create four new downloads and check for going offline and then online again. + + download1 = yield addDownload(publicList); + download2 = yield addDownload(publicList); + download3 = yield addDownload(privateList); + download4 = yield addDownload(privateList); + + // Going offline should cancel the downloads. + Services.obs.notifyObservers(null, "network:offline-about-to-go-offline", null); + do_check_true(download1.canceled); + do_check_true(download2.canceled); + do_check_true(download3.canceled); + do_check_true(download4.canceled); + + // Going back online should start the downloads again. + Services.obs.notifyObservers(null, "network:offline-status-changed", "online"); + yield download1.whenSucceeded(); + yield download2.whenSucceeded(); + yield download3.whenSucceeded(); + yield download4.whenSucceeded(); + + Services.prefs.clearUserPref("browser.download.manager.resumeOnWakeDelay"); +}); + +/** + * Tests both the downloads list and the in-progress downloads are clear when + * private browsing observer is notified. + */ +add_task(function* test_exit_private_browsing() +{ + mustInterruptResponses(); + + let privateList = yield promiseNewList(true); + let download1 = yield promiseNewDownload(httpUrl("source.txt")); + let download2 = yield promiseNewDownload(httpUrl("interruptible.txt")); + let promiseAttempt1 = download1.start(); + download2.start(); + + // Add downloads to list. + yield privateList.add(download1); + yield privateList.add(download2); + + // Complete the download. + yield promiseAttempt1; + + do_check_eq((yield privateList.getAll()).length, 2); + + // Simulate exiting the private browsing. + yield new Promise(resolve => { + DownloadIntegration._testResolveClearPrivateList = resolve; + Services.obs.notifyObservers(null, "last-pb-context-exited", null); + }); + delete DownloadIntegration._testResolveClearPrivateList; + + do_check_eq((yield privateList.getAll()).length, 0); + + continueResponses(); +}); diff --git a/toolkit/components/jsdownloads/test/unit/test_DownloadLegacy.js b/toolkit/components/jsdownloads/test/unit/test_DownloadLegacy.js new file mode 100644 index 000000000..dc6c18623 --- /dev/null +++ b/toolkit/components/jsdownloads/test/unit/test_DownloadLegacy.js @@ -0,0 +1,17 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the integration with legacy interfaces for downloads. + */ + +"use strict"; + +// Execution of common tests + +var gUseLegacySaver = true; + +var scriptFile = do_get_file("common_test_Download.js"); +Services.scriptloader.loadSubScript(NetUtil.newURI(scriptFile).spec); diff --git a/toolkit/components/jsdownloads/test/unit/test_DownloadList.js b/toolkit/components/jsdownloads/test/unit/test_DownloadList.js new file mode 100644 index 000000000..71e880741 --- /dev/null +++ b/toolkit/components/jsdownloads/test/unit/test_DownloadList.js @@ -0,0 +1,564 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the DownloadList object. + */ + +"use strict"; + +// Globals + +/** + * Returns a PRTime in the past usable to add expirable visits. + * + * @note Expiration ignores any visit added in the last 7 days, but it's + * better be safe against DST issues, by going back one day more. + */ +function getExpirablePRTime() +{ + let dateObj = new Date(); + // Normalize to midnight + dateObj.setHours(0); + dateObj.setMinutes(0); + dateObj.setSeconds(0); + dateObj.setMilliseconds(0); + dateObj = new Date(dateObj.getTime() - 8 * 86400000); + return dateObj.getTime() * 1000; +} + +/** + * Adds an expirable history visit for a download. + * + * @param aSourceUrl + * String containing the URI for the download source, or null to use + * httpUrl("source.txt"). + * + * @return {Promise} + * @rejects JavaScript exception. + */ +function promiseExpirableDownloadVisit(aSourceUrl) +{ + let deferred = Promise.defer(); + PlacesUtils.asyncHistory.updatePlaces( + { + uri: NetUtil.newURI(aSourceUrl || httpUrl("source.txt")), + visits: [{ + transitionType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD, + visitDate: getExpirablePRTime(), + }] + }, + { + handleError: function handleError(aResultCode, aPlaceInfo) { + let ex = new Components.Exception("Unexpected error in adding visits.", + aResultCode); + deferred.reject(ex); + }, + handleResult: function () {}, + handleCompletion: function handleCompletion() { + deferred.resolve(); + } + }); + return deferred.promise; +} + +// Tests + +/** + * Checks the testing mechanism used to build different download lists. + */ +add_task(function* test_construction() +{ + let downloadListOne = yield promiseNewList(); + let downloadListTwo = yield promiseNewList(); + let privateDownloadListOne = yield promiseNewList(true); + let privateDownloadListTwo = yield promiseNewList(true); + + do_check_neq(downloadListOne, downloadListTwo); + do_check_neq(privateDownloadListOne, privateDownloadListTwo); + do_check_neq(downloadListOne, privateDownloadListOne); +}); + +/** + * Checks the methods to add and retrieve items from the list. + */ +add_task(function* test_add_getAll() +{ + let list = yield promiseNewList(); + + let downloadOne = yield promiseNewDownload(); + yield list.add(downloadOne); + + let itemsOne = yield list.getAll(); + do_check_eq(itemsOne.length, 1); + do_check_eq(itemsOne[0], downloadOne); + + let downloadTwo = yield promiseNewDownload(); + yield list.add(downloadTwo); + + let itemsTwo = yield list.getAll(); + do_check_eq(itemsTwo.length, 2); + do_check_eq(itemsTwo[0], downloadOne); + do_check_eq(itemsTwo[1], downloadTwo); + + // The first snapshot should not have been modified. + do_check_eq(itemsOne.length, 1); +}); + +/** + * Checks the method to remove items from the list. + */ +add_task(function* test_remove() +{ + let list = yield promiseNewList(); + + yield list.add(yield promiseNewDownload()); + yield list.add(yield promiseNewDownload()); + + let items = yield list.getAll(); + yield list.remove(items[0]); + + // Removing an item that was never added should not raise an error. + yield list.remove(yield promiseNewDownload()); + + items = yield list.getAll(); + do_check_eq(items.length, 1); +}); + +/** + * Tests that the "add", "remove", and "getAll" methods on the global + * DownloadCombinedList object combine the contents of the global DownloadList + * objects for public and private downloads. + */ +add_task(function* test_DownloadCombinedList_add_remove_getAll() +{ + let publicList = yield promiseNewList(); + let privateList = yield Downloads.getList(Downloads.PRIVATE); + let combinedList = yield Downloads.getList(Downloads.ALL); + + let publicDownload = yield promiseNewDownload(); + let privateDownload = yield Downloads.createDownload({ + source: { url: httpUrl("source.txt"), isPrivate: true }, + target: getTempFile(TEST_TARGET_FILE_NAME).path, + }); + + yield publicList.add(publicDownload); + yield privateList.add(privateDownload); + + do_check_eq((yield combinedList.getAll()).length, 2); + + yield combinedList.remove(publicDownload); + yield combinedList.remove(privateDownload); + + do_check_eq((yield combinedList.getAll()).length, 0); + + yield combinedList.add(publicDownload); + yield combinedList.add(privateDownload); + + do_check_eq((yield publicList.getAll()).length, 1); + do_check_eq((yield privateList.getAll()).length, 1); + do_check_eq((yield combinedList.getAll()).length, 2); + + yield publicList.remove(publicDownload); + yield privateList.remove(privateDownload); + + do_check_eq((yield combinedList.getAll()).length, 0); +}); + +/** + * Checks that views receive the download add and remove notifications, and that + * adding and removing views works as expected, both for a normal and a combined + * list. + */ +add_task(function* test_notifications_add_remove() +{ + for (let isCombined of [false, true]) { + // Force creating a new list for both the public and combined cases. + let list = yield promiseNewList(); + if (isCombined) { + list = yield Downloads.getList(Downloads.ALL); + } + + let downloadOne = yield promiseNewDownload(); + let downloadTwo = yield Downloads.createDownload({ + source: { url: httpUrl("source.txt"), isPrivate: true }, + target: getTempFile(TEST_TARGET_FILE_NAME).path, + }); + yield list.add(downloadOne); + yield list.add(downloadTwo); + + // Check that we receive add notifications for existing elements. + let addNotifications = 0; + let viewOne = { + onDownloadAdded: function (aDownload) { + // The first download to be notified should be the first that was added. + if (addNotifications == 0) { + do_check_eq(aDownload, downloadOne); + } else if (addNotifications == 1) { + do_check_eq(aDownload, downloadTwo); + } + addNotifications++; + }, + }; + yield list.addView(viewOne); + do_check_eq(addNotifications, 2); + + // Check that we receive add notifications for new elements. + yield list.add(yield promiseNewDownload()); + do_check_eq(addNotifications, 3); + + // Check that we receive remove notifications. + let removeNotifications = 0; + let viewTwo = { + onDownloadRemoved: function (aDownload) { + do_check_eq(aDownload, downloadOne); + removeNotifications++; + }, + }; + yield list.addView(viewTwo); + yield list.remove(downloadOne); + do_check_eq(removeNotifications, 1); + + // We should not receive remove notifications after the view is removed. + yield list.removeView(viewTwo); + yield list.remove(downloadTwo); + do_check_eq(removeNotifications, 1); + + // We should not receive add notifications after the view is removed. + yield list.removeView(viewOne); + yield list.add(yield promiseNewDownload()); + do_check_eq(addNotifications, 3); + } +}); + +/** + * Checks that views receive the download change notifications, both for a + * normal and a combined list. + */ +add_task(function* test_notifications_change() +{ + for (let isCombined of [false, true]) { + // Force creating a new list for both the public and combined cases. + let list = yield promiseNewList(); + if (isCombined) { + list = yield Downloads.getList(Downloads.ALL); + } + + let downloadOne = yield promiseNewDownload(); + let downloadTwo = yield Downloads.createDownload({ + source: { url: httpUrl("source.txt"), isPrivate: true }, + target: getTempFile(TEST_TARGET_FILE_NAME).path, + }); + yield list.add(downloadOne); + yield list.add(downloadTwo); + + // Check that we receive change notifications. + let receivedOnDownloadChanged = false; + yield list.addView({ + onDownloadChanged: function (aDownload) { + do_check_eq(aDownload, downloadOne); + receivedOnDownloadChanged = true; + }, + }); + yield downloadOne.start(); + do_check_true(receivedOnDownloadChanged); + + // We should not receive change notifications after a download is removed. + receivedOnDownloadChanged = false; + yield list.remove(downloadTwo); + yield downloadTwo.start(); + do_check_false(receivedOnDownloadChanged); + } +}); + +/** + * Checks that the reference to "this" is correct in the view callbacks. + */ +add_task(function* test_notifications_this() +{ + let list = yield promiseNewList(); + + // Check that we receive change notifications. + let receivedOnDownloadAdded = false; + let receivedOnDownloadChanged = false; + let receivedOnDownloadRemoved = false; + let view = { + onDownloadAdded: function () { + do_check_eq(this, view); + receivedOnDownloadAdded = true; + }, + onDownloadChanged: function () { + // Only do this check once. + if (!receivedOnDownloadChanged) { + do_check_eq(this, view); + receivedOnDownloadChanged = true; + } + }, + onDownloadRemoved: function () { + do_check_eq(this, view); + receivedOnDownloadRemoved = true; + }, + }; + yield list.addView(view); + + let download = yield promiseNewDownload(); + yield list.add(download); + yield download.start(); + yield list.remove(download); + + // Verify that we executed the checks. + do_check_true(receivedOnDownloadAdded); + do_check_true(receivedOnDownloadChanged); + do_check_true(receivedOnDownloadRemoved); +}); + +/** + * Checks that download is removed on history expiration. + */ +add_task(function* test_history_expiration() +{ + mustInterruptResponses(); + + function cleanup() { + Services.prefs.clearUserPref("places.history.expiration.max_pages"); + } + do_register_cleanup(cleanup); + + // Set max pages to 0 to make the download expire. + Services.prefs.setIntPref("places.history.expiration.max_pages", 0); + + let list = yield promiseNewList(); + let downloadOne = yield promiseNewDownload(); + let downloadTwo = yield promiseNewDownload(httpUrl("interruptible.txt")); + + let deferred = Promise.defer(); + let removeNotifications = 0; + let downloadView = { + onDownloadRemoved: function (aDownload) { + if (++removeNotifications == 2) { + deferred.resolve(); + } + }, + }; + yield list.addView(downloadView); + + // Work with one finished download and one canceled download. + yield downloadOne.start(); + downloadTwo.start().catch(() => {}); + yield downloadTwo.cancel(); + + // We must replace the visits added while executing the downloads with visits + // that are older than 7 days, otherwise they will not be expired. + yield PlacesTestUtils.clearHistory(); + yield promiseExpirableDownloadVisit(); + yield promiseExpirableDownloadVisit(httpUrl("interruptible.txt")); + + // After clearing history, we can add the downloads to be removed to the list. + yield list.add(downloadOne); + yield list.add(downloadTwo); + + // Force a history expiration. + Cc["@mozilla.org/places/expiration;1"] + .getService(Ci.nsIObserver).observe(null, "places-debug-start-expiration", -1); + + // Wait for both downloads to be removed. + yield deferred.promise; + + cleanup(); +}); + +/** + * Checks all downloads are removed after clearing history. + */ +add_task(function* test_history_clear() +{ + let list = yield promiseNewList(); + let downloadOne = yield promiseNewDownload(); + let downloadTwo = yield promiseNewDownload(); + yield list.add(downloadOne); + yield list.add(downloadTwo); + + let deferred = Promise.defer(); + let removeNotifications = 0; + let downloadView = { + onDownloadRemoved: function (aDownload) { + if (++removeNotifications == 2) { + deferred.resolve(); + } + }, + }; + yield list.addView(downloadView); + + yield downloadOne.start(); + yield downloadTwo.start(); + + yield PlacesTestUtils.clearHistory(); + + // Wait for the removal notifications that may still be pending. + yield deferred.promise; +}); + +/** + * Tests the removeFinished method to ensure that it only removes + * finished downloads. + */ +add_task(function* test_removeFinished() +{ + let list = yield promiseNewList(); + let downloadOne = yield promiseNewDownload(); + let downloadTwo = yield promiseNewDownload(); + let downloadThree = yield promiseNewDownload(); + let downloadFour = yield promiseNewDownload(); + yield list.add(downloadOne); + yield list.add(downloadTwo); + yield list.add(downloadThree); + yield list.add(downloadFour); + + let deferred = Promise.defer(); + let removeNotifications = 0; + let downloadView = { + onDownloadRemoved: function (aDownload) { + do_check_true(aDownload == downloadOne || + aDownload == downloadTwo || + aDownload == downloadThree); + do_check_true(removeNotifications < 3); + if (++removeNotifications == 3) { + deferred.resolve(); + } + }, + }; + yield list.addView(downloadView); + + // Start three of the downloads, but don't start downloadTwo, then set + // downloadFour to have partial data. All downloads except downloadFour + // should be removed. + yield downloadOne.start(); + yield downloadThree.start(); + yield downloadFour.start(); + downloadFour.hasPartialData = true; + + list.removeFinished(); + yield deferred.promise; + + let downloads = yield list.getAll() + do_check_eq(downloads.length, 1); +}); + +/** + * Tests the global DownloadSummary objects for the public, private, and + * combined download lists. + */ +add_task(function* test_DownloadSummary() +{ + mustInterruptResponses(); + + let publicList = yield promiseNewList(); + let privateList = yield Downloads.getList(Downloads.PRIVATE); + + let publicSummary = yield Downloads.getSummary(Downloads.PUBLIC); + let privateSummary = yield Downloads.getSummary(Downloads.PRIVATE); + let combinedSummary = yield Downloads.getSummary(Downloads.ALL); + + // Add a public download that has succeeded. + let succeededPublicDownload = yield promiseNewDownload(); + yield succeededPublicDownload.start(); + yield publicList.add(succeededPublicDownload); + + // Add a public download that has been canceled midway. + let canceledPublicDownload = + yield promiseNewDownload(httpUrl("interruptible.txt")); + canceledPublicDownload.start().catch(() => {}); + yield promiseDownloadMidway(canceledPublicDownload); + yield canceledPublicDownload.cancel(); + yield publicList.add(canceledPublicDownload); + + // Add a public download that is in progress. + let inProgressPublicDownload = + yield promiseNewDownload(httpUrl("interruptible.txt")); + inProgressPublicDownload.start().catch(() => {}); + yield promiseDownloadMidway(inProgressPublicDownload); + yield publicList.add(inProgressPublicDownload); + + // Add a private download that is in progress. + let inProgressPrivateDownload = yield Downloads.createDownload({ + source: { url: httpUrl("interruptible.txt"), isPrivate: true }, + target: getTempFile(TEST_TARGET_FILE_NAME).path, + }); + inProgressPrivateDownload.start().catch(() => {}); + yield promiseDownloadMidway(inProgressPrivateDownload); + yield privateList.add(inProgressPrivateDownload); + + // Verify that the summary includes the total number of bytes and the + // currently transferred bytes only for the downloads that are not stopped. + // For simplicity, we assume that after a download is added to the list, its + // current state is immediately propagated to the summary object, which is + // true in the current implementation, though it is not guaranteed as all the + // download operations may happen asynchronously. + do_check_false(publicSummary.allHaveStopped); + do_check_eq(publicSummary.progressTotalBytes, TEST_DATA_SHORT.length * 2); + do_check_eq(publicSummary.progressCurrentBytes, TEST_DATA_SHORT.length); + + do_check_false(privateSummary.allHaveStopped); + do_check_eq(privateSummary.progressTotalBytes, TEST_DATA_SHORT.length * 2); + do_check_eq(privateSummary.progressCurrentBytes, TEST_DATA_SHORT.length); + + do_check_false(combinedSummary.allHaveStopped); + do_check_eq(combinedSummary.progressTotalBytes, TEST_DATA_SHORT.length * 4); + do_check_eq(combinedSummary.progressCurrentBytes, TEST_DATA_SHORT.length * 2); + + yield inProgressPublicDownload.cancel(); + + // Stopping the download should have excluded it from the summary. + do_check_true(publicSummary.allHaveStopped); + do_check_eq(publicSummary.progressTotalBytes, 0); + do_check_eq(publicSummary.progressCurrentBytes, 0); + + do_check_false(privateSummary.allHaveStopped); + do_check_eq(privateSummary.progressTotalBytes, TEST_DATA_SHORT.length * 2); + do_check_eq(privateSummary.progressCurrentBytes, TEST_DATA_SHORT.length); + + do_check_false(combinedSummary.allHaveStopped); + do_check_eq(combinedSummary.progressTotalBytes, TEST_DATA_SHORT.length * 2); + do_check_eq(combinedSummary.progressCurrentBytes, TEST_DATA_SHORT.length); + + yield inProgressPrivateDownload.cancel(); + + // All the downloads should be stopped now. + do_check_true(publicSummary.allHaveStopped); + do_check_eq(publicSummary.progressTotalBytes, 0); + do_check_eq(publicSummary.progressCurrentBytes, 0); + + do_check_true(privateSummary.allHaveStopped); + do_check_eq(privateSummary.progressTotalBytes, 0); + do_check_eq(privateSummary.progressCurrentBytes, 0); + + do_check_true(combinedSummary.allHaveStopped); + do_check_eq(combinedSummary.progressTotalBytes, 0); + do_check_eq(combinedSummary.progressCurrentBytes, 0); +}); + +/** + * Checks that views receive the summary change notification. This is tested on + * the combined summary when adding a public download, as we assume that if we + * pass the test in this case we will also pass it in the others. + */ +add_task(function* test_DownloadSummary_notifications() +{ + let list = yield promiseNewList(); + let summary = yield Downloads.getSummary(Downloads.ALL); + + let download = yield promiseNewDownload(); + yield list.add(download); + + // Check that we receive change notifications. + let receivedOnSummaryChanged = false; + yield summary.addView({ + onSummaryChanged: function () { + receivedOnSummaryChanged = true; + }, + }); + yield download.start(); + do_check_true(receivedOnSummaryChanged); +}); diff --git a/toolkit/components/jsdownloads/test/unit/test_DownloadStore.js b/toolkit/components/jsdownloads/test/unit/test_DownloadStore.js new file mode 100644 index 000000000..3a23dfbe3 --- /dev/null +++ b/toolkit/components/jsdownloads/test/unit/test_DownloadStore.js @@ -0,0 +1,315 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the DownloadStore object. + */ + +"use strict"; + +// Globals + +XPCOMUtils.defineLazyModuleGetter(this, "DownloadStore", + "resource://gre/modules/DownloadStore.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm") + +/** + * Returns a new DownloadList object with an associated DownloadStore. + * + * @param aStorePath + * String pointing to the file to be associated with the DownloadStore, + * or undefined to use a non-existing temporary file. In this case, the + * temporary file is deleted when the test file execution finishes. + * + * @return {Promise} + * @resolves Array [ Newly created DownloadList , associated DownloadStore ]. + * @rejects JavaScript exception. + */ +function promiseNewListAndStore(aStorePath) +{ + return promiseNewList().then(function (aList) { + let path = aStorePath || getTempFile(TEST_STORE_FILE_NAME).path; + let store = new DownloadStore(aList, path); + return [aList, store]; + }); +} + +// Tests + +/** + * Saves downloads to a file, then reloads them. + */ +add_task(function* test_save_reload() +{ + let [listForSave, storeForSave] = yield promiseNewListAndStore(); + let [listForLoad, storeForLoad] = yield promiseNewListAndStore( + storeForSave.path); + + listForSave.add(yield promiseNewDownload(httpUrl("source.txt"))); + listForSave.add(yield Downloads.createDownload({ + source: { url: httpUrl("empty.txt"), + referrer: TEST_REFERRER_URL }, + target: getTempFile(TEST_TARGET_FILE_NAME), + })); + + // This PDF download should not be serialized because it never succeeds. + let pdfDownload = yield Downloads.createDownload({ + source: { url: httpUrl("empty.txt"), + referrer: TEST_REFERRER_URL }, + target: getTempFile(TEST_TARGET_FILE_NAME), + saver: "pdf", + }); + listForSave.add(pdfDownload); + + // If we used a callback to adjust the channel, the download should + // not be serialized because we can't recreate it across sessions. + let adjustedDownload = yield Downloads.createDownload({ + source: { url: httpUrl("empty.txt"), + adjustChannel: () => Promise.resolve() }, + target: getTempFile(TEST_TARGET_FILE_NAME), + }); + listForSave.add(adjustedDownload); + + let legacyDownload = yield promiseStartLegacyDownload(); + yield legacyDownload.cancel(); + listForSave.add(legacyDownload); + + yield storeForSave.save(); + yield storeForLoad.load(); + + // Remove the PDF and adjusted downloads because they should not appear here. + listForSave.remove(adjustedDownload); + listForSave.remove(pdfDownload); + + let itemsForSave = yield listForSave.getAll(); + let itemsForLoad = yield listForLoad.getAll(); + + do_check_eq(itemsForSave.length, itemsForLoad.length); + + // Downloads should be reloaded in the same order. + for (let i = 0; i < itemsForSave.length; i++) { + // The reloaded downloads are different objects. + do_check_neq(itemsForSave[i], itemsForLoad[i]); + + // The reloaded downloads have the same properties. + do_check_eq(itemsForSave[i].source.url, + itemsForLoad[i].source.url); + do_check_eq(itemsForSave[i].source.referrer, + itemsForLoad[i].source.referrer); + do_check_eq(itemsForSave[i].target.path, + itemsForLoad[i].target.path); + do_check_eq(itemsForSave[i].saver.toSerializable(), + itemsForLoad[i].saver.toSerializable()); + } +}); + +/** + * Checks that saving an empty list deletes any existing file. + */ +add_task(function* test_save_empty() +{ + let [, store] = yield promiseNewListAndStore(); + + let createdFile = yield OS.File.open(store.path, { create: true }); + yield createdFile.close(); + + yield store.save(); + + do_check_false(yield OS.File.exists(store.path)); + + // If the file does not exist, saving should not generate exceptions. + yield store.save(); +}); + +/** + * Checks that loading from a missing file results in an empty list. + */ +add_task(function* test_load_empty() +{ + let [list, store] = yield promiseNewListAndStore(); + + do_check_false(yield OS.File.exists(store.path)); + + yield store.load(); + + let items = yield list.getAll(); + do_check_eq(items.length, 0); +}); + +/** + * Loads downloads from a string in a predefined format. The purpose of this + * test is to verify that the JSON format used in previous versions can be + * loaded, assuming the file is reloaded on the same platform. + */ +add_task(function* test_load_string_predefined() +{ + let [list, store] = yield promiseNewListAndStore(); + + // The platform-dependent file name should be generated dynamically. + let targetPath = getTempFile(TEST_TARGET_FILE_NAME).path; + let filePathLiteral = JSON.stringify(targetPath); + let sourceUriLiteral = JSON.stringify(httpUrl("source.txt")); + let emptyUriLiteral = JSON.stringify(httpUrl("empty.txt")); + let referrerUriLiteral = JSON.stringify(TEST_REFERRER_URL); + + let string = "{\"list\":[{\"source\":" + sourceUriLiteral + "," + + "\"target\":" + filePathLiteral + "}," + + "{\"source\":{\"url\":" + emptyUriLiteral + "," + + "\"referrer\":" + referrerUriLiteral + "}," + + "\"target\":" + filePathLiteral + "}]}"; + + yield OS.File.writeAtomic(store.path, + new TextEncoder().encode(string), + { tmpPath: store.path + ".tmp" }); + + yield store.load(); + + let items = yield list.getAll(); + + do_check_eq(items.length, 2); + + do_check_eq(items[0].source.url, httpUrl("source.txt")); + do_check_eq(items[0].target.path, targetPath); + + do_check_eq(items[1].source.url, httpUrl("empty.txt")); + do_check_eq(items[1].source.referrer, TEST_REFERRER_URL); + do_check_eq(items[1].target.path, targetPath); +}); + +/** + * Loads downloads from a well-formed JSON string containing unrecognized data. + */ +add_task(function* test_load_string_unrecognized() +{ + let [list, store] = yield promiseNewListAndStore(); + + // The platform-dependent file name should be generated dynamically. + let targetPath = getTempFile(TEST_TARGET_FILE_NAME).path; + let filePathLiteral = JSON.stringify(targetPath); + let sourceUriLiteral = JSON.stringify(httpUrl("source.txt")); + + let string = "{\"list\":[{\"source\":null," + + "\"target\":null}," + + "{\"source\":{\"url\":" + sourceUriLiteral + "}," + + "\"target\":{\"path\":" + filePathLiteral + "}," + + "\"saver\":{\"type\":\"copy\"}}]}"; + + yield OS.File.writeAtomic(store.path, + new TextEncoder().encode(string), + { tmpPath: store.path + ".tmp" }); + + yield store.load(); + + let items = yield list.getAll(); + + do_check_eq(items.length, 1); + + do_check_eq(items[0].source.url, httpUrl("source.txt")); + do_check_eq(items[0].target.path, targetPath); +}); + +/** + * Loads downloads from a malformed JSON string. + */ +add_task(function* test_load_string_malformed() +{ + let [list, store] = yield promiseNewListAndStore(); + + let string = "{\"list\":[{\"source\":null,\"target\":null}," + + "{\"source\":{\"url\":\"about:blank\"}}}"; + + yield OS.File.writeAtomic(store.path, new TextEncoder().encode(string), + { tmpPath: store.path + ".tmp" }); + + try { + yield store.load(); + do_throw("Exception expected when JSON data is malformed."); + } catch (ex) { + if (ex.name != "SyntaxError") { + throw ex; + } + do_print("The expected SyntaxError exception was thrown."); + } + + let items = yield list.getAll(); + + do_check_eq(items.length, 0); +}); + +/** + * Saves downloads with unknown properties to a file and then reloads + * them to ensure that these properties are preserved. + */ +add_task(function* test_save_reload_unknownProperties() +{ + let [listForSave, storeForSave] = yield promiseNewListAndStore(); + let [listForLoad, storeForLoad] = yield promiseNewListAndStore( + storeForSave.path); + + let download1 = yield promiseNewDownload(httpUrl("source.txt")); + // startTime should be ignored as it is a known property, and error + // is ignored by serialization + download1._unknownProperties = { peanut: "butter", + orange: "marmalade", + startTime: 77, + error: { message: "Passed" } }; + listForSave.add(download1); + + let download2 = yield promiseStartLegacyDownload(); + yield download2.cancel(); + download2._unknownProperties = { number: 5, object: { test: "string" } }; + listForSave.add(download2); + + let download3 = yield Downloads.createDownload({ + source: { url: httpUrl("empty.txt"), + referrer: TEST_REFERRER_URL, + source1: "download3source1", + source2: "download3source2" }, + target: { path: getTempFile(TEST_TARGET_FILE_NAME).path, + target1: "download3target1", + target2: "download3target2" }, + saver : { type: "copy", + saver1: "download3saver1", + saver2: "download3saver2" }, + }); + listForSave.add(download3); + + yield storeForSave.save(); + yield storeForLoad.load(); + + let itemsForSave = yield listForSave.getAll(); + let itemsForLoad = yield listForLoad.getAll(); + + do_check_eq(itemsForSave.length, itemsForLoad.length); + + do_check_eq(Object.keys(itemsForLoad[0]._unknownProperties).length, 2); + do_check_eq(itemsForLoad[0]._unknownProperties.peanut, "butter"); + do_check_eq(itemsForLoad[0]._unknownProperties.orange, "marmalade"); + do_check_false("startTime" in itemsForLoad[0]._unknownProperties); + do_check_false("error" in itemsForLoad[0]._unknownProperties); + + do_check_eq(Object.keys(itemsForLoad[1]._unknownProperties).length, 2); + do_check_eq(itemsForLoad[1]._unknownProperties.number, 5); + do_check_eq(itemsForLoad[1]._unknownProperties.object.test, "string"); + + do_check_eq(Object.keys(itemsForLoad[2].source._unknownProperties).length, 2); + do_check_eq(itemsForLoad[2].source._unknownProperties.source1, + "download3source1"); + do_check_eq(itemsForLoad[2].source._unknownProperties.source2, + "download3source2"); + + do_check_eq(Object.keys(itemsForLoad[2].target._unknownProperties).length, 2); + do_check_eq(itemsForLoad[2].target._unknownProperties.target1, + "download3target1"); + do_check_eq(itemsForLoad[2].target._unknownProperties.target2, + "download3target2"); + + do_check_eq(Object.keys(itemsForLoad[2].saver._unknownProperties).length, 2); + do_check_eq(itemsForLoad[2].saver._unknownProperties.saver1, + "download3saver1"); + do_check_eq(itemsForLoad[2].saver._unknownProperties.saver2, + "download3saver2"); +}); diff --git a/toolkit/components/jsdownloads/test/unit/test_Downloads.js b/toolkit/components/jsdownloads/test/unit/test_Downloads.js new file mode 100644 index 000000000..2027beee1 --- /dev/null +++ b/toolkit/components/jsdownloads/test/unit/test_Downloads.js @@ -0,0 +1,194 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the functions located directly in the "Downloads" object. + */ + +"use strict"; + +// Tests + +/** + * Tests that the createDownload function exists and can be called. More + * detailed tests are implemented separately for the DownloadCore module. + */ +add_task(function* test_createDownload() +{ + // Creates a simple Download object without starting the download. + yield Downloads.createDownload({ + source: { url: "about:blank" }, + target: { path: getTempFile(TEST_TARGET_FILE_NAME).path }, + saver: { type: "copy" }, + }); +}); + +/** + * Tests createDownload for private download. + */ +add_task(function* test_createDownload_private() +{ + let download = yield Downloads.createDownload({ + source: { url: "about:blank", isPrivate: true }, + target: { path: getTempFile(TEST_TARGET_FILE_NAME).path }, + saver: { type: "copy" } + }); + do_check_true(download.source.isPrivate); +}); + +/** + * Tests createDownload for normal (public) download. + */ +add_task(function* test_createDownload_public() +{ + let tempPath = getTempFile(TEST_TARGET_FILE_NAME).path; + let download = yield Downloads.createDownload({ + source: { url: "about:blank", isPrivate: false }, + target: { path: tempPath }, + saver: { type: "copy" } + }); + do_check_false(download.source.isPrivate); + + download = yield Downloads.createDownload({ + source: { url: "about:blank" }, + target: { path: tempPath }, + saver: { type: "copy" } + }); + do_check_false(download.source.isPrivate); +}); + +/** + * Tests createDownload for a pdf saver throws if only given a url. + */ +add_task(function* test_createDownload_pdf() +{ + let download = yield Downloads.createDownload({ + source: { url: "about:blank" }, + target: { path: getTempFile(TEST_TARGET_FILE_NAME).path }, + saver: { type: "pdf" }, + }); + + try { + yield download.start(); + do_throw("The download should have failed."); + } catch (ex) { + if (!(ex instanceof Downloads.Error) || !ex.becauseSourceFailed) { + throw ex; + } + } + + do_check_false(download.succeeded); + do_check_true(download.stopped); + do_check_false(download.canceled); + do_check_true(download.error !== null); + do_check_true(download.error.becauseSourceFailed); + do_check_false(download.error.becauseTargetFailed); + do_check_false(yield OS.File.exists(download.target.path)); +}); + +/** + * Tests "fetch" with nsIURI and nsIFile as arguments. + */ +add_task(function* test_fetch_uri_file_arguments() +{ + let targetFile = getTempFile(TEST_TARGET_FILE_NAME); + yield Downloads.fetch(NetUtil.newURI(httpUrl("source.txt")), targetFile); + yield promiseVerifyContents(targetFile.path, TEST_DATA_SHORT); +}); + +/** + * Tests "fetch" with DownloadSource and DownloadTarget as arguments. + */ +add_task(function* test_fetch_object_arguments() +{ + let targetPath = getTempFile(TEST_TARGET_FILE_NAME).path; + yield Downloads.fetch({ url: httpUrl("source.txt") }, { path: targetPath }); + yield promiseVerifyContents(targetPath, TEST_DATA_SHORT); +}); + +/** + * Tests "fetch" with string arguments. + */ +add_task(function* test_fetch_string_arguments() +{ + let targetPath = getTempFile(TEST_TARGET_FILE_NAME).path; + yield Downloads.fetch(httpUrl("source.txt"), targetPath); + yield promiseVerifyContents(targetPath, TEST_DATA_SHORT); + + targetPath = getTempFile(TEST_TARGET_FILE_NAME).path; + yield Downloads.fetch(new String(httpUrl("source.txt")), + new String(targetPath)); + yield promiseVerifyContents(targetPath, TEST_DATA_SHORT); +}); + +/** + * Tests that the getList function returns the same list when called multiple + * times with the same argument, but returns different lists when called with + * different arguments. More detailed tests are implemented separately for the + * DownloadList module. + */ +add_task(function* test_getList() +{ + let publicListOne = yield Downloads.getList(Downloads.PUBLIC); + let privateListOne = yield Downloads.getList(Downloads.PRIVATE); + + let publicListTwo = yield Downloads.getList(Downloads.PUBLIC); + let privateListTwo = yield Downloads.getList(Downloads.PRIVATE); + + do_check_eq(publicListOne, publicListTwo); + do_check_eq(privateListOne, privateListTwo); + + do_check_neq(publicListOne, privateListOne); +}); + +/** + * Tests that the getSummary function returns the same summary when called + * multiple times with the same argument, but returns different summaries when + * called with different arguments. More detailed tests are implemented + * separately for the DownloadSummary object in the DownloadList module. + */ +add_task(function* test_getSummary() +{ + let publicSummaryOne = yield Downloads.getSummary(Downloads.PUBLIC); + let privateSummaryOne = yield Downloads.getSummary(Downloads.PRIVATE); + + let publicSummaryTwo = yield Downloads.getSummary(Downloads.PUBLIC); + let privateSummaryTwo = yield Downloads.getSummary(Downloads.PRIVATE); + + do_check_eq(publicSummaryOne, publicSummaryTwo); + do_check_eq(privateSummaryOne, privateSummaryTwo); + + do_check_neq(publicSummaryOne, privateSummaryOne); +}); + +/** + * Tests that the getSystemDownloadsDirectory returns a non-empty download + * directory string. + */ +add_task(function* test_getSystemDownloadsDirectory() +{ + let downloadDir = yield Downloads.getSystemDownloadsDirectory(); + do_check_neq(downloadDir, ""); +}); + +/** + * Tests that the getPreferredDownloadsDirectory returns a non-empty download + * directory string. + */ +add_task(function* test_getPreferredDownloadsDirectory() +{ + let downloadDir = yield Downloads.getPreferredDownloadsDirectory(); + do_check_neq(downloadDir, ""); +}); + +/** + * Tests that the getTemporaryDownloadsDirectory returns a non-empty download + * directory string. + */ +add_task(function* test_getTemporaryDownloadsDirectory() +{ + let downloadDir = yield Downloads.getTemporaryDownloadsDirectory(); + do_check_neq(downloadDir, ""); +}); diff --git a/toolkit/components/jsdownloads/test/unit/test_PrivateTemp.js b/toolkit/components/jsdownloads/test/unit/test_PrivateTemp.js new file mode 100644 index 000000000..1308e9782 --- /dev/null +++ b/toolkit/components/jsdownloads/test/unit/test_PrivateTemp.js @@ -0,0 +1,24 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + * The temporary directory downloads saves to, should be only readable + * for the current user. + */ +add_task(function* test_private_temp() { + + let download = yield promiseStartExternalHelperAppServiceDownload( + httpUrl("empty.txt")); + + yield promiseDownloadStopped(download); + + var targetFile = Cc['@mozilla.org/file/local;1'].createInstance(Ci.nsIFile); + targetFile.initWithPath(download.target.path); + + // 488 is the decimal value of 0o700. + equal(targetFile.parent.permissions, 448); +}); diff --git a/toolkit/components/jsdownloads/test/unit/xpcshell.ini b/toolkit/components/jsdownloads/test/unit/xpcshell.ini new file mode 100644 index 000000000..8de554540 --- /dev/null +++ b/toolkit/components/jsdownloads/test/unit/xpcshell.ini @@ -0,0 +1,19 @@ +[DEFAULT] +head = head.js +tail = +skip-if = toolkit == 'android' + +# Note: The "tail.js" file is not defined in the "tail" key because it calls +# the "add_test_task" function, that does not work properly in tail files. +support-files = + common_test_Download.js + +[test_DownloadCore.js] +[test_DownloadImport.js] +[test_DownloadIntegration.js] +[test_DownloadLegacy.js] +[test_DownloadList.js] +[test_Downloads.js] +[test_DownloadStore.js] +[test_PrivateTemp.js] +skip-if = os != 'linux' |