1187 lines
37 KiB
JavaScript
1187 lines
37 KiB
JavaScript
/* -*- 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";
|
|
|
|
var { Integration } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/Integration.sys.mjs"
|
|
);
|
|
var { XPCOMUtils } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/XPCOMUtils.sys.mjs"
|
|
);
|
|
const { AppConstants } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/AppConstants.sys.mjs"
|
|
);
|
|
|
|
ChromeUtils.defineESModuleGetters(this, {
|
|
DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
|
|
Downloads: "resource://gre/modules/Downloads.sys.mjs",
|
|
E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
|
|
FileTestUtils: "resource://testing-common/FileTestUtils.sys.mjs",
|
|
FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
|
|
HttpServer: "resource://testing-common/httpd.sys.mjs",
|
|
MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs",
|
|
NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
|
|
PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
|
|
TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
|
|
TestUtils: "resource://testing-common/TestUtils.sys.mjs",
|
|
});
|
|
|
|
XPCOMUtils.defineLazyServiceGetter(
|
|
this,
|
|
"gExternalHelperAppService",
|
|
"@mozilla.org/uriloader/external-helper-app-service;1",
|
|
Ci.nsIExternalHelperAppService
|
|
);
|
|
|
|
/* global DownloadIntegration */
|
|
Integration.downloads.defineESModuleGetter(
|
|
this,
|
|
"DownloadIntegration",
|
|
"resource://gre/modules/DownloadIntegration.sys.mjs"
|
|
);
|
|
|
|
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 ReferrerInfo = Components.Constructor(
|
|
"@mozilla.org/referrer-info;1",
|
|
"nsIReferrerInfo",
|
|
"init"
|
|
);
|
|
|
|
const TEST_TARGET_FILE_NAME = "test-download.txt";
|
|
const TEST_STORE_FILE_NAME = "test-downloads.json";
|
|
|
|
// We are testing an HTTPS referrer with HTTP downloads in order to verify that
|
|
// the default policy will not prevent the referrer from being passed around.
|
|
const TEST_REFERRER_URL = "https://www.example.com/referrer.html";
|
|
|
|
const TEST_DATA_SHORT = "This test string is downloaded.";
|
|
// Generate using gzipCompressString in TelemetryController.sys.mjs.
|
|
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://www.example.com:" +
|
|
gHttpServer.identity.primaryPort +
|
|
"/" +
|
|
aFileName
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns a reference to a temporary file that is guaranteed not to exist and
|
|
* is cleaned up later. See FileTestUtils.getTempFile for details.
|
|
*/
|
|
function getTempFile(leafName) {
|
|
return FileTestUtils.getTempFile(leafName);
|
|
}
|
|
|
|
/**
|
|
* Check for file existence.
|
|
* @param {string} path The file path.
|
|
*/
|
|
async function fileExists(path) {
|
|
try {
|
|
return (await IOUtils.stat(path)).type == "regular";
|
|
} catch (ex) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Waits for pending events to be processed.
|
|
*
|
|
* @return {Promise}
|
|
* @resolves When pending events have been processed.
|
|
* @rejects Never.
|
|
*/
|
|
function promiseExecuteSoon() {
|
|
return new Promise(resolve => {
|
|
executeSoon(resolve);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
return new Promise(resolve => {
|
|
do_timeout(aTime, resolve);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 page-visited places event.
|
|
* @rejects Never.
|
|
*/
|
|
function promiseWaitForVisit(aUrl) {
|
|
return new Promise(resolve => {
|
|
function listener(aEvents) {
|
|
Assert.equal(aEvents.length, 1);
|
|
let event = aEvents[0];
|
|
Assert.equal(event.type, "page-visited");
|
|
if (event.url == aUrl) {
|
|
PlacesObservers.removeListener(["page-visited"], listener);
|
|
resolve([event.visitTime, event.transitionType, event.lastKnownTitle]);
|
|
}
|
|
}
|
|
PlacesObservers.addListener(["page-visited"], listener);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* referrerInfo: referrerInfo for the download source.
|
|
* cookieJarSettings: cookieJarSettings for the download source.
|
|
* 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.pathQueryRef.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) {
|
|
Assert.ok(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.launcherId) {
|
|
Assert.ok(mimeInfo != null);
|
|
|
|
let gioHandlerApp = Cc["@mozilla.org/gio-service;1"]
|
|
.getService(Ci.nsIGIOService)
|
|
.createHandlerAppFromAppId(aOptions.launcherId);
|
|
|
|
mimeInfo.preferredApplicationHandler = gioHandlerApp;
|
|
mimeInfo.preferredAction = Ci.nsIMIMEInfo.useHelperApp;
|
|
}
|
|
|
|
if (aOptions && aOptions.launchWhenSucceeded) {
|
|
Assert.ok(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);
|
|
|
|
return new Promise(resolve => {
|
|
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(aDownload) {
|
|
aList.removeView(this).catch(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(
|
|
() => resolve(aDownload),
|
|
do_report_unexpected_exception
|
|
);
|
|
},
|
|
})
|
|
.catch(do_report_unexpected_exception);
|
|
|
|
let isPrivate = aOptions && aOptions.isPrivate;
|
|
let referrerInfo = aOptions ? aOptions.referrerInfo : null;
|
|
let cookieJarSettings = aOptions ? aOptions.cookieJarSettings : null;
|
|
let classification =
|
|
aOptions?.downloadClassification ??
|
|
Ci.nsITransfer.DOWNLOAD_ACCEPTABLE;
|
|
// 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,
|
|
null,
|
|
NetUtil.newURI(targetFile),
|
|
null,
|
|
mimeInfo,
|
|
null,
|
|
null,
|
|
persist,
|
|
isPrivate,
|
|
classification,
|
|
null
|
|
);
|
|
persist.progressListener = transfer;
|
|
|
|
// Start the actual download process.
|
|
persist.saveURI(
|
|
sourceURI,
|
|
Services.scriptSecurityManager.getSystemPrincipal(),
|
|
0,
|
|
referrerInfo,
|
|
cookieJarSettings,
|
|
null,
|
|
null,
|
|
targetFile,
|
|
Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD,
|
|
isPrivate
|
|
);
|
|
})
|
|
.catch(do_report_unexpected_exception);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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")
|
|
);
|
|
|
|
return new Promise(resolve => {
|
|
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(aDownload) {
|
|
aList.removeView(this).catch(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(
|
|
() => resolve(aDownload),
|
|
do_report_unexpected_exception
|
|
);
|
|
},
|
|
})
|
|
.catch(do_report_unexpected_exception);
|
|
|
|
let channel = NetUtil.newChannel({
|
|
uri: sourceURI,
|
|
loadUsingSystemPrincipal: true,
|
|
});
|
|
|
|
// Start the actual download process.
|
|
channel.asyncOpen({
|
|
contentListener: null,
|
|
|
|
onStartRequest(aRequest) {
|
|
let requestChannel = aRequest.QueryInterface(Ci.nsIChannel);
|
|
this.contentListener = gExternalHelperAppService.doContent(
|
|
requestChannel.contentType,
|
|
aRequest,
|
|
null,
|
|
true
|
|
);
|
|
this.contentListener.onStartRequest(aRequest);
|
|
},
|
|
|
|
onStopRequest(aRequest, aStatusCode) {
|
|
this.contentListener.onStopRequest(aRequest, aStatusCode);
|
|
},
|
|
|
|
onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
|
|
this.contentListener.onDataAvailable(
|
|
aRequest,
|
|
aInputStream,
|
|
aOffset,
|
|
aCount
|
|
);
|
|
},
|
|
});
|
|
})
|
|
.catch(do_report_unexpected_exception);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
return new Promise(resolve => {
|
|
// Wait for the download to reach half of its progress.
|
|
let onchange = function () {
|
|
if (
|
|
!aDownload.stopped &&
|
|
!aDownload.canceled &&
|
|
aDownload.progress == 50
|
|
) {
|
|
aDownload.onchange = null;
|
|
resolve();
|
|
}
|
|
};
|
|
|
|
// Register for the notification, but also call the function directly in
|
|
// case the download already reached the expected progress.
|
|
aDownload.onchange = onchange;
|
|
onchange();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Waits for a download to make any amount of progress.
|
|
*
|
|
* @param aDownload
|
|
* The Download object to wait upon.
|
|
*
|
|
* @return {Promise}
|
|
* @resolves When the download has transfered any number of bytes.
|
|
* @rejects Never.
|
|
*/
|
|
function promiseDownloadStarted(aDownload) {
|
|
return new Promise(resolve => {
|
|
// Wait for the download to transfer some amount of bytes.
|
|
let onchange = function () {
|
|
if (
|
|
!aDownload.stopped &&
|
|
!aDownload.canceled &&
|
|
aDownload.currentBytes > 0
|
|
) {
|
|
aDownload.onchange = null;
|
|
resolve();
|
|
}
|
|
};
|
|
|
|
// Register for the notification, but also call the function directly in
|
|
// case the download already reached the expected progress.
|
|
aDownload.onchange = onchange;
|
|
onchange();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Waits for a download to finish.
|
|
*
|
|
* @param aDownload
|
|
* The Download object to wait upon.
|
|
*
|
|
* @return {Promise}
|
|
* @resolves When the download succeeded or errored.
|
|
* @rejects Never.
|
|
*/
|
|
function promiseDownloadFinished(aDownload) {
|
|
return new Promise(resolve => {
|
|
// Wait for the download to finish.
|
|
let onchange = function () {
|
|
if (aDownload.succeeded || aDownload.error) {
|
|
aDownload.onchange = null;
|
|
resolve();
|
|
}
|
|
};
|
|
|
|
// Register for the notification, but also call the function directly in
|
|
// case the download already reached the expected progress.
|
|
aDownload.onchange = onchange;
|
|
onchange();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async function promiseVerifyContents(aPath, aExpectedContents) {
|
|
let file = new FileUtils.File(aPath);
|
|
|
|
if (!(await IOUtils.exists(aPath))) {
|
|
do_throw("File does not exist: " + aPath);
|
|
}
|
|
|
|
if ((await IOUtils.stat(aPath)).size == 0) {
|
|
do_throw("File is empty: " + aPath);
|
|
}
|
|
|
|
await new Promise(resolve => {
|
|
NetUtil.asyncFetch(
|
|
{ uri: NetUtil.newURI(file), loadUsingSystemPrincipal: true },
|
|
function (aInputStream, aStatus) {
|
|
Assert.ok(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.
|
|
Assert.equal(contents.length, aExpectedContents.length);
|
|
Assert.ok(contents == aExpectedContents);
|
|
} else {
|
|
// Print the string if it is short and made of printable characters.
|
|
Assert.equal(contents, aExpectedContents);
|
|
}
|
|
resolve();
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @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:
|
|
* {
|
|
* useLegacySaver: Boolean indicating whether to launch a legacy download.
|
|
* }
|
|
*
|
|
* @return {Promise}
|
|
* @resolves The newly created Download object, still in progress.
|
|
* @rejects JavaScript exception.
|
|
*/
|
|
async function promiseStartDownload_tryToKeepPartialData({
|
|
useLegacySaver = false,
|
|
} = {}) {
|
|
mustInterruptResponses();
|
|
|
|
// Start a new download and configure it to keep partially downloaded data.
|
|
let download;
|
|
if (!useLegacySaver) {
|
|
let targetFilePath = getTempFile(TEST_TARGET_FILE_NAME).path;
|
|
download = await 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 = await promiseStartExternalHelperAppServiceDownload();
|
|
}
|
|
|
|
await promiseDownloadMidway(download);
|
|
await 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.
|
|
*/
|
|
async function promisePartFileReady(aDownload) {
|
|
// 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 {
|
|
await promiseTimeout(50);
|
|
} while (!(await IOUtils.exists(aDownload.target.partFilePath)));
|
|
} catch (ex) {
|
|
if (!DOMException.isInstance(ex)) {
|
|
throw ex;
|
|
}
|
|
// This indicates that the file has been created and cannot be accessed.
|
|
// The specific error might vary with the platform.
|
|
info("IOUtils exception while checking existence: " + ex.name);
|
|
// Wait some more time to allow the write to complete.
|
|
await promiseTimeout(100);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a download which will be reputation blocked.
|
|
*
|
|
* @param options
|
|
* {
|
|
* keepPartialData: bool,
|
|
* keepBlockedData: bool,
|
|
* useLegacySaver: bool,
|
|
* verdict: string indicating the detailed reason for the block,
|
|
* }
|
|
* @return {Promise}
|
|
* @resolves The reputation blocked download.
|
|
* @rejects JavaScript exception.
|
|
*/
|
|
async function promiseBlockedDownload({
|
|
keepPartialData,
|
|
keepBlockedData,
|
|
useLegacySaver,
|
|
verdict = Downloads.Error.BLOCK_VERDICT_UNCOMMON,
|
|
} = {}) {
|
|
let blockFn = () => ({
|
|
shouldBlockForReputationCheck: () =>
|
|
Promise.resolve({
|
|
shouldBlock: true,
|
|
verdict,
|
|
}),
|
|
shouldKeepBlockedData: () => Promise.resolve(keepBlockedData),
|
|
});
|
|
|
|
Integration.downloads.register(blockFn);
|
|
function cleanup() {
|
|
Integration.downloads.unregister(blockFn);
|
|
}
|
|
registerCleanupFunction(cleanup);
|
|
|
|
let download;
|
|
|
|
try {
|
|
if (keepPartialData) {
|
|
download = await promiseStartDownload_tryToKeepPartialData({
|
|
useLegacySaver,
|
|
});
|
|
continueResponses();
|
|
} else if (useLegacySaver) {
|
|
download = await promiseStartLegacyDownload();
|
|
} else {
|
|
download = await promiseNewDownload();
|
|
await download.start();
|
|
do_throw("The download should have blocked.");
|
|
}
|
|
|
|
await promiseDownloadStopped(download);
|
|
do_throw("The download should have blocked.");
|
|
} catch (ex) {
|
|
if (!(ex instanceof Downloads.Error) || !ex.becauseBlocked) {
|
|
throw ex;
|
|
}
|
|
Assert.ok(ex.becauseBlockedByReputationCheck);
|
|
Assert.equal(ex.reputationCheckVerdict, verdict);
|
|
Assert.ok(download.error.becauseBlockedByReputationCheck);
|
|
Assert.equal(download.error.reputationCheckVerdict, verdict);
|
|
}
|
|
|
|
Assert.ok(download.stopped);
|
|
Assert.ok(!download.succeeded);
|
|
|
|
cleanup();
|
|
return download;
|
|
}
|
|
|
|
/**
|
|
* 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(aServ, aTransport) {
|
|
aTransport.close(Cr.NS_BINDING_ABORTED);
|
|
},
|
|
onStopListening() {},
|
|
});
|
|
return serverSocket;
|
|
}
|
|
|
|
/**
|
|
* This is an internal reference that should not be used directly by tests.
|
|
*/
|
|
var _gDeferResponses = Promise.withResolvers();
|
|
|
|
/**
|
|
* 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();
|
|
|
|
info("Interruptible responses will be blocked midway.");
|
|
_gDeferResponses = Promise.withResolvers();
|
|
}
|
|
|
|
/**
|
|
* Allows all the current and future interruptible requests to complete.
|
|
*/
|
|
function continueResponses() {
|
|
info("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) {
|
|
info("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();
|
|
info("Interruptible request finished.");
|
|
})
|
|
.catch(console.error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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());
|
|
}
|
|
|
|
/**
|
|
* Check actual ReferrerInfo is the same as expected.
|
|
* Because the actual download's referrer info's computedReferrer is computed
|
|
* from referrerPolicy and originalReferrer and is non-null, and the expected
|
|
* referrer info was constructed in isolation and therefore the computedReferrer
|
|
* is null, it isn't possible to use equals here. */
|
|
function checkEqualReferrerInfos(aActualInfo, aExpectedInfo) {
|
|
Assert.equal(
|
|
!!aExpectedInfo.originalReferrer,
|
|
!!aActualInfo.originalReferrer
|
|
);
|
|
if (aExpectedInfo.originalReferrer && aActualInfo.originalReferrer) {
|
|
Assert.equal(
|
|
aExpectedInfo.originalReferrer.spec,
|
|
aActualInfo.originalReferrer.spec
|
|
);
|
|
}
|
|
|
|
Assert.equal(aExpectedInfo.sendReferrer, aActualInfo.sendReferrer);
|
|
Assert.equal(aExpectedInfo.referrerPolicy, aActualInfo.referrerPolicy);
|
|
}
|
|
|
|
/**
|
|
* Waits for the download annotations to be set for the given page, required
|
|
* because the addDownload method will add these to the database asynchronously.
|
|
*/
|
|
function waitForAnnotation(sourceUriSpec, annotationName, optionalValue) {
|
|
return TestUtils.waitForCondition(async () => {
|
|
let pageInfo = await PlacesUtils.history.fetch(sourceUriSpec, {
|
|
includeAnnotations: true,
|
|
});
|
|
if (optionalValue) {
|
|
return pageInfo?.annotations.get(annotationName) == optionalValue;
|
|
}
|
|
return pageInfo?.annotations.has(annotationName);
|
|
}, `Should have found annotation ${annotationName} for ${sourceUriSpec}`);
|
|
}
|
|
|
|
/**
|
|
* 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_setup(function test_common_initialize() {
|
|
// Start the HTTP server.
|
|
gHttpServer = new HttpServer();
|
|
gHttpServer.registerDirectory("/", do_get_file("../data"));
|
|
gHttpServer.start(-1);
|
|
registerCleanupFunction(() => {
|
|
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);
|
|
});
|
|
});
|
|
|
|
// Serve the downloads from a domain located in the Internet zone on Windows.
|
|
gHttpServer.identity.setPrimary(
|
|
"http",
|
|
"www.example.com",
|
|
gHttpServer.identity.primaryPort
|
|
);
|
|
Services.prefs.setCharPref("network.dns.localDomains", "www.example.com");
|
|
registerCleanupFunction(function () {
|
|
Services.prefs.clearUserPref("network.dns.localDomains");
|
|
});
|
|
|
|
// 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);
|
|
registerCleanupFunction(function () {
|
|
Services.prefs.clearUserPref("browser.cache.disk.enable");
|
|
Services.prefs.clearUserPref("browser.cache.memory.enable");
|
|
});
|
|
|
|
// Allow relaxing default referrer.
|
|
Services.prefs.setBoolPref(
|
|
"network.http.referer.disallowCrossSiteRelaxingDefault",
|
|
false
|
|
);
|
|
registerCleanupFunction(function () {
|
|
Services.prefs.clearUserPref(
|
|
"network.http.referer.disallowCrossSiteRelaxingDefault"
|
|
);
|
|
});
|
|
|
|
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_nosize.txt",
|
|
function firstPart(aRequest, aResponse) {
|
|
aResponse.setHeader("Content-Type", "text/plain", 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);
|
|
},
|
|
function secondPart(aRequest, aResponse) {
|
|
let bos = new BinaryOutputStream(aResponse.bodyOutputStream);
|
|
bos.writeByteArray(TEST_DATA_SHORT_GZIP_ENCODED_SECOND);
|
|
}
|
|
);
|
|
|
|
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();
|
|
}
|
|
);
|
|
|
|
gHttpServer.registerPathHandler("/busy.txt", function (aRequest, aResponse) {
|
|
aResponse.setStatusLine("1.1", 504, "Gateway Timeout");
|
|
aResponse.setHeader("Content-Type", "text/plain", false);
|
|
aResponse.setHeader("Content-Length", "" + TEST_DATA_SHORT.length, false);
|
|
aResponse.write(TEST_DATA_SHORT);
|
|
});
|
|
|
|
gHttpServer.registerPathHandler("/redirect", function (aRequest, aResponse) {
|
|
aResponse.setStatusLine("1.1", 301, "Moved Permanently");
|
|
aResponse.setHeader("Location", httpUrl("busy.txt"), false);
|
|
aResponse.setHeader("Content-Type", "text/javascript", false);
|
|
aResponse.setHeader("Content-Length", "0", false);
|
|
});
|
|
|
|
// 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"
|
|
);
|
|
}
|
|
);
|
|
|
|
// This URL sends some data followed by an RST packet
|
|
gHttpServer.registerPathHandler(
|
|
"/netreset.txt",
|
|
function (aRequest, aResponse) {
|
|
info("Starting response that will be aborted.");
|
|
aResponse.processAsync();
|
|
aResponse.setHeader("Content-Type", "text/plain", false);
|
|
aResponse.write(TEST_DATA_SHORT);
|
|
promiseExecuteSoon()
|
|
.then(() => {
|
|
aResponse.abort(null, true);
|
|
aResponse.finish();
|
|
info("Aborting response with network reset.");
|
|
})
|
|
.then(null, console.error);
|
|
}
|
|
);
|
|
|
|
// 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 => {
|
|
let override = {
|
|
loadPublicDownloadListFromStore: () => Promise.resolve(),
|
|
shouldKeepBlockedData: () => Promise.resolve(false),
|
|
shouldBlockForParentalControls: () => 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");
|
|
},
|
|
};
|
|
Object.setPrototypeOf(override, base);
|
|
return override;
|
|
});
|
|
|
|
// Make sure that downloads started using nsIExternalHelperAppService are
|
|
// saved to disk without asking for a destination interactively.
|
|
let mock = {
|
|
QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncherDialog"]),
|
|
promptForSaveToFileAsync(aLauncher) {
|
|
// 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
|
|
);
|
|
registerCleanupFunction(() => {
|
|
MockRegistrar.unregister(cid);
|
|
});
|
|
});
|