Bug 1245603 - Implement browser.downloads.search(). r=kmag
authorAndrew Swan <aswan@mozilla.com>
Wed, 02 Mar 2016 10:23:55 -0800
changeset 323083 09ada320af0b49f54dbec6b678b3f7fd52af4193
parent 323082 4df6e54d8cf18c16469b23bb01cde070c701ba98
child 323084 428778584d516aa656cf50ea49e7455ee3d6c1c7
push id5913
push userjlund@mozilla.com
push dateMon, 25 Apr 2016 16:57:49 +0000
treeherdermozilla-beta@dcaf0a6fa115 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerskmag
bugs1245603
milestone47.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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>