Bug 1245602 - Implement chrome.downloads.pause(), .resume(), .cancel() r?kmag draft
authorAndrew Swan <aswan@mozilla.com>
Tue, 15 Mar 2016 05:51:27 -0700
changeset 340483 b5a5ce3f454fec7bfb30c2e807553cca0eb6594f
parent 339874 f0c0480732d36153e8839c7f17394d45f679f87d
child 340522 fc173b82ec1483475fd996a6cb57d9d2e878d615
push id12978
push useraswan@mozilla.com
push dateTue, 15 Mar 2016 12:51:44 +0000
reviewerskmag
bugs1245602
milestone48.0a1
Bug 1245602 - Implement chrome.downloads.pause(), .resume(), .cancel() r?kmag MozReview-Commit-ID: Cu2TNJRTZ07
toolkit/components/extensions/ext-downloads.js
toolkit/components/extensions/schemas/downloads.json
toolkit/components/extensions/test/mochitest/chrome.ini
toolkit/components/extensions/test/mochitest/interruptible.sjs
toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_misc.html
--- a/toolkit/components/extensions/ext-downloads.js
+++ b/toolkit/components/extensions/ext-downloads.js
@@ -21,21 +21,27 @@ Cu.import("resource://gre/modules/Extens
 const {
   ignoreEvent,
   runSafeSync,
   SingletonEventManager,
 } = ExtensionUtils;
 
 const DOWNLOAD_ITEM_FIELDS = ["id", "url", "referrer", "filename", "incognito",
                               "danger", "mime", "startTime", "endTime",
-                              "estimatedEndTime", "state", "canResume",
-                              "error", "bytesReceived", "totalBytes",
+                              "estimatedEndTime", "state",
+                              "paused", "canResume", "error",
+                              "bytesReceived", "totalBytes",
                               "fileSize", "exists",
                               "byExtensionId", "byExtensionName"];
 
+// Fields that we generate onChanged events for.
+const DOWNLOAD_ITEM_CHANGE_FIELDS = ["endTime", "state", "paused", "canResume",
+                                     "error", "exists"];
+
+
 class DownloadItem {
   constructor(id, download, extension) {
     this.id = id;
     this.download = download;
     this.extension = extension;
     this.prechange = {};
   }
 
@@ -47,23 +53,27 @@ class DownloadItem {
   get mime() { return this.download.contentType; }
   get startTime() { return this.download.startTime; }
   get endTime() { return null; } // TODO
   get estimatedEndTime() { return null; } // TODO
   get state() {
     if (this.download.succeeded) {
       return "complete";
     }
-    if (this.download.stopped) {
+    if (this.download.canceled) {
       return "interrupted";
     }
     return "in_progress";
   }
+  get paused() {
+    return this.download.canceled && this.download.hasPartialData && !this.download.error;
+  }
   get canResume() {
-    return this.download.stopped && this.download.hasPartialData;
+    return (this.download.stopped || this.download.canceled) &&
+      this.download.hasPartialData && !this.download.error;
   }
   get error() {
     if (!this.download.stopped || this.download.succeeded) {
       return null;
     }
     // TODO store this instead of calculating it
 
     if (this.download.error) {
@@ -109,17 +119,17 @@ class DownloadItem {
     return obj;
   }
 
   // When a change event fires, handlers can look at how an individual
   // field changed by comparing item.fieldname with item.prechange.fieldname.
   // After all handlers have been invoked, this gets called to store the
   // current values of all fields ahead of the next event.
   _change() {
-    for (let field of DOWNLOAD_ITEM_FIELDS) {
+    for (let field of DOWNLOAD_ITEM_CHANGE_FIELDS) {
       this.prechange[field] = this[field];
     }
   }
 }
 
 
 // DownloadMap maps back and forth betwen the numeric identifiers used in
 // the downloads WebExtension API and a Download object from the Downloads jsm.
@@ -153,17 +163,21 @@ const DownloadMap = {
             }
           },
 
           onDownloadChanged(download) {
             const item = self.byDownload.get(download);
             if (item == null) {
               Cu.reportError("Got onDownloadChanged for unknown download object");
             } else {
-              self.emit("change", item);
+              // We get the first one of these when the download is started.
+              // In this case, don't emit anything, just initialize prechange.
+              if (Object.keys(item.prechange).length > 0) {
+                self.emit("change", item);
+              }
               item._change();
             }
           },
         }).then(() => list.getAll())
           .then(downloads => {
             downloads.forEach(download => {
               this.newFromDownload(download, null);
             });
@@ -298,18 +312,19 @@ function downloadQuery(query) {
     if (item.totalBytes == -1) {
       if (query.totalBytesGreater != null || query.totalBytesLess != null) {
         return false;
       }
     } else if (item.totalBytes <= totalBytesGreater || item.totalBytes >= totalBytesLess) {
       return false;
     }
 
-    // todo: include danger, paused, error
+    // todo: include danger
     const SIMPLE_ITEMS = ["id", "mime", "startTime", "endTime", "state",
+                          "paused", "error",
                           "bytesReceived", "totalBytes", "fileSize", "exists"];
     for (let field of SIMPLE_ITEMS) {
       if (query[field] != null && item[field] != query[field]) {
         return false;
       }
     }
 
     return true;
@@ -372,17 +387,20 @@ extensions.registerSchemaAPI("downloads"
           });
         }
 
         let download;
         return Downloads.getPreferredDownloadsDirectory()
           .then(downloadsDir => createTarget(downloadsDir))
           .then(target => Downloads.createDownload({
             source: options.url,
-            target: target,
+            target: {
+              path: target,
+              partFilePath: target + ".part",
+            },
           })).then(dl => {
             download = dl;
             return DownloadMap.getDownloadList();
           }).then(list => {
             list.add(download);
 
             // This is necessary to make pause/resume work.
             download.tryToKeepPartialData = true;
@@ -441,16 +459,57 @@ extensions.registerSchemaAPI("downloads"
             if (matchFn(download)) {
               results.push(download.serialize());
             }
           }
           return results;
         });
       },
 
+      pause(id) {
+        return DownloadMap.lazyInit().then(() => {
+          let item = DownloadMap.fromId(id);
+          if (!item) {
+            return Promise.reject({message: `Invalid download id ${id}`});
+          }
+          if (item.state != "in_progress") {
+            return Promise.reject({message: `Download ${id} cannot be paused since it is in state ${item.state}`});
+          }
+
+          return item.download.cancel();
+        });
+      },
+
+      resume(id) {
+        return DownloadMap.lazyInit().then(() => {
+          let item = DownloadMap.fromId(id);
+          if (!item) {
+            return Promise.reject({message: `Invalid download id ${id}`});
+          }
+          if (!item.canResume) {
+            return Promise.reject({message: `Download ${id} cannot be resumed`});
+          }
+
+          return item.download.start();
+        });
+      },
+
+      cancel(id) {
+        return DownloadMap.lazyInit().then(() => {
+          let item = DownloadMap.fromId(id);
+          if (!item) {
+            return Promise.reject({message: `Invalid download id ${id}`});
+          }
+          if (item.download.succeeded) {
+            return Promise.reject({message: `Download ${id} is already complete`});
+          }
+          return item.download.finalize(true);
+        });
+      },
+
       showDefaultFolder() {
         Downloads.getPreferredDownloadsDirectory().then(dir => {
           let dirobj = new FileUtils.File(dir);
           if (dirobj.isDirectory()) {
             dirobj.launch();
           } else {
             throw new Error(`Download directory ${dirobj.path} is not actually a directory`);
           }
@@ -464,24 +523,29 @@ extensions.registerSchemaAPI("downloads"
       //     throw new context.cloneScope.Error("Permission denied because 'downloads.open' permission is missing.");
       //   }
       //   ...
       // }
       // likewise for setShelfEnabled() and the "download.shelf" permission
 
       onChanged: new SingletonEventManager(context, "downloads.onChanged", fire => {
         const handler = (what, item) => {
-          if (item.state != item.prechange.state) {
-            runSafeSync(context, fire, {
-              id: item.id,
-              state: {
-                previous: item.prechange.state || null,
-                current: item.state,
-              },
-            });
+          let changes = {};
+          const noundef = val => (val === undefined) ? null : val;
+          DOWNLOAD_ITEM_CHANGE_FIELDS.forEach(fld => {
+            if (item[fld] != item.prechange[fld]) {
+              changes[fld] = {
+                previous: noundef(item.prechange[fld]),
+                current: noundef(item[fld]),
+              };
+            }
+          });
+          if (Object.keys(changes).length > 0) {
+            changes.id = item.id;
+            runSafeSync(context, fire, changes);
           }
         };
 
         let registerPromise = DownloadMap.getDownloadList().then(() => {
           DownloadMap.on("change", handler);
         });
         return () => {
           registerPromise.then(() => {
--- a/toolkit/components/extensions/schemas/downloads.json
+++ b/toolkit/components/extensions/schemas/downloads.json
@@ -453,17 +453,17 @@
               }
             ]
           }
         ]
       },
       {
         "name": "pause",
         "type": "function",
-        "unsupported": true,
+        "async": "callback",
         "description": "Pause the download. If the request was successful the download is in a paused state. Otherwise <a href='extension.html#property-lastError'>chrome.extension.lastError</a> contains an error message. The request will fail if the download is not active.",
         "parameters": [
           {
             "description": "The id of the download to pause.",
             "name": "downloadId",
             "type": "integer"
           },
           {
@@ -472,17 +472,17 @@
             "parameters": [],
             "type": "function"
           }
         ]
       },
       {
         "name": "resume",
         "type": "function",
-        "unsupported": true,
+        "async": "callback",
         "description": "Resume a paused download. If the request was successful the download is in progress and unpaused. Otherwise <a href='extension.html#property-lastError'>chrome.extension.lastError</a> contains an error message. The request will fail if the download is not active.",
         "parameters": [
           {
             "description": "The id of the download to resume.",
             "name": "downloadId",
             "type": "integer"
           },
           {
@@ -491,17 +491,17 @@
             "parameters": [],
             "type": "function"
           }
         ]
       },
       {
         "name": "cancel",
         "type": "function",
-        "unsupported": true,
+        "async": "callback",
         "description": "Cancel a download. When <code>callback</code> is run, the download is cancelled, completed, interrupted or doesn't exist anymore.",
         "parameters": [
           {
             "description": "The id of the download to cancel.",
             "name": "downloadId",
             "type": "integer"
           },
           {
--- a/toolkit/components/extensions/test/mochitest/chrome.ini
+++ b/toolkit/components/extensions/test/mochitest/chrome.ini
@@ -1,9 +1,10 @@
 [DEFAULT]
 skip-if = os == 'android'
 support-files =
   file_download.html
   file_download.txt
+  interruptible.sjs
 
 [test_chrome_ext_downloads_download.html]
 [test_chrome_ext_downloads_misc.html]
 [test_chrome_ext_downloads_search.html]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/interruptible.sjs
@@ -0,0 +1,38 @@
+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] ? pareInt(matchs[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);
+      response.finish();
+      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 {
+    response.processAsync();
+    response.setHeader("Content-Length", `${TOTAL_LEN}`, false);
+    response.write(TEST_DATA.slice(0, PARTIAL_LEN));
+  }
+}
--- a/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_misc.html
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_misc.html
@@ -20,16 +20,20 @@ const {
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/FileUtils.jsm");
 Cu.import("resource://gre/modules/Downloads.jsm");
 
 const BASE = "http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest";
 const TXT_FILE = "file_download.txt";
 const TXT_URL = BASE + "/" + TXT_FILE;
+const INTERRUPTIBLE_URL = BASE + "/interruptible.sjs";
+// Keep these in sync with code in interruptible.sjs
+const INT_PARTIAL_LEN = 15;
+const INT_TOTAL_LEN = 31;
 
 function backgroundScript() {
   let events = [];
   let eventWaiter = null;
 
   browser.downloads.onCreated.addListener(data => {
     events.push({type: "onCreated", data});
     if (eventWaiter) {
@@ -42,16 +46,19 @@ function backgroundScript() {
     if (eventWaiter) {
       eventWaiter();
     }
   });
 
   function waitForEvents(expected) {
     function compare(received, expected) {
       if (typeof expected == "object" && expected != null) {
+        if (typeof received != "object") {
+          return false;
+        }
         return Object.keys(expected).every(fld => compare(received[fld], expected[fld]));
       }
       return (received == expected);
     }
     return new Promise((resolve, reject) => {
       function check() {
         if (events.length < expected.length) {
           return;
@@ -70,30 +77,37 @@ function backgroundScript() {
         eventWaiter = null;
         resolve();
       }
       eventWaiter = check;
       check();
     });
   }
 
-  browser.test.onMessage.addListener(function(msg) {
-    // extension functions throw on bad arguments, we can remove the extra
-    // promise when bug 1250223 is fixed.
-    if (msg == "download.request") {
-      Promise.resolve().then(() => browser.downloads.download(arguments[1]))
-                       .then(id => {
-                         browser.test.sendMessage("download.done", {status: "success", id});
-                       })
-                       .catch(error => {
-                         browser.test.sendMessage("download.done", {status: "error", errmsg: error.message});
-                       });
-    } else if (msg == "waitForEvents.request") {
+  browser.test.onMessage.addListener(function(msg, ...args) {
+    let match = msg.match(/(\w+).request$/);
+    if (!match) {
+      return;
+    }
+    let what = match[1];
+    if (what == "waitForEvents") {
       waitForEvents(arguments[1]).then(() => {
         browser.test.sendMessage("waitForEvents.done", {status: "success"});
+      }).catch(error => {
+        browser.test.sendMessage("waitForEvents.done", {status: "error", errmsg: error.message});
+      });
+    } else {
+      // extension functions throw on bad arguments, we can remove the extra
+      // promise when bug 1250223 is fixed.
+      Promise.resolve().then(() => {
+        return browser.downloads[what](...args);
+      }).then(result => {
+        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;
@@ -103,17 +117,41 @@ function clearDownloads(callback) {
   return Downloads.getList(Downloads.ALL).then(list => {
     return list.getAll().then(downloads => {
       return Promise.all(downloads.map(download => list.remove(download)))
                     .then(() => downloads);
     });
   });
 }
 
-function* setup(backgroundScript) {
+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.
+function waitForProgress(url, bytes) {
+  return Downloads.getList(Downloads.ALL)
+                  .then(list => new Promise(resolve => {
+                    const view = {
+                      onDownloadChanged(download) {
+                        if (download.source.url == url && download.currentBytes == bytes) {
+                          list.removeView(view);
+                          resolve();
+                        }
+                      },
+                    };
+                    list.addView(view);
+                  }));
+}
+
+add_task(function* setup() {
   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);
 
@@ -133,55 +171,305 @@ function* setup(backgroundScript) {
     manifest: {
       permissions: ["downloads"],
     },
   });
 
   yield extension.startup();
   yield extension.awaitMessage("ready");
   info("extension started");
-}
-
-function runInExtension(what, args) {
-  extension.sendMessage(`${what}.request`, args);
-  return extension.awaitMessage(`${what}.done`);
-}
+});
 
-
-add_task(function* test_misc() {
-  yield setup(backgroundScript);
-
+add_task(function* test_events() {
   let msg = yield runInExtension("download", {url: TXT_URL});
-  is(msg.status, "success", "downoad succeeded");
-  const id = msg.id;
+  is(msg.status, "success", "download() succeeded");
+  const id = msg.result;
 
   msg = yield runInExtension("waitForEvents", [
     {type: "onCreated", data: {id, url: TXT_URL}},
     {
       type: "onChanged",
       data: {
         id,
         state: {
+          previous: "in_progress",
+          current: "complete",
+        },
+      },
+    },
+  ]);
+  is(msg.status, "success", "got onCreated and onChanged events");
+});
+
+add_task(function* test_cancel() {
+  let msg = yield runInExtension("download", {url: INTERRUPTIBLE_URL});
+  is(msg.status, "success", "download() succeeded");
+  const id = msg.result;
+
+  let progressPromise = waitForProgress(INTERRUPTIBLE_URL, INT_PARTIAL_LEN);
+
+  msg = yield runInExtension("waitForEvents", [
+    {type: "onCreated", data: {id}},
+  ]);
+  is(msg.status, "success", "got created and changed events");
+
+  yield progressPromise;
+  info(`download reached ${INT_PARTIAL_LEN} bytes`);
+
+  msg = yield runInExtension("cancel", id);
+  is(msg.status, "success", "cancel() succeeded");
+
+  // This sequence of events is bogus (bug 1256243)
+  msg = yield 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",
+        },
+      },
+    }]);
+  is(msg.status, "success", "got onChanged event corresponding to pause");
+
+  msg = yield runInExtension("search", {error: "USER_CANCELED"});
+  is(msg.status, "success", "search() succeeded");
+  is(msg.result.length, 1, "search() found 1 download");
+  is(msg.result[0].id, id, "download.id is correct");
+  is(msg.result[0].state, "interrupted", "download.state is correct");
+  is(msg.result[0].paused, false, "download.paused is correct");
+  is(msg.result[0].canResume, false, "download.canResume is correct");
+  is(msg.result[0].error, "USER_CANCELED", "download.error is correct");
+  is(msg.result[0].totalBytes, INT_TOTAL_LEN, "download.totalBytes is correct");
+  is(msg.result[0].exists, false, "download.exists is correct");
+
+  msg = yield runInExtension("pause", id);
+  is(msg.status, "error", "cannot pause a canceled download");
+
+  msg = yield runInExtension("resume", id);
+  is(msg.status, "error", "cannot resume a canceled download");
+});
+
+add_task(function* test_pauseresume() {
+  let msg = yield runInExtension("download", {url: INTERRUPTIBLE_URL});
+  is(msg.status, "success", "download() succeeded");
+  const id = msg.result;
+
+  let progressPromise = waitForProgress(INTERRUPTIBLE_URL, INT_PARTIAL_LEN);
+
+  msg = yield runInExtension("waitForEvents", [
+    {type: "onCreated", data: {id}},
+  ]);
+  is(msg.status, "success", "got created and changed events");
+
+  yield progressPromise;
+  info(`download reached ${INT_PARTIAL_LEN} bytes`);
+
+  msg = yield runInExtension("pause", id);
+  is(msg.status, "success", "pause() succeeded");
+
+  msg = yield runInExtension("waitForEvents", [
+    {
+      type: "onChanged",
+      data: {
+        id,
+        state: {
+          previous: "in_progress",
+          current: "interrupted",
+        },
+        paused: {
+          previous: false,
+          current: true,
+        },
+        canResume: {
+          previous: false,
+          current: true,
+        },
+      },
+    }]);
+  is(msg.status, "success", "got onChanged event corresponding to pause");
+
+  msg = yield runInExtension("search", {paused: true});
+  is(msg.status, "success", "search() succeeded");
+  is(msg.result.length, 1, "search() found 1 download");
+  is(msg.result[0].id, id, "download.id is correct");
+  is(msg.result[0].state, "interrupted", "download.state is correct");
+  is(msg.result[0].paused, true, "download.paused is correct");
+  is(msg.result[0].canResume, true, "download.canResume is correct");
+  is(msg.result[0].error, "USER_CANCELED", "download.error is correct");
+  is(msg.result[0].bytesReceived, INT_PARTIAL_LEN, "download.bytesReceived is correct");
+  is(msg.result[0].totalBytes, INT_TOTAL_LEN, "download.totalBytes is correct");
+  is(msg.result[0].exists, false, "download.exists is correct");
+
+  msg = yield runInExtension("search", {error: "USER_CANCELED"});
+  is(msg.status, "success", "search() succeeded");
+  let found = msg.result.filter(item => item.id == id);
+  is(found.length, 1, "search() by error found the paused download");
+
+  msg = yield runInExtension("pause", id);
+  is(msg.status, "error", "cannot pause an already paused download");
+
+  msg = yield runInExtension("resume", id);
+  is(msg.status, "success", "resume() succeeded");
+
+  msg = yield 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",
         },
       },
     },
   ]);
-  is(msg.status, "success", "got onCreated and onChanged events");
+  is(msg.status, "success", "got onChanged events for resume and complete");
+
+  msg = yield runInExtension("search", {id});
+  is(msg.status, "success", "search() succeeded");
+  is(msg.result.length, 1, "search() found 1 download");
+  is(msg.result[0].state, "complete", "download.state is correct");
+  is(msg.result[0].paused, false, "download.paused is correct");
+  is(msg.result[0].canResume, false, "download.canResume is correct");
+  is(msg.result[0].error, null, "download.error is correct");
+  is(msg.result[0].bytesReceived, INT_TOTAL_LEN, "download.bytesReceived is correct");
+  is(msg.result[0].totalBytes, INT_TOTAL_LEN, "download.totalBytes is correct");
+  is(msg.result[0].exists, true, "download.exists is correct");
+
+  msg = yield runInExtension("pause", id);
+  is(msg.status, "error", "cannot pause a completed download");
+
+  msg = yield runInExtension("resume", id);
+  is(msg.status, "error", "cannot resume a completed download");
+});
+
+add_task(function* test_pausecancel() {
+  let msg = yield runInExtension("download", {url: INTERRUPTIBLE_URL});
+  is(msg.status, "success", "download() succeeded");
+  const id = msg.result;
+
+  let progressPromise = waitForProgress(INTERRUPTIBLE_URL, INT_PARTIAL_LEN);
+
+  msg = yield runInExtension("waitForEvents", [
+    {type: "onCreated", data: {id}},
+  ]);
+  is(msg.status, "success", "got created and changed events");
+
+  yield progressPromise;
+  info(`download reached ${INT_PARTIAL_LEN} bytes`);
+
+  msg = yield runInExtension("pause", id);
+  is(msg.status, "success", "pause() succeeded");
 
+  msg = yield runInExtension("waitForEvents", [
+    {
+      type: "onChanged",
+      data: {
+        id,
+        state: {
+          previous: "in_progress",
+          current: "interrupted",
+        },
+        paused: {
+          previous: false,
+          current: true,
+        },
+        canResume: {
+          previous: false,
+          current: true,
+        },
+      },
+    }]);
+  is(msg.status, "success", "got onChanged event corresponding to pause");
+
+  msg = yield runInExtension("search", {paused: true});
+  is(msg.status, "success", "search() succeeded");
+  is(msg.result.length, 1, "search() found 1 download");
+  is(msg.result[0].id, id, "download.id is correct");
+  is(msg.result[0].state, "interrupted", "download.state is correct");
+  is(msg.result[0].paused, true, "download.paused is correct");
+  is(msg.result[0].canResume, true, "download.canResume is correct");
+  is(msg.result[0].error, "USER_CANCELED", "download.error is correct");
+  is(msg.result[0].bytesReceived, INT_PARTIAL_LEN, "download.bytesReceived is correct");
+  is(msg.result[0].totalBytes, INT_TOTAL_LEN, "download.totalBytes is correct");
+  is(msg.result[0].exists, false, "download.exists is correct");
+
+  msg = yield runInExtension("search", {error: "USER_CANCELED"});
+  is(msg.status, "success", "search() succeeded");
+  let found = msg.result.filter(item => item.id == id);
+  is(found.length, 1, "search() by error found the paused download");
+
+  msg = yield runInExtension("cancel", id);
+  is(msg.status, "success", "cancel() succeeded");
+
+  msg = yield runInExtension("waitForEvents", [
+    {
+      type: "onChanged",
+      data: {
+        id,
+        paused: {
+          previous: true,
+          current: false,
+        },
+        canResume: {
+          previous: true,
+          current: false,
+        },
+      },
+    },
+  ]);
+  is(msg.status, "success", "got onChanged event for cancel");
+
+  msg = yield runInExtension("search", {id});
+  is(msg.status, "success", "search() succeeded");
+  is(msg.result.length, 1, "search() found 1 download");
+  is(msg.result[0].state, "interrupted", "download.state is correct");
+  is(msg.result[0].paused, false, "download.paused is correct");
+  is(msg.result[0].canResume, false, "download.canResume is correct");
+  is(msg.result[0].error, "USER_CANCELED", "download.error is correct");
+  is(msg.result[0].totalBytes, INT_TOTAL_LEN, "download.totalBytes is correct");
+  is(msg.result[0].exists, false, "download.exists is correct");
+});
+
+add_task(function* cleanup() {
   yield extension.unload();
 });
 
 </script>
 
 </body>
 </html>