bug 1245603 - Implement browser.downloads.search() r=kmag draft
authorAndrew Swan <aswan@mozilla.com>
Wed, 02 Mar 2016 10:23:55 -0800
changeset 336554 125def3b3fcec728762915165e88a96e860d5048
parent 335498 04634ec900b2fb94962733148ecdeac7ae98e0e2
child 515446 05b17650e6942cd68960136acb8363622a939f97
push id12119
push useraswan@mozilla.com
push dateThu, 03 Mar 2016 19:08:43 +0000
reviewerskmag
bugs1245603
milestone47.0a1
bug 1245603 - Implement browser.downloads.search() r=kmag MozReview-Commit-ID: 9XqkfZyeS8X
toolkit/components/extensions/ext-downloads.js
toolkit/components/extensions/schemas/downloads.json
toolkit/components/extensions/test/mochitest/chrome.ini
toolkit/components/extensions/test/mochitest/file_download.html
toolkit/components/extensions/test/mochitest/mochitest.ini
toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_search.html
--- a/toolkit/components/extensions/ext-downloads.js
+++ b/toolkit/components/extensions/ext-downloads.js
@@ -15,17 +15,277 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 const {
   ignoreEvent,
 } = ExtensionUtils;
 
-let currentId = 0;
+const DOWNLOAD_ITEM_FIELDS = ["id", "url", "referrer", "filename", "incognito",
+                              "danger", "mime", "startTime", "endTime",
+                              "estimatedEndTime", "state", "canResume",
+                              "error", "bytesReceived", "totalBytes",
+                              "fileSize", "exists",
+                              "byExtensionId", "byExtensionName"];
+
+class DownloadItem {
+  constructor(id, download, extension) {
+    this.id = id;
+    this.download = download;
+    this.extension = extension;
+  }
+
+  get url() { return this.download.source.url; }
+  get referrer() { return this.download.source.referrer; }
+  get filename() { return this.download.target.path; }
+  get incognito() { return this.download.source.isPrivate; }
+  get danger() { return "safe"; } // TODO
+  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) {
+      return "interrupted";
+    }
+    return "in_progress";
+  }
+  get canResume() {
+    return this.download.stopped && this.download.hasPartialData;
+  }
+  get error() {
+    if (!this.download.stopped || this.download.succeeded) {
+      return null;
+    }
+    // TODO store this instead of calculating it
+
+    if (this.download.error) {
+      if (this.download.error.becauseSourceFailed) {
+        return "NETWORK_FAILED"; // TODO
+      }
+      if (this.download.error.becauseTargetFailed) {
+        return "FILE_FAILED"; // TODO
+      }
+      return "CRASH";
+    }
+    return "USER_CANCELED";
+  }
+  get bytesReceived() {
+    return this.download.currentBytes;
+  }
+  get totalBytes() {
+    return this.download.hasProgress ? this.download.totalBytes : -1;
+  }
+  get fileSize() {
+    // todo: this is supposed to be post-compression
+    return this.download.succeeded ? this.download.target.size : -1;
+  }
+  get exists() { return this.download.target.exists; }
+  get byExtensionId() { return this.extension ? this.extension.id : undefined; }
+  get byExtensionName() { return this.extension ? this.extension.name : undefined; }
+
+  /**
+   * Create a cloneable version of this object by pulling all the
+   * fields into simple properties (instead of getters).
+   *
+   * @returns {object} A DownloadItem with flat properties,
+   *                   suitable for cloning.
+   */
+  serialize() {
+    let obj = {};
+    for (let field of DOWNLOAD_ITEM_FIELDS) {
+      obj[field] = this[field];
+    }
+    if (obj.startTime) {
+      obj.startTime = obj.startTime.toISOString();
+    }
+    return obj;
+  }
+}
+
+
+// DownloadMap maps back and forth betwen the numeric identifiers used in
+// the downloads WebExtension API and a Download object from the Downloads jsm.
+// todo: make id and extension info persistent (bug 1247794)
+const DownloadMap = {
+  currentId: 0,
+  loadPromise: null,
+
+  // Maps numeric id -> DownloadItem
+  byId: new Map(),
+
+  // Maps Download object -> DownloadItem
+  byDownload: new WeakMap(),
+
+  lazyInit() {
+    if (this.loadPromise == null) {
+      this.loadPromise = Downloads.getList(Downloads.ALL).then(list => {
+        let self = this;
+        return list.addView({
+          onDownloadAdded(download) {
+            self.newFromDownload(download, null);
+          },
+
+          onDownloadRemoved(download) {
+            const item = self.byDownload.get(download);
+            if (item != null) {
+              self.byDownload.delete(download);
+              self.byId.delete(item.id);
+            }
+          },
+        }).then(() => list.getAll())
+          .then(downloads => {
+            downloads.forEach(download => {
+              this.newFromDownload(download, null);
+            });
+          })
+          .then(() => list);
+      });
+    }
+    return this.loadPromise;
+  },
+
+  getDownloadList() {
+    return this.lazyInit();
+  },
+
+  getAll() {
+    return this.lazyInit().then(() => this.byId.values());
+  },
+
+  fromId(id) {
+    const download = this.byId.get(id);
+    if (!download) {
+      throw new Error(`Invalid download id ${id}`);
+    }
+    return download;
+  },
+
+  newFromDownload(download, extension) {
+    if (this.byDownload.has(download)) {
+      return this.byDownload.get(download);
+    }
+
+    const id = ++this.currentId;
+    let item = new DownloadItem(id, download, extension);
+    this.byId.set(id, item);
+    this.byDownload.set(download, item);
+    return item;
+  },
+};
+
+// Create a callable function that filters a DownloadItem based on a
+// query object of the type passed to search() or erase().
+function downloadQuery(query) {
+  let queryTerms = [];
+  let queryNegativeTerms = [];
+  if (query.query != null) {
+    for (let term of query.query) {
+      if (term[0] == "-") {
+        queryNegativeTerms.push(term.slice(1).toLowerCase());
+      } else {
+        queryTerms.push(term.toLowerCase());
+      }
+    }
+  }
+
+  function normalizeTime(arg, before) {
+    if (arg == null) {
+      return before ? Number.MAX_VALUE : 0;
+    }
+    return parseInt(arg, 10);
+  }
+
+  const startedBefore = normalizeTime(query.startedBefore, true);
+  const startedAfter = normalizeTime(query.startedAfter, false);
+  // const endedBefore = normalizeTime(query.endedBefore, true);
+  // const endedAfter = normalizeTime(query.endedAfter, false);
+
+  const totalBytesGreater = query.totalBytesGreater || 0;
+  const totalBytesLess = (query.totalBytesLess != null)
+        ? query.totalBytesLess : Number.MAX_VALUE;
+
+  // Handle options for which we can have a regular expression and/or
+  // an explicit value to match.
+  function makeMatch(regex, value, field) {
+    if (value == null && regex == null) {
+      return input => true;
+    }
+
+    let re;
+    try {
+      re = new RegExp(regex || "", "i");
+    } catch (err) {
+      throw new Error(`Invalid ${field}Regex: ${err.message}`);
+    }
+    if (value == null) {
+      return input => re.test(input);
+    }
+
+    value = value.toLowerCase();
+    if (re.test(value)) {
+      return input => (value == input);
+    } else {
+      return input => false;
+    }
+  }
+
+  const matchFilename = makeMatch(query.filenameRegex, query.filename, "filename");
+  const matchUrl = makeMatch(query.urlRegex, query.url, "url");
+
+  return function(item) {
+    const url = item.url.toLowerCase();
+    const filename = item.filename.toLowerCase();
+
+    if (!queryTerms.every(term => url.includes(term) || filename.includes(term))) {
+      return false;
+    }
+
+    if (queryNegativeTerms.some(term => url.includes(term) || filename.includes(term))) {
+      return false;
+    }
+
+    if (!matchFilename(filename) || !matchUrl(url)) {
+      return false;
+    }
+
+    if (!item.startTime) {
+      if (query.startedBefore != null || query.startedAfter != null) {
+        return false;
+      }
+    } else if (item.startTime > startedBefore || item.startTime < startedAfter) {
+      return false;
+    }
+
+    // todo endedBefore, endedAfter
+
+    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
+    const SIMPLE_ITEMS = ["id", "mime", "startTime", "endTime", "state",
+                          "bytesReceived", "totalBytes", "fileSize", "exists"];
+    for (let field of SIMPLE_ITEMS) {
+      if (query[field] != null && item[field] != query[field]) {
+        return false;
+      }
+    }
+
+    return true;
+  };
+}
 
 extensions.registerSchemaAPI("downloads", "downloads", (extension, context) => {
   return {
     downloads: {
       download(options) {
         if (options.filename != null) {
           if (options.filename.length == 0) {
             return Promise.reject({message: "filename must not be empty"});
@@ -81,30 +341,82 @@ extensions.registerSchemaAPI("downloads"
         let download;
         return Downloads.getPreferredDownloadsDirectory()
           .then(downloadsDir => createTarget(downloadsDir))
           .then(target => Downloads.createDownload({
             source: options.url,
             target: target,
           })).then(dl => {
             download = dl;
-            return Downloads.getList(Downloads.ALL);
+            return DownloadMap.getDownloadList();
           }).then(list => {
             list.add(download);
 
             // This is necessary to make pause/resume work.
             download.tryToKeepPartialData = true;
             download.start();
 
-            // Without other chrome.downloads methods, we can't actually
-            // do anything with the id so just return a dummy value for now.
-            return currentId++;
+            const item = DownloadMap.newFromDownload(download, extension);
+            return item.id;
           });
       },
 
+      search(query) {
+        let matchFn;
+        try {
+          matchFn = downloadQuery(query);
+        } catch (err) {
+          return Promise.reject({message: err.message});
+        }
+
+        let compareFn;
+        if (query.orderBy != null) {
+          const fields = query.orderBy.map(field => field[0] == "-"
+                                           ? {reverse: true, name: field.slice(1)}
+                                           : {reverse: false, name: field});
+
+          for (let field of fields) {
+            if (!DOWNLOAD_ITEM_FIELDS.includes(field.name)) {
+              return Promise.reject({message: `Invalid orderBy field ${field.name}`});
+            }
+          }
+
+          compareFn = (dl1, dl2) => {
+            for (let field of fields) {
+              const val1 = dl1[field.name];
+              const val2 = dl2[field.name];
+
+              if (val1 < val2) {
+                return field.reverse ? 1 : -1;
+              } else if (val1 > val2) {
+                return field.reverse ? -1 : 1;
+              }
+            }
+            return 0;
+          };
+        }
+
+        return DownloadMap.getAll().then(downloads => {
+          if (compareFn) {
+            downloads = Array.from(downloads);
+            downloads.sort(compareFn);
+          }
+          let results = [];
+          for (let download of downloads) {
+            if (query.limit && results.length >= query.limit) {
+              break;
+            }
+            if (matchFn(download)) {
+              results.push(download.serialize());
+            }
+          }
+          return results;
+        });
+      },
+
       // When we do open(), check for additional downloads.open permission.
       // i.e.:
       // open(downloadId) {
       //   if (!extension.hasPermission("downloads.open")) {
       //     throw new context.cloneScope.Error("Permission denied because 'downloads.open' permission is missing.");
       //   }
       //   ...
       // }
--- a/toolkit/components/extensions/schemas/downloads.json
+++ b/toolkit/components/extensions/schemas/downloads.json
@@ -290,48 +290,52 @@
               }
             ]
           }
         ]
       },
       {
         "name": "search",
         "type": "function",
-        "unsupported": true,
+        "async": "callback",
         "description": "Find <a href='#type-DownloadItem'>DownloadItems</a>. Set <code>query</code> to the empty object to get all <a href='#type-DownloadItem'>DownloadItems</a>. To get a specific <a href='#type-DownloadItem'>DownloadItem</a>, set only the <code>id</code> field.",
         "parameters": [
           {
             "name": "query",
             "type": "object",
             "properties": {
               "query": {
                 "description": "This array of search terms limits results to <a href='#type-DownloadItem'>DownloadItems</a> whose <code>filename</code> or <code>url</code> contain all of the search terms that do not begin with a dash '-' and none of the search terms that do begin with a dash.",
                 "optional": true,
                 "type": "array",
                 "items": { "type": "string" }
               },
               "startedBefore": {
                 "description": "Limits results to downloads that started before the given ms since the epoch.",
                 "optional": true,
-                "type": "string"
+                "type": "string",
+                "pattern": "^[1-9]\\d*$"
               },
               "startedAfter": {
                 "description": "Limits results to downloads that started after the given ms since the epoch.",
                 "optional": true,
-                "type": "string"
+                "type": "string",
+                "pattern": "^[1-9]\\d*$"
               },
               "endedBefore": {
                 "description": "Limits results to downloads that ended before the given ms since the epoch.",
                 "optional": true,
-                "type": "string"
+                "type": "string",
+                "pattern": "^[1-9]\\d*$"
               },
               "endedAfter": {
                 "description": "Limits results to downloads that ended after the given ms since the epoch.",
                 "optional": true,
-                "type": "string"
+                "type": "string",
+                "pattern": "^[1-9]\\d*$"
               },
               "totalBytesGreater": {
                 "description": "Limits results to downloads whose totalBytes is greater than the given integer.",
                 "optional": true,
                 "type": "number"
               },
               "totalBytesLess": {
                 "description": "Limits results to downloads whose totalBytes is less than the given integer.",
--- a/toolkit/components/extensions/test/mochitest/chrome.ini
+++ b/toolkit/components/extensions/test/mochitest/chrome.ini
@@ -1,6 +1,8 @@
 [DEFAULT]
 skip-if = os == 'android'
 support-files =
+  file_download.html
   file_download.txt
 
 [test_chrome_ext_downloads_download.html]
+[test_chrome_ext_downloads_search.html]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_download.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<div>Download HTML File</div>
+
+</body>
+</html>
--- a/toolkit/components/extensions/test/mochitest/mochitest.ini
+++ b/toolkit/components/extensions/test/mochitest/mochitest.ini
@@ -17,16 +17,17 @@ support-files =
   file_script_bad.js
   file_script_redirect.js
   file_script_xhr.js
   file_sample.html
   redirection.sjs
   file_privilege_escalation.html
   file_ext_test_api_injection.js
   file_permission_xhr.html
+  file_download.txt
 
 [test_ext_simple.html]
 [test_ext_schema.html]
 skip-if = e10s # Uses a console montitor. Actual code does not depend on e10s.
 [test_ext_geturl.html]
 [test_ext_contentscript.html]
 skip-if = buildapp == 'b2g' # runat != document_idle is not supported.
 [test_ext_contentscript_create_iframe.html]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_search.html
@@ -0,0 +1,393 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>WebExtension test</title>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SpawnTask.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+  <script type="text/javascript" src="head.js"></script>
+  <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const {
+  interfaces: Ci,
+  utils: Cu,
+} = Components;
+
+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 TXT_LEN = 46;
+const HTML_FILE = "file_download.html";
+const HTML_URL = BASE + "/" + HTML_FILE;
+const HTML_LEN = 117;
+const BIG_LEN = 1000;  // something bigger both TXT_LEN and HTML_LEN
+
+function backgroundScript() {
+  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 == "search.request") {
+      Promise.resolve().then(() => browser.downloads.search(arguments[1]))
+                       .then(downloads => {
+                         browser.test.sendMessage("search.done", {status: "success", downloads});
+                       })
+                       .catch(error => {
+                         browser.test.sendMessage("search.done", {status: "error", errmsg: error.message});
+                       });
+    }
+  });
+
+  browser.test.sendMessage("ready");
+}
+
+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);
+    });
+  });
+}
+
+// This function is a bit of a sledgehammer, it looks at every download
+// the browser knows about and waits for all active downloads to complete.
+// But we only start one at a time and only do a handful in total.
+// Replace this when we have onChanged (bug 1245600)
+function waitForDownloads() {
+  return Downloads.getList(Downloads.ALL)
+                  .then(list => list.getAll())
+                  .then(downloads => {
+                    let inprogress = downloads.filter(dl => !dl.stopped);
+                    return Promise.all(inprogress.map(dl => dl.whenSucceeded()));
+                  });
+}
+
+add_task(function* test_search() {
+  const nsIFile = Ci.nsIFile;
+  let downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+  downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+  info(`downloadDir ${downloadDir.path}`);
+
+  function downloadPath(filename) {
+    let path = downloadDir.clone();
+    path.append(filename);
+    return path.path;
+  }
+
+  Services.prefs.setIntPref("browser.download.folderList", 2);
+  Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir);
+
+  SimpleTest.registerCleanupFunction(() => {
+    Services.prefs.clearUserPref("browser.download.folderList");
+    Services.prefs.clearUserPref("browser.download.dir");
+    downloadDir.remove(true);
+    return clearDownloads();
+  });
+
+  yield clearDownloads().then(downloads => {
+    info(`removed ${downloads.length} pre-existing downloads from history`);
+  });
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: `(${backgroundScript})()`,
+    manifest: {
+      permissions: ["downloads"],
+    },
+  });
+
+  function download(options) {
+    extension.sendMessage("download.request", options);
+    return extension.awaitMessage("download.done");
+  }
+
+  function search(query) {
+    extension.sendMessage("search.request", query);
+    return extension.awaitMessage("search.done");
+  }
+
+  yield extension.startup();
+  yield extension.awaitMessage("ready");
+  info("extension started");
+
+  // Do some downloads...
+  const time1 = new Date();
+
+  let downloadIds = {};
+  let msg = yield download({url: TXT_URL});
+  is(msg.status, "success", "download() succeeded");
+  downloadIds.txt1 = msg.id;
+
+  const TXT_FILE2 = "NewFile.txt";
+  msg = yield download({url: TXT_URL, filename: TXT_FILE2});
+  is(msg.status, "success", "download() succeeded");
+  downloadIds.txt2 = msg.id;
+
+  const time2 = new Date();
+
+  msg = yield download({url: HTML_URL});
+  is(msg.status, "success", "download() succeeded");
+  downloadIds.html1 = msg.id;
+
+  const HTML_FILE2 = "renamed.html";
+  msg = yield download({url: HTML_URL, filename: HTML_FILE2});
+  is(msg.status, "success", "download() succeeded");
+  downloadIds.html2 = msg.id;
+
+  const time3 = new Date();
+
+  yield waitForDownloads();
+
+  // Search for each individual download and check
+  // the corresponding DownloadItem.
+  function* checkDownloadItem(id, expect) {
+    let msg = yield search({id});
+    is(msg.status, "success", "search() succeeded");
+    is(msg.downloads.length, 1, "search() found exactly 1 download");
+
+    Object.keys(expect).forEach(function(field) {
+      is(msg.downloads[0][field], expect[field], `DownloadItem.${field} is correct"`);
+    });
+  }
+  yield checkDownloadItem(downloadIds.txt1, {
+    url: TXT_URL,
+    filename: downloadPath(TXT_FILE),
+    mime: "text/plain",
+    state: "complete",
+    bytesReceived: TXT_LEN,
+    totalBytes: TXT_LEN,
+    fileSize: TXT_LEN,
+    exists: true,
+  });
+
+  yield checkDownloadItem(downloadIds.txt2, {
+    url: TXT_URL,
+    filename: downloadPath(TXT_FILE2),
+    mime: "text/plain",
+    state: "complete",
+    bytesReceived: TXT_LEN,
+    totalBytes: TXT_LEN,
+    fileSize: TXT_LEN,
+    exists: true,
+  });
+
+  yield checkDownloadItem(downloadIds.html1, {
+    url: HTML_URL,
+    filename: downloadPath(HTML_FILE),
+    mime: "text/html",
+    state: "complete",
+    bytesReceived: HTML_LEN,
+    totalBytes: HTML_LEN,
+    fileSize: HTML_LEN,
+    exists: true,
+  });
+
+  yield checkDownloadItem(downloadIds.html2, {
+    url: HTML_URL,
+    filename: downloadPath(HTML_FILE2),
+    mime: "text/html",
+    state: "complete",
+    bytesReceived: HTML_LEN,
+    totalBytes: HTML_LEN,
+    fileSize: HTML_LEN,
+    exists: true,
+  });
+
+  function* checkSearch(query, expected, description, exact) {
+    let msg = yield search(query);
+    is(msg.status, "success", "search() succeeded");
+    is(msg.downloads.length, expected.length, `search() for ${description} found exactly ${expected.length} downloads`);
+
+    let receivedIds = msg.downloads.map(item => item.id);
+    if (exact) {
+      receivedIds.forEach((id, idx) => {
+        is(id, downloadIds[expected[idx]], `search() for ${description} returned ${expected[idx]} in position ${idx}`);
+      });
+    } else {
+      Object.keys(downloadIds).forEach(key => {
+        const id = downloadIds[key];
+        const thisExpected = expected.includes(key);
+        is(receivedIds.includes(id), thisExpected,
+           `search() for ${description} ${thisExpected ? "includes" : "does not include"} ${key}`);
+      });
+    }
+  }
+
+  // Check that search with an invalid id returns nothing.
+  // NB: for now ids are not persistent and we start numbering them at 1
+  //     so a sufficiently large number will be unused.
+  const INVALID_ID = 1000;
+  yield checkSearch({id: INVALID_ID}, [], "invalid id");
+
+  // Check that search on url works.
+  yield checkSearch({url: TXT_URL}, ["txt1", "txt2"], "url");
+
+  // Check that regexp on url works.
+  const HTML_REGEX = "[downlad]{8}\.html+$";
+  yield checkSearch({urlRegex: HTML_REGEX}, ["html1", "html2"], "url regexp");
+
+  // Check that compatible url+regexp works
+  yield checkSearch({url: HTML_URL, urlRegex: HTML_REGEX}, ["html1", "html2"], "compatible url+urlRegex");
+
+  // Check that incompatible url+regexp works
+  yield checkSearch({url: TXT_URL, urlRegex: HTML_REGEX}, [], "incompatible url+urlRegex");
+
+  // Check that search on filename works.
+  yield checkSearch({filename: downloadPath(TXT_FILE)}, ["txt1"], "filename");
+
+  // Check that regexp on filename works.
+  yield checkSearch({filenameRegex: HTML_REGEX}, ["html1"], "filename regex");
+
+  // Check that compatible filename+regexp works
+  yield checkSearch({filename: downloadPath(HTML_FILE), filenameRegex: HTML_REGEX}, ["html1"], "compatible filename+filename regex");
+
+  // Check that incompatible filename+regexp works
+  yield checkSearch({filename: downloadPath(TXT_FILE), filenameRegex: HTML_REGEX}, [], "incompatible filename+filename regex");
+
+  // Check that simple positive search terms work.
+  yield checkSearch({query: ["file_download"]}, ["txt1", "txt2", "html1", "html2"],
+                    "term file_download");
+  yield checkSearch({query: ["NewFile"]}, ["txt2"], "term NewFile");
+
+  // Check that positive search terms work case-insensitive.
+  yield checkSearch({query: ["nEwfILe"]}, ["txt2"], "term nEwfiLe");
+
+  // Check that negative search terms work.
+  yield checkSearch({query: ["-txt"]}, ["html1", "html2"], "term -txt");
+
+  // Check that positive and negative search terms together work.
+  yield checkSearch({query: ["html", "-renamed"]}, ["html1"], "postive and negative terms");
+
+  // Check that startedBefore works with stringified milliseconds.
+  yield checkSearch({startedBefore: time1.valueOf().toString()}, [], "before time1");
+  yield checkSearch({startedBefore: time2.valueOf().toString()}, ["txt1", "txt2"], "before time2");
+  yield checkSearch({startedBefore: time3.valueOf().toString()}, ["txt1", "txt2", "html1", "html2"], "before time3");
+
+  // Check that startedBefore works with iso string.
+  // enable with fix for bug 1251766
+  // yield checkSearch({startedBefore: time1.toISOString()}, [], "before time1");
+  // yield checkSearch({startedBefore: time2.toISOString()}, ["txt1", "txt2"], "before time2");
+  // yield checkSearch({startedBefore: time3.toISOString()}, ["txt1", "txt2", "html1", "html2"], "before time3");
+
+  // Check that startedAfter works with stringified milliseconds.
+  yield checkSearch({startedAfter: time1.valueOf().toString()}, ["txt1", "txt2", "html1", "html2"], "after time1");
+  yield checkSearch({startedAfter: time2.valueOf().toString()}, ["html1", "html2"], "after time2");
+  yield checkSearch({startedAfter: time3.valueOf().toString()}, [], "after time3");
+
+  // Check that startedAfter works with iso string.
+  // enable with fix for bug 1251766
+  // yield checkSearch({startedAfter: time1.toISOString()}, ["txt1", "txt2", "html1", "html2"], "after time1");
+  // yield checkSearch({startedAfter: time2.toISOString()}, ["html1", "html2"], "after time2");
+  // yield checkSearch({startedAfter: time3.toISOString()}, [], "after time3");
+
+  // Check simple search on totalBytes
+  yield checkSearch({totalBytes: TXT_LEN}, ["txt1", "txt2"], "totalBytes");
+  yield checkSearch({totalBytes: HTML_LEN}, ["html1", "html2"], "totalBytes");
+
+  // Check simple test on totalBytes{Greater,Less}
+  // (NB: TXT_LEN < HTML_LEN < BIG_LEN)
+  yield checkSearch({totalBytesGreater: 0}, ["txt1", "txt2", "html1", "html2"], "totalBytesGreater than 0");
+  yield checkSearch({totalBytesGreater: TXT_LEN}, ["html1", "html2"], `totalBytesGreater than ${TXT_LEN}`);
+  yield checkSearch({totalBytesGreater: HTML_LEN}, [], `totalBytesGreater than ${HTML_LEN}`);
+  yield checkSearch({totalBytesLess: TXT_LEN}, [], `totalBytesLess than ${TXT_LEN}`);
+  yield checkSearch({totalBytesLess: HTML_LEN}, ["txt1", "txt2"], `totalBytesLess than ${HTML_LEN}`);
+  yield checkSearch({totalBytesLess: BIG_LEN}, ["txt1", "txt2", "html1", "html2"], `totalBytesLess than ${BIG_LEN}`);
+
+  // Check good combinations of totalBytes*.
+  yield checkSearch({totalBytes: HTML_LEN, totalBytesGreater: TXT_LEN}, ["html1", "html2"], "totalBytes and totalBytesGreater");
+  yield checkSearch({totalBytes: TXT_LEN, totalBytesLess: HTML_LEN}, ["txt1", "txt2"], "totalBytes and totalBytesGreater");
+  yield checkSearch({totalBytes: HTML_LEN, totalBytesLess: BIG_LEN, totalBytesGreater: 0}, ["html1", "html2"], "totalBytes and totalBytesLess and totalBytesGreater");
+
+  // Check bad combination of totalBytes*.
+  yield checkSearch({totalBytesLess: TXT_LEN, totalBytesGreater: HTML_LEN}, [], "bad totalBytesLess, totalBytesGreater combination");
+  yield checkSearch({totalBytes: TXT_LEN, totalBytesGreater: HTML_LEN}, [], "bad totalBytes, totalBytesGreater combination");
+  yield checkSearch({totalBytes: HTML_LEN, totalBytesLess: TXT_LEN}, [], "bad totalBytes, totalBytesLess combination");
+
+  // Check mime.
+  yield checkSearch({mime: "text/plain"}, ["txt1", "txt2"], "mime text/plain");
+  yield checkSearch({mime: "text/html"}, ["html1", "html2"], "mime text/htmlplain");
+  yield checkSearch({mime: "video/webm"}, [], "mime video/webm");
+
+  // Check fileSize.
+  yield checkSearch({fileSize: TXT_LEN}, ["txt1", "txt2"], "fileSize");
+  yield checkSearch({fileSize: HTML_LEN}, ["html1", "html2"], "fileSize");
+
+  // Fields like bytesReceived, paused, state, exists are meaningful
+  // for downloads that are in progress but have not yet completed.
+  // todo: add tests for these when we have better support for in-progress
+  // downloads (e.g., after pause(), resume() and cancel() are implemented)
+
+  // Check multiple query properties.
+  // We could make this testing arbitrarily complicated...
+  // We already tested combining fields with obvious interactions above
+  // (e.g., filename and filenameRegex or startTime and startedBefore/After)
+  // so now just throw as many fields as we can at a single search and
+  // make sure a simple case still works.
+  yield checkSearch({
+    url: TXT_URL,
+    urlRegex: "download",
+    filename: downloadPath(TXT_FILE),
+    filenameRegex: "download",
+    query: ["download"],
+    startedAfter: time1.valueOf().toString(),
+    startedBefore: time2.valueOf().toString(),
+    totalBytes: TXT_LEN,
+    totalBytesGreater: 0,
+    totalBytesLess: BIG_LEN,
+    mime: "text/plain",
+    fileSize: TXT_LEN,
+  }, ["txt1"], "many properties");
+
+  // Check simple orderBy (forward and backward).
+  yield checkSearch({orderBy: ["startTime"]}, ["txt1", "txt2", "html1", "html2"], "orderBy startTime", true);
+  yield checkSearch({orderBy: ["-startTime"]}, ["html2", "html1", "txt2", "txt1"], "orderBy -startTime", true);
+
+  // Check orderBy with multiple fields.
+  // NB: TXT_URL and HTML_URL differ only in extension and .html precedes .txt
+  yield checkSearch({orderBy: ["url", "-startTime"]}, ["html2", "html1", "txt2", "txt1"], "orderBy with multiple fields", true);
+
+  // Check orderBy with limit.
+  yield checkSearch({orderBy: ["url"], limit: 1}, ["html1"], "orderBy with limit", true);
+
+  // Check bad arguments.
+  function* checkBadSearch(query, pattern, description) {
+    let msg = yield search(query);
+    is(msg.status, "error", "search() failed");
+    ok(pattern.test(msg.errmsg), `error message for ${description} was correct (${msg.errmsg}).`);
+  }
+
+  yield checkBadSearch("myquery", /Incorrect argument type/, "query is not an object");
+  yield checkBadSearch({bogus: "boo"}, /Unexpected property/, "query contains an unknown field");
+  yield checkBadSearch({query: "query string"}, /Expected array/, "query.query is a string");
+  yield checkBadSearch({startedBefore: "i am not a number"}, /Type error/, "query.startedBefore is not a valid time");
+  yield checkBadSearch({startedAfter: "i am not a number"}, /Type error/, "query.startedAfter is not a valid time");
+  yield checkBadSearch({endedBefore: "i am not a number"}, /Type error/, "query.endedBefore is not a valid time");
+  yield checkBadSearch({endedAfter: "i am not a number"}, /Type error/, "query.endedAfter is not a valid time");
+  yield checkBadSearch({urlRegex: "["}, /Invalid urlRegex/, "query.urlRegexp is not a valid regular expression");
+  yield checkBadSearch({filenameRegex: "["}, /Invalid filenameRegex/, "query.filenameRegexp is not a valid regular expression");
+  yield checkBadSearch({orderBy: "startTime"}, /Expected array/, "query.orderBy is not an array");
+  yield checkBadSearch({orderBy: ["bogus"]}, /Invalid orderBy field/, "query.orderBy references a non-existent field");
+
+  yield extension.unload();
+});
+
+</script>
+
+</body>
+</html>