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