/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
"use strict";

const { Downloads } = ChromeUtils.importESModule(
  "resource://gre/modules/Downloads.sys.mjs"
);

const { TestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/TestUtils.sys.mjs"
);

const server = createHttpServer();
server.registerDirectory("/data/", do_get_file("data"));

const ROOT = `http://localhost:${server.identity.primaryPort}`;
const BASE = `${ROOT}/data`;
const TXT_FILE = "file_download.txt";
const TXT_URL = BASE + "/" + TXT_FILE;

// Keep these in sync with code in interruptible.sjs
const INT_PARTIAL_LEN = 15;
const INT_TOTAL_LEN = 31;

const TEST_DATA = "This is 31 bytes of sample data";
const TOTAL_LEN = TEST_DATA.length;
const PARTIAL_LEN = 15;

// A handler to let us systematically test pausing/resuming/canceling
// of downloads.  This target represents a small text file but a simple
// GET will stall after sending part of the data, to give the test code
// a chance to pause or do other operations on an in-progress download.
// A resumed download (ie, a GET with a Range: header) will allow the
// download to complete.
function handleRequest(request, response) {
  response.setHeader("Content-Type", "text/plain", false);

  if (request.hasHeader("Range")) {
    let start, end;
    let matches = request
      .getHeader("Range")
      .match(/^\s*bytes=(\d+)?-(\d+)?\s*$/);
    if (matches != null) {
      start = matches[1] ? parseInt(matches[1], 10) : 0;
      end = matches[2] ? parseInt(matches[2], 10) : TOTAL_LEN - 1;
    }

    if (end == undefined || end >= TOTAL_LEN) {
      response.setStatusLine(
        request.httpVersion,
        416,
        "Requested Range Not Satisfiable"
      );
      response.setHeader("Content-Range", `*/${TOTAL_LEN}`, false);
      return;
    }

    response.setStatusLine(request.httpVersion, 206, "Partial Content");
    response.setHeader("Content-Range", `${start}-${end}/${TOTAL_LEN}`, false);
    response.write(TEST_DATA.slice(start, end + 1));
  } else if (request.queryString.includes("stream")) {
    response.processAsync();
    response.setHeader("Content-Length", "10000", false);
    response.write("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
    setInterval(() => {
      response.write("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
    }, 50);
  } else {
    response.processAsync();
    response.setHeader("Content-Length", `${TOTAL_LEN}`, false);
    response.write(TEST_DATA.slice(0, PARTIAL_LEN));
  }

  registerCleanupFunction(() => {
    try {
      response.finish();
    } catch (e) {
      // This can throw, but we don't care at this point.
    }
  });
}

server.registerPrefixHandler("/interruptible/", handleRequest);

let interruptibleCount = 0;
function getInterruptibleUrl(filename = "interruptible.html") {
  let n = interruptibleCount++;
  return `${ROOT}/interruptible/${filename}?count=${n}`;
}

function backgroundScript() {
  let events = new Set();
  let eventWaiter = null;

  browser.downloads.onCreated.addListener(data => {
    events.add({ type: "onCreated", data });
    if (eventWaiter) {
      eventWaiter();
    }
  });

  browser.downloads.onChanged.addListener(data => {
    events.add({ type: "onChanged", data });
    if (eventWaiter) {
      eventWaiter();
    }
  });

  browser.downloads.onErased.addListener(data => {
    events.add({ type: "onErased", data });
    if (eventWaiter) {
      eventWaiter();
    }
  });

  // Returns a promise that will resolve when the given list of expected
  // events have all been seen.  By default, succeeds only if the exact list
  // of expected events is seen in the given order.  options.exact can be
  // set to false to allow other events and options.inorder can be set to
  // false to allow the events to arrive in any order.
  function waitForEvents(expected, options = {}) {
    function compare(a, b) {
      if (typeof b == "object" && b != null) {
        if (typeof a != "object") {
          return false;
        }
        return Object.keys(b).every(fld => compare(a[fld], b[fld]));
      }
      return a == b;
    }

    const exact = "exact" in options ? options.exact : true;
    const inorder = "inorder" in options ? options.inorder : true;
    return new Promise((resolve, reject) => {
      function check() {
        function fail(msg) {
          browser.test.fail(msg);
          reject(new Error(msg));
        }
        if (events.size < expected.length) {
          return;
        }
        if (exact && expected.length < events.size) {
          fail(
            `Got ${events.size} events but only expected ${expected.length}`
          );
          return;
        }

        let remaining = new Set(events);
        if (inorder) {
          for (let event of events) {
            if (compare(event, expected[0])) {
              expected.shift();
              remaining.delete(event);
            }
          }
        } else {
          expected = expected.filter(val => {
            for (let remainingEvent of remaining) {
              if (compare(remainingEvent, val)) {
                remaining.delete(remainingEvent);
                return false;
              }
            }
            return true;
          });
        }

        // Events that did occur have been removed from expected so if
        // expected is empty, we're done.  If we didn't see all the
        // expected events and we're not looking for an exact match,
        // then we just may not have seen the event yet, so return without
        // failing and check() will be called again when a new event arrives.
        if (!expected.length) {
          events = remaining;
          eventWaiter = null;
          resolve();
        } else if (exact) {
          fail(
            `Mismatched event: expecting ${JSON.stringify(
              expected[0]
            )} but got ${JSON.stringify(Array.from(remaining)[0])}`
          );
        }
      }
      eventWaiter = check;
      check();
    });
  }

  browser.test.onMessage.addListener(async (msg, ...args) => {
    let match = msg.match(/(\w+).request$/);
    if (!match) {
      return;
    }

    let what = match[1];
    if (what == "waitForEvents") {
      try {
        await waitForEvents(...args);
        browser.test.sendMessage("waitForEvents.done", { status: "success" });
      } catch (error) {
        browser.test.sendMessage("waitForEvents.done", {
          status: "error",
          errmsg: error.message,
        });
      }
    } else if (what == "clearEvents") {
      events = new Set();
      browser.test.sendMessage("clearEvents.done", { status: "success" });
    } else {
      try {
        let result = await browser.downloads[what](...args);
        browser.test.sendMessage(`${what}.done`, { status: "success", result });
      } catch (error) {
        browser.test.sendMessage(`${what}.done`, {
          status: "error",
          errmsg: error.message,
        });
      }
    }
  });

  browser.test.sendMessage("ready");
}

let downloadDir;
let extension;

async function waitForCreatedPartFile(baseFilename = "interruptible.html") {
  const partFilePath = PathUtils.join(downloadDir.path, `${baseFilename}.part`);

  info(`Wait for ${partFilePath} to be created`);
  let lastError;
  await TestUtils.waitForCondition(
    () =>
      IOUtils.exists(partFilePath).catch(err => {
        lastError = err;
        return false;
      }),
    `Wait for the ${partFilePath} to exists before pausing the download`
  ).catch(err => {
    if (lastError) {
      throw lastError;
    }
    throw err;
  });
}

async function clearDownloads() {
  let list = await Downloads.getList(Downloads.ALL);
  let downloads = await list.getAll();

  await Promise.all(
    downloads.map(async download => {
      await download.finalize(true);
      list.remove(download);
    })
  );

  return downloads;
}

function runInExtension(what, ...args) {
  extension.sendMessage(`${what}.request`, ...args);
  return extension.awaitMessage(`${what}.done`);
}

// This is pretty simplistic, it looks for a progress update for a
// download of the given url in which the total bytes are exactly equal
// to the given value.  Unless you know exactly how data will arrive from
// the server (eg see interruptible.sjs), it probably isn't very useful.
async function waitForProgress(url, testFn) {
  let list = await Downloads.getList(Downloads.ALL);

  return new Promise(resolve => {
    const view = {
      onDownloadChanged(download) {
        if (download.source.url == url && testFn(download.currentBytes)) {
          list.removeView(view);
          resolve(download.currentBytes);
        }
      },
    };
    list.addView(view);
  });
}

add_setup(async () => {
  const nsIFile = Ci.nsIFile;
  downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
  downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
  info(`downloadDir ${downloadDir.path}`);

  Services.prefs.setIntPref("browser.download.folderList", 2);
  Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir);

  registerCleanupFunction(async () => {
    Services.prefs.clearUserPref("browser.download.folderList");
    Services.prefs.clearUserPref("browser.download.dir");
    await clearDownloads();
    downloadDir.remove(true);
  });

  await clearDownloads().then(downloads => {
    info(`removed ${downloads.length} pre-existing downloads from history`);
  });

  extension = ExtensionTestUtils.loadExtension({
    background: backgroundScript,
    manifest: {
      permissions: ["downloads"],
    },
  });

  await extension.startup();
  await extension.awaitMessage("ready");

  registerCleanupFunction(async () => {
    await extension.unload();
  });
});

add_task(async function test_events() {
  let msg = await runInExtension("download", { url: TXT_URL });
  equal(msg.status, "success", "download() succeeded");
  const id = msg.result;

  msg = await runInExtension("waitForEvents", [
    { type: "onCreated", data: { id, url: TXT_URL } },
    {
      type: "onChanged",
      data: {
        id,
        state: {
          previous: "in_progress",
          current: "complete",
        },
      },
    },
  ]);
  equal(msg.status, "success", "got onCreated and onChanged events");
});

add_task(async function test_cancel() {
  let url = getInterruptibleUrl();
  info(url);
  let msg = await runInExtension("download", { url });
  equal(msg.status, "success", "download() succeeded");
  const id = msg.result;

  let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN);

  msg = await runInExtension("waitForEvents", [
    { type: "onCreated", data: { id } },
  ]);
  equal(msg.status, "success", "got created and changed events");

  await progressPromise;
  info(`download reached ${INT_PARTIAL_LEN} bytes`);

  msg = await runInExtension("cancel", id);
  equal(msg.status, "success", "cancel() succeeded");

  // TODO bug 1256243: This sequence of events is bogus
  msg = await runInExtension("waitForEvents", [
    {
      type: "onChanged",
      data: {
        state: {
          previous: "in_progress",
          current: "interrupted",
        },
        paused: {
          previous: false,
          current: true,
        },
      },
    },
    {
      type: "onChanged",
      data: {
        id,
        error: {
          previous: null,
          current: "USER_CANCELED",
        },
      },
    },
    {
      type: "onChanged",
      data: {
        id,
        paused: {
          previous: true,
          current: false,
        },
      },
    },
  ]);
  equal(
    msg.status,
    "success",
    "got onChanged events corresponding to cancel()"
  );

  msg = await runInExtension("search", { error: "USER_CANCELED" });
  equal(msg.status, "success", "search() succeeded");
  equal(msg.result.length, 1, "search() found 1 download");
  equal(msg.result[0].id, id, "download.id is correct");
  equal(msg.result[0].state, "interrupted", "download.state is correct");
  equal(msg.result[0].paused, false, "download.paused is correct");
  equal(
    msg.result[0].estimatedEndTime,
    undefined,
    "download.estimatedEndTime is correct"
  );
  equal(msg.result[0].canResume, false, "download.canResume is correct");
  equal(msg.result[0].error, "USER_CANCELED", "download.error is correct");
  equal(
    msg.result[0].totalBytes,
    INT_TOTAL_LEN,
    "download.totalBytes is correct"
  );
  equal(msg.result[0].exists, false, "download.exists is correct");

  msg = await runInExtension("pause", id);
  equal(msg.status, "error", "cannot pause a canceled download");

  msg = await runInExtension("resume", id);
  equal(msg.status, "error", "cannot resume a canceled download");
});

add_task(async function test_pauseresume() {
  const filename = "pauseresume.html";
  let url = getInterruptibleUrl(filename);
  let msg = await runInExtension("download", { url });
  equal(msg.status, "success", "download() succeeded");
  const id = msg.result;

  let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN);

  msg = await runInExtension("waitForEvents", [
    { type: "onCreated", data: { id } },
  ]);
  equal(msg.status, "success", "got created and changed events");

  await progressPromise;
  info(`download reached ${INT_PARTIAL_LEN} bytes`);

  // Prevent intermittent timeouts due to the part file not yet created
  // (e.g. see Bug 1573360).
  await waitForCreatedPartFile(filename);

  info("Pause the download item");
  msg = await runInExtension("pause", id);
  equal(msg.status, "success", "pause() succeeded");

  msg = await runInExtension("waitForEvents", [
    {
      type: "onChanged",
      data: {
        id,
        state: {
          previous: "in_progress",
          current: "interrupted",
        },
        paused: {
          previous: false,
          current: true,
        },
        canResume: {
          previous: false,
          current: true,
        },
      },
    },
    {
      type: "onChanged",
      data: {
        id,
        error: {
          previous: null,
          current: "USER_CANCELED",
        },
      },
    },
  ]);
  equal(msg.status, "success", "got onChanged event corresponding to pause");

  msg = await runInExtension("search", { paused: true });
  equal(msg.status, "success", "search() succeeded");
  equal(msg.result.length, 1, "search() found 1 download");
  equal(msg.result[0].id, id, "download.id is correct");
  equal(msg.result[0].state, "interrupted", "download.state is correct");
  equal(msg.result[0].paused, true, "download.paused is correct");
  equal(
    msg.result[0].estimatedEndTime,
    undefined,
    "download.estimatedEndTime is correct"
  );
  equal(msg.result[0].canResume, true, "download.canResume is correct");
  equal(msg.result[0].error, "USER_CANCELED", "download.error is correct");
  equal(
    msg.result[0].bytesReceived,
    INT_PARTIAL_LEN,
    "download.bytesReceived is correct"
  );
  equal(
    msg.result[0].totalBytes,
    INT_TOTAL_LEN,
    "download.totalBytes is correct"
  );
  equal(msg.result[0].exists, false, "download.exists is correct");

  msg = await runInExtension("search", { error: "USER_CANCELED" });
  equal(msg.status, "success", "search() succeeded");
  let found = msg.result.filter(item => item.id == id);
  equal(found.length, 1, "search() by error found the paused download");

  msg = await runInExtension("pause", id);
  equal(msg.status, "error", "cannot pause an already paused download");

  msg = await runInExtension("resume", id);
  equal(msg.status, "success", "resume() succeeded");

  msg = await runInExtension("waitForEvents", [
    {
      type: "onChanged",
      data: {
        id,
        state: {
          previous: "interrupted",
          current: "in_progress",
        },
        paused: {
          previous: true,
          current: false,
        },
        canResume: {
          previous: true,
          current: false,
        },
        error: {
          previous: "USER_CANCELED",
          current: null,
        },
      },
    },
    {
      type: "onChanged",
      data: {
        id,
        state: {
          previous: "in_progress",
          current: "complete",
        },
      },
    },
  ]);
  equal(msg.status, "success", "got onChanged events for resume and complete");

  msg = await runInExtension("search", { id });
  equal(msg.status, "success", "search() succeeded");
  equal(msg.result.length, 1, "search() found 1 download");
  equal(msg.result[0].state, "complete", "download.state is correct");
  equal(msg.result[0].paused, false, "download.paused is correct");
  equal(
    msg.result[0].estimatedEndTime,
    undefined,
    "download.estimatedEndTime is correct"
  );
  equal(msg.result[0].canResume, false, "download.canResume is correct");
  equal(msg.result[0].error, null, "download.error is correct");
  equal(
    msg.result[0].bytesReceived,
    INT_TOTAL_LEN,
    "download.bytesReceived is correct"
  );
  equal(
    msg.result[0].totalBytes,
    INT_TOTAL_LEN,
    "download.totalBytes is correct"
  );
  equal(msg.result[0].exists, true, "download.exists is correct");

  msg = await runInExtension("pause", id);
  equal(msg.status, "error", "cannot pause a completed download");

  msg = await runInExtension("resume", id);
  equal(msg.status, "error", "cannot resume a completed download");
});

add_task(async function test_pausecancel() {
  let url = getInterruptibleUrl();
  let msg = await runInExtension("download", { url });
  equal(msg.status, "success", "download() succeeded");
  const id = msg.result;

  let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN);

  msg = await runInExtension("waitForEvents", [
    { type: "onCreated", data: { id } },
  ]);
  equal(msg.status, "success", "got created and changed events");

  await progressPromise;
  info(`download reached ${INT_PARTIAL_LEN} bytes`);

  msg = await runInExtension("pause", id);
  equal(msg.status, "success", "pause() succeeded");

  msg = await runInExtension("waitForEvents", [
    {
      type: "onChanged",
      data: {
        id,
        state: {
          previous: "in_progress",
          current: "interrupted",
        },
        paused: {
          previous: false,
          current: true,
        },
        canResume: {
          previous: false,
          current: true,
        },
      },
    },
    {
      type: "onChanged",
      data: {
        id,
        error: {
          previous: null,
          current: "USER_CANCELED",
        },
      },
    },
  ]);
  equal(msg.status, "success", "got onChanged event corresponding to pause");

  msg = await runInExtension("search", { paused: true });
  equal(msg.status, "success", "search() succeeded");
  equal(msg.result.length, 1, "search() found 1 download");
  equal(msg.result[0].id, id, "download.id is correct");
  equal(msg.result[0].state, "interrupted", "download.state is correct");
  equal(msg.result[0].paused, true, "download.paused is correct");
  equal(
    msg.result[0].estimatedEndTime,
    undefined,
    "download.estimatedEndTime is correct"
  );
  equal(msg.result[0].canResume, true, "download.canResume is correct");
  equal(msg.result[0].error, "USER_CANCELED", "download.error is correct");
  equal(
    msg.result[0].bytesReceived,
    INT_PARTIAL_LEN,
    "download.bytesReceived is correct"
  );
  equal(
    msg.result[0].totalBytes,
    INT_TOTAL_LEN,
    "download.totalBytes is correct"
  );
  equal(msg.result[0].exists, false, "download.exists is correct");

  msg = await runInExtension("search", { error: "USER_CANCELED" });
  equal(msg.status, "success", "search() succeeded");
  let found = msg.result.filter(item => item.id == id);
  equal(found.length, 1, "search() by error found the paused download");

  msg = await runInExtension("cancel", id);
  equal(msg.status, "success", "cancel() succeeded");

  msg = await runInExtension("waitForEvents", [
    {
      type: "onChanged",
      data: {
        id,
        paused: {
          previous: true,
          current: false,
        },
        canResume: {
          previous: true,
          current: false,
        },
      },
    },
  ]);
  equal(msg.status, "success", "got onChanged event for cancel");

  msg = await runInExtension("search", { id });
  equal(msg.status, "success", "search() succeeded");
  equal(msg.result.length, 1, "search() found 1 download");
  equal(msg.result[0].state, "interrupted", "download.state is correct");
  equal(msg.result[0].paused, false, "download.paused is correct");
  equal(
    msg.result[0].estimatedEndTime,
    undefined,
    "download.estimatedEndTime is correct"
  );
  equal(msg.result[0].canResume, false, "download.canResume is correct");
  equal(msg.result[0].error, "USER_CANCELED", "download.error is correct");
  equal(
    msg.result[0].totalBytes,
    INT_TOTAL_LEN,
    "download.totalBytes is correct"
  );
  equal(msg.result[0].exists, false, "download.exists is correct");
});

add_task(async function test_pause_resume_cancel_badargs() {
  let BAD_ID = 1000;

  let msg = await runInExtension("pause", BAD_ID);
  equal(msg.status, "error", "pause() failed with a bad download id");
  ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive");

  msg = await runInExtension("resume", BAD_ID);
  equal(msg.status, "error", "resume() failed with a bad download id");
  ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive");

  msg = await runInExtension("cancel", BAD_ID);
  equal(msg.status, "error", "cancel() failed with a bad download id");
  ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive");
});

add_task(async function test_file_removal() {
  let msg = await runInExtension("download", { url: TXT_URL });
  equal(msg.status, "success", "download() succeeded");
  const id = msg.result;

  msg = await runInExtension("waitForEvents", [
    { type: "onCreated", data: { id, url: TXT_URL } },
    {
      type: "onChanged",
      data: {
        id,
        state: {
          previous: "in_progress",
          current: "complete",
        },
      },
    },
  ]);

  equal(msg.status, "success", "got onCreated and onChanged events");

  msg = await runInExtension("removeFile", id);
  equal(msg.status, "success", "removeFile() succeeded");

  msg = await runInExtension("removeFile", id);
  equal(
    msg.status,
    "error",
    "removeFile() fails since the file was already removed."
  );
  equal(
    msg.errmsg,
    `Could not remove download id ${id} because the file doesn't exist`,
    "removeFile() failed on removed file."
  );

  msg = await runInExtension("removeFile", 1000);
  equal(
    msg.errmsg,
    "Invalid download id 1000",
    "removeFile() failed due to non-existent id"
  );
});

add_task(async function test_file_removeFile_permission_failure() {
  const inputDirname = "subdir_for_download";
  const inputFilename = "downloaded_filename.txt";
  const expectedDir = PathUtils.join(downloadDir.path, inputDirname);
  const expectedPath = PathUtils.join(expectedDir, inputFilename);

  let msg = await runInExtension("download", {
    url: TXT_URL,
    filename: `${inputDirname}/${inputFilename}`,
  });
  equal(msg.status, "success", "download() succeeded");
  const id = msg.result;

  msg = await runInExtension("waitForEvents", [
    { type: "onCreated", data: { id, url: TXT_URL } },
    {
      type: "onChanged",
      data: {
        id,
        state: {
          previous: "in_progress",
          current: "complete",
        },
      },
    },
  ]);

  equal(msg.status, "success", "got onCreated and onChanged events");

  msg = await runInExtension("search", { id });
  equal(msg.status, "success", "search() succeeded");
  equal(expectedPath, msg.result[0]?.filename, "Got expected filename");

  async function withUndeletableFileUnix(testRemoveFile) {
    try {
      // Temporarily make directory unreadable/inaccessible.
      await IOUtils.setPermissions(expectedDir, 0);
      // Remove should fail with Unix error 13 (EACCES).
      await testRemoveFile();
    } finally {
      await IOUtils.setPermissions(expectedDir, 0o777);
    }
  }
  async function withUndeletableFileWin(testRemoveFile) {
    // On Windows, a directory marked as read-only does not prevent the deletion
    // of its content. So we need an alternative approach here.
    let stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
      Ci.nsIFileInputStream
    );
    try {
      // Open a file handle. The file cannot be deleted until it is closed.
      stream.init(await IOUtils.getFile(expectedPath), -1, 0, 0);
      // Remove should fail with Win error 32 (ERROR_SHARING_VIOLATION).
      await testRemoveFile();
    } finally {
      stream.close();
    }
  }

  const withUndeletableFile =
    AppConstants.platform === "win"
      ? withUndeletableFileWin
      : withUndeletableFileUnix;

  let consoleOutput;
  await withUndeletableFile(async () => {
    consoleOutput = await promiseConsoleOutput(async () => {
      msg = await runInExtension("removeFile", id);
    });
  });

  equal(msg.status, "error", "removeFile() fails due to missing dir perms");
  // Verify that an unexpected error is redacted, with a useful error message
  // logged to the console.
  // Note: if we ever decide to make the error for permission failures more
  // useful, try to add a new test case for unexpected errors, even if
  // completely artificial such as mocking + breaking an internal API.
  equal(msg.errmsg, "An unexpected error occurred", "Error message redacted");

  AddonTestUtils.checkMessages(consoleOutput.messages, {
    expected: [{ message: /NotAllowedError/ }],
  });

  ok(await IOUtils.exists(expectedPath), "File exists before removeFile()");

  msg = await runInExtension("removeFile", id);
  equal(msg.status, "success", "removeFile() succeeded");

  equal(await IOUtils.exists(expectedPath), false, "File was really removed");

  // As a bonus: check that the re-created file can be deleted without issues.
  await IOUtils.writeUTF8(expectedPath, "content here");

  msg = await runInExtension("removeFile", id);
  equal(msg.status, "success", "removeFile() succeeded after recreation");

  equal(await IOUtils.exists(expectedPath), false, "File was removed again");
});

add_task(async function test_removal_of_incomplete_download() {
  const filename = "remove-incomplete.html";
  let url = getInterruptibleUrl(filename);
  let msg = await runInExtension("download", { url });
  equal(msg.status, "success", "download() succeeded");
  const id = msg.result;

  let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN);

  msg = await runInExtension("waitForEvents", [
    { type: "onCreated", data: { id } },
  ]);
  equal(msg.status, "success", "got created and changed events");

  await progressPromise;
  info(`download reached ${INT_PARTIAL_LEN} bytes`);

  // Prevent intermittent timeouts due to the part file not yet created
  // (e.g. see Bug 1573360).
  await waitForCreatedPartFile(filename);

  msg = await runInExtension("pause", id);
  equal(msg.status, "success", "pause() succeeded");

  msg = await runInExtension("waitForEvents", [
    {
      type: "onChanged",
      data: {
        id,
        state: {
          previous: "in_progress",
          current: "interrupted",
        },
        paused: {
          previous: false,
          current: true,
        },
        canResume: {
          previous: false,
          current: true,
        },
      },
    },
    {
      type: "onChanged",
      data: {
        id,
        error: {
          previous: null,
          current: "USER_CANCELED",
        },
      },
    },
  ]);
  equal(msg.status, "success", "got onChanged event corresponding to pause");

  msg = await runInExtension("removeFile", id);
  equal(msg.status, "error", "removeFile() on paused download failed");

  ok(
    /Cannot remove incomplete download/.test(msg.errmsg),
    "removeFile() failed due to download being incomplete"
  );

  msg = await runInExtension("resume", id);
  equal(msg.status, "success", "resume() succeeded");

  msg = await runInExtension("waitForEvents", [
    {
      type: "onChanged",
      data: {
        id,
        state: {
          previous: "interrupted",
          current: "in_progress",
        },
        paused: {
          previous: true,
          current: false,
        },
        canResume: {
          previous: true,
          current: false,
        },
        error: {
          previous: "USER_CANCELED",
          current: null,
        },
      },
    },
    {
      type: "onChanged",
      data: {
        id,
        state: {
          previous: "in_progress",
          current: "complete",
        },
      },
    },
  ]);
  equal(msg.status, "success", "got onChanged events for resume and complete");

  msg = await runInExtension("removeFile", id);
  equal(
    msg.status,
    "success",
    "removeFile() succeeded following completion of resumed download."
  );
});

// Test erase().  We don't do elaborate testing of the query handling
// since it uses the exact same engine as search() which is tested
// more thoroughly in test_chrome_ext_downloads_search.html
add_task(async function test_erase() {
  await clearDownloads();

  await runInExtension("clearEvents");

  async function download() {
    let msg = await runInExtension("download", { url: TXT_URL });
    equal(msg.status, "success", "download succeeded");
    let id = msg.result;

    msg = await runInExtension(
      "waitForEvents",
      [
        {
          type: "onChanged",
          data: { id, state: { current: "complete" } },
        },
      ],
      { exact: false }
    );
    equal(msg.status, "success", "download finished");

    return id;
  }

  let ids = {};
  ids.dl1 = await download();
  ids.dl2 = await download();
  ids.dl3 = await download();

  let msg = await runInExtension("search", {});
  equal(msg.status, "success", "search succeeded");
  equal(msg.result.length, 3, "search found 3 downloads");

  msg = await runInExtension("clearEvents");

  msg = await runInExtension("erase", { id: ids.dl1 });
  equal(msg.status, "success", "erase by id succeeded");

  msg = await runInExtension("waitForEvents", [
    { type: "onErased", data: ids.dl1 },
  ]);
  equal(msg.status, "success", "received onErased event");

  msg = await runInExtension("search", {});
  equal(msg.status, "success", "search succeeded");
  equal(msg.result.length, 2, "search found 2 downloads");

  msg = await runInExtension("erase", {});
  equal(msg.status, "success", "erase everything succeeded");

  msg = await runInExtension(
    "waitForEvents",
    [
      { type: "onErased", data: ids.dl2 },
      { type: "onErased", data: ids.dl3 },
    ],
    { inorder: false }
  );
  equal(msg.status, "success", "received 2 onErased events");

  msg = await runInExtension("search", {});
  equal(msg.status, "success", "search succeeded");
  equal(msg.result.length, 0, "search found 0 downloads");
});

function loadImage(img, data) {
  return new Promise(resolve => {
    img.src = data;
    img.onload = resolve;
  });
}

add_task(async function test_getFileIcon() {
  let webNav = Services.appShell.createWindowlessBrowser(false);
  let docShell = webNav.docShell;

  let system = Services.scriptSecurityManager.getSystemPrincipal();
  docShell.createAboutBlankDocumentViewer(system, system);

  let img = webNav.document.createElement("img");

  let msg = await runInExtension("download", { url: TXT_URL });
  equal(msg.status, "success", "download() succeeded");
  const id = msg.result;

  msg = await runInExtension("getFileIcon", id);
  if (AppConstants.platform === "linux" && Services.env.get("MOZ_HEADLESS")) {
    // Linux doesn't have moz-icon: support when headless. It relies on GTK.
    // See bug 1955705 to try to lift this restriction.
    equal(msg.status, "error", "getFileIcon() failed");
    webNav.close();
    return;
  }

  equal(msg.status, "success", "getFileIcon() succeeded");
  await loadImage(img, msg.result);
  equal(img.height, 32, "returns an icon with the right height");
  equal(img.width, 32, "returns an icon with the right width");

  msg = await runInExtension("waitForEvents", [
    { type: "onCreated", data: { id, url: TXT_URL } },
    { type: "onChanged" },
  ]);
  equal(msg.status, "success", "got events");

  msg = await runInExtension("getFileIcon", id);
  equal(msg.status, "success", "getFileIcon() succeeded");
  await loadImage(img, msg.result);
  equal(img.height, 32, "returns an icon with the right height after download");
  equal(img.width, 32, "returns an icon with the right width after download");

  msg = await runInExtension("getFileIcon", id + 100);
  equal(msg.status, "error", "getFileIcon() failed");
  ok(msg.errmsg.includes("Invalid download id"), "download id is invalid");

  msg = await runInExtension("getFileIcon", id, { size: 127 });
  equal(msg.status, "success", "getFileIcon() succeeded");
  await loadImage(img, msg.result);
  equal(img.height, 127, "returns an icon with the right custom height");
  equal(img.width, 127, "returns an icon with the right custom width");

  msg = await runInExtension("getFileIcon", id, { size: 1 });
  equal(msg.status, "success", "getFileIcon() succeeded");
  await loadImage(img, msg.result);
  equal(img.height, 1, "returns an icon with the right custom height");
  equal(img.width, 1, "returns an icon with the right custom width");

  msg = await runInExtension("getFileIcon", id, { size: "foo" });
  equal(msg.status, "error", "getFileIcon() fails");
  ok(msg.errmsg.includes("Error processing size"), "size is not a number");

  msg = await runInExtension("getFileIcon", id, { size: 0 });
  equal(msg.status, "error", "getFileIcon() fails");
  ok(msg.errmsg.includes("Error processing size"), "size is too small");

  msg = await runInExtension("getFileIcon", id, { size: 128 });
  equal(msg.status, "error", "getFileIcon() fails");
  ok(msg.errmsg.includes("Error processing size"), "size is too big");

  webNav.close();
});

add_task(async function test_estimatedendtime() {
  // Note we are not testing the actual value calculation of estimatedEndTime,
  // only whether it is null/non-null at the appropriate times.

  let url = `${getInterruptibleUrl()}&stream=1`;
  let msg = await runInExtension("download", { url });
  equal(msg.status, "success", "download() succeeded");
  const id = msg.result;

  let previousBytes = await waitForProgress(url, bytes => bytes > 0);
  await waitForProgress(url, bytes => bytes > previousBytes);

  msg = await runInExtension("search", { id });
  equal(msg.status, "success", "search() succeeded");
  equal(msg.result.length, 1, "search() found 1 download");
  ok(msg.result[0].estimatedEndTime, "download.estimatedEndTime is correct");
  Assert.greater(
    msg.result[0].bytesReceived,
    0,
    "download.bytesReceived is correct"
  );

  msg = await runInExtension("cancel", id);

  msg = await runInExtension("search", { id });
  equal(msg.status, "success", "search() succeeded");
  equal(msg.result.length, 1, "search() found 1 download");
  ok(!msg.result[0].estimatedEndTime, "download.estimatedEndTime is correct");
});

add_task(async function test_byExtension() {
  let msg = await runInExtension("download", { url: TXT_URL });
  equal(msg.status, "success", "download() succeeded");
  const id = msg.result;
  msg = await runInExtension("search", { id });

  equal(msg.result.length, 1, "search() found 1 download");
  equal(
    msg.result[0].byExtensionName,
    "Generated extension",
    "download.byExtensionName is correct"
  );
  equal(
    msg.result[0].byExtensionId,
    extension.id,
    "download.byExtensionId is correct"
  );
});
