/* -*- 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 channel = aRequest.QueryInterface(Ci.nsIChannel);
        this.contentListener = gExternalHelperAppService.doContent(
                                     channel.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");
    },
  }));

  // Get a reference to nsIComponentRegistrar, and ensure that is is freed
  // before the XPCOM shutdown.
  let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
  do_register_cleanup(() => registrar = null);

  // 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);
  });
});
