/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* vim: set sts=2 sw=2 et tw=80: */ "use strict"; const { Downloads } = ChromeUtils.importESModule( "resource://gre/modules/Downloads.sys.mjs" ); const server = createHttpServer(); server.registerDirectory("/data/", do_get_file("data")); const BASE = `http://localhost:${server.identity.primaryPort}/data`; 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 EMPTY_FILE = "empty_file_download.txt"; const EMPTY_URL = BASE + "/" + EMPTY_FILE; const EMPTY_LEN = 0; const BIG_LEN = 1000; // something bigger both TXT_LEN and HTML_LEN function backgroundScript() { let complete = new Map(); function waitForComplete(id) { if (complete.has(id)) { return complete.get(id).promise; } let promise = new Promise(resolve => { complete.set(id, { resolve }); }); complete.get(id).promise = promise; return promise; } browser.downloads.onChanged.addListener(change => { if (change.state && change.state.current == "complete") { // Make sure we have a promise. waitForComplete(change.id); complete.get(change.id).resolve(); } }); browser.test.onMessage.addListener(async (msg, ...args) => { if (msg == "download.request") { try { let id = await browser.downloads.download(args[0]); 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") { try { let downloads = await browser.downloads.search(args[0]); browser.test.sendMessage("search.done", { status: "success", downloads, }); } catch (error) { browser.test.sendMessage("search.done", { status: "error", errmsg: error.message, }); } } else if (msg == "waitForComplete.request") { await waitForComplete(args[0]); browser.test.sendMessage("waitForComplete.done"); } }); browser.test.sendMessage("ready"); } async function clearDownloads() { let list = await Downloads.getList(Downloads.ALL); let downloads = await list.getAll(); await Promise.all(downloads.map(download => list.remove(download))); return downloads; } add_task(async 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); Services.prefs.setBoolPref("privacy.reduceTimerPrecision", false); registerCleanupFunction(async () => { Services.prefs.clearUserPref("browser.download.folderList"); Services.prefs.clearUserPref("browser.download.dir"); Services.prefs.clearUserPref("privacy.reduceTimerPrecision"); await cleanupDir(downloadDir); await clearDownloads(); }); await clearDownloads().then(downloads => { info(`removed ${downloads.length} pre-existing downloads from history`); }); let extension = ExtensionTestUtils.loadExtension({ background: backgroundScript, manifest: { permissions: ["downloads"], }, }); async function download(options) { extension.sendMessage("download.request", options); let result = await extension.awaitMessage("download.done"); if (result.status == "success") { info(`wait for onChanged event to indicate ${result.id} is complete`); extension.sendMessage("waitForComplete.request", result.id); await extension.awaitMessage("waitForComplete.done"); } return result; } function search(query) { extension.sendMessage("search.request", query); return extension.awaitMessage("search.done"); } await extension.startup(); await extension.awaitMessage("ready"); // Do some downloads... const time1 = new Date(); let downloadIds = {}; let msg = await download({ url: TXT_URL }); equal(msg.status, "success", "download() succeeded"); downloadIds.txt1 = msg.id; const TXT_FILE2 = "NewFile.txt"; msg = await download({ url: TXT_URL, filename: TXT_FILE2 }); equal(msg.status, "success", "download() succeeded"); downloadIds.txt2 = msg.id; msg = await download({ url: EMPTY_URL }); equal(msg.status, "success", "download() succeeded"); downloadIds.txt3 = msg.id; const time2 = new Date(); msg = await download({ url: HTML_URL }); equal(msg.status, "success", "download() succeeded"); downloadIds.html1 = msg.id; const HTML_FILE2 = "renamed.html"; msg = await download({ url: HTML_URL, filename: HTML_FILE2 }); equal(msg.status, "success", "download() succeeded"); downloadIds.html2 = msg.id; const time3 = new Date(); // Search for each individual download and check // the corresponding DownloadItem. async function checkDownloadItem(id, expect) { let item = await search({ id }); equal(item.status, "success", "search() succeeded"); equal(item.downloads.length, 1, "search() found exactly 1 download"); Object.keys(expect).forEach(function (field) { equal( item.downloads[0][field], expect[field], `DownloadItem.${field} is correct"` ); }); } await 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, }); await 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, }); await checkDownloadItem(downloadIds.txt3, { url: EMPTY_URL, filename: downloadPath(EMPTY_FILE), mime: "text/plain", state: "complete", bytesReceived: EMPTY_LEN, totalBytes: EMPTY_LEN, fileSize: EMPTY_LEN, exists: true, }); await 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, }); await 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, }); async function checkSearch(query, expected, description, exact) { let item = await search(query); equal(item.status, "success", "search() succeeded"); equal( item.downloads.length, expected.length, `search() for ${description} found exactly ${expected.length} downloads` ); let receivedIds = item.downloads.map(i => i.id); if (exact) { receivedIds.forEach((id, idx) => { equal( 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); equal( 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; await checkSearch({ id: INVALID_ID }, [], "invalid id"); // Check that search on url works. await checkSearch({ url: TXT_URL }, ["txt1", "txt2"], "url"); // Check that regexp on url works. const HTML_REGEX = "[download]{8}.html+$"; await checkSearch({ urlRegex: HTML_REGEX }, ["html1", "html2"], "url regexp"); // Check that compatible url+regexp works await checkSearch( { url: HTML_URL, urlRegex: HTML_REGEX }, ["html1", "html2"], "compatible url+urlRegex" ); // Check that incompatible url+regexp works await checkSearch( { url: TXT_URL, urlRegex: HTML_REGEX }, [], "incompatible url+urlRegex" ); // Check that search on filename works. await checkSearch({ filename: downloadPath(TXT_FILE) }, ["txt1"], "filename"); // Check that regexp on filename works. await checkSearch({ filenameRegex: HTML_REGEX }, ["html1"], "filename regex"); // Check that compatible filename+regexp works await checkSearch( { filename: downloadPath(HTML_FILE), filenameRegex: HTML_REGEX }, ["html1"], "compatible filename+filename regex" ); // Check that incompatible filename+regexp works await checkSearch( { filename: downloadPath(TXT_FILE), filenameRegex: HTML_REGEX }, [], "incompatible filename+filename regex" ); // Check that simple positive search terms work. await checkSearch( { query: ["file_download"] }, ["txt1", "txt2", "txt3", "html1", "html2"], "term file_download" ); await checkSearch({ query: ["NewFile"] }, ["txt2"], "term NewFile"); // Check that positive search terms work case-insensitive. await checkSearch({ query: ["nEwfILe"] }, ["txt2"], "term nEwfiLe"); // Check that negative search terms work. await checkSearch({ query: ["-txt"] }, ["html1", "html2"], "term -txt"); // Check that positive and negative search terms together work. await checkSearch( { query: ["html", "-renamed"] }, ["html1"], "positive and negative terms" ); async function checkSearchWithDate(query, expected, description) { const fields = Object.keys(query); if (fields.length != 1 || !(query[fields[0]] instanceof Date)) { throw new Error("checkSearchWithDate expects exactly one Date field"); } const field = fields[0]; const date = query[field]; let newquery = {}; // Check as a Date newquery[field] = date; await checkSearch(newquery, expected, `${description} as Date`); // Check as numeric milliseconds newquery[field] = date.valueOf(); await checkSearch(newquery, expected, `${description} as numeric ms`); // Check as stringified milliseconds newquery[field] = date.valueOf().toString(); await checkSearch(newquery, expected, `${description} as string ms`); // Check as ISO string newquery[field] = date.toISOString(); await checkSearch(newquery, expected, `${description} as iso string`); } // Check startedBefore await checkSearchWithDate({ startedBefore: time1 }, [], "before time1"); await checkSearchWithDate( { startedBefore: time2 }, ["txt1", "txt2", "txt3"], "before time2" ); await checkSearchWithDate( { startedBefore: time3 }, ["txt1", "txt2", "txt3", "html1", "html2"], "before time3" ); // Check startedAfter await checkSearchWithDate( { startedAfter: time1 }, ["txt1", "txt2", "txt3", "html1", "html2"], "after time1" ); await checkSearchWithDate( { startedAfter: time2 }, ["html1", "html2"], "after time2" ); await checkSearchWithDate({ startedAfter: time3 }, [], "after time3"); // Check simple search on totalBytes await checkSearch({ totalBytes: TXT_LEN }, ["txt1", "txt2"], "totalBytes"); await checkSearch({ totalBytes: HTML_LEN }, ["html1", "html2"], "totalBytes"); // Check simple test on totalBytes{Greater,Less} // (NB: TXT_LEN < HTML_LEN < BIG_LEN) await checkSearch( { totalBytesGreater: 0 }, ["txt1", "txt2", "html1", "html2"], "totalBytesGreater than 0" ); await checkSearch( { totalBytesGreater: TXT_LEN }, ["html1", "html2"], `totalBytesGreater than ${TXT_LEN}` ); await checkSearch( { totalBytesGreater: HTML_LEN }, [], `totalBytesGreater than ${HTML_LEN}` ); await checkSearch( { totalBytesLess: TXT_LEN }, ["txt3"], `totalBytesLess than ${TXT_LEN}` ); await checkSearch( { totalBytesLess: HTML_LEN }, ["txt1", "txt2", "txt3"], `totalBytesLess than ${HTML_LEN}` ); await checkSearch( { totalBytesLess: BIG_LEN }, ["txt1", "txt2", "txt3", "html1", "html2"], `totalBytesLess than ${BIG_LEN}` ); // Bug 1503760 check if 0 byte files with no search query are returned. await checkSearch( {}, ["txt1", "txt2", "txt3", "html1", "html2"], "totalBytesGreater than -1" ); // Check good combinations of totalBytes*. await checkSearch( { totalBytes: HTML_LEN, totalBytesGreater: TXT_LEN }, ["html1", "html2"], "totalBytes and totalBytesGreater" ); await checkSearch( { totalBytes: TXT_LEN, totalBytesLess: HTML_LEN }, ["txt1", "txt2"], "totalBytes and totalBytesGreater" ); await checkSearch( { totalBytes: HTML_LEN, totalBytesLess: BIG_LEN, totalBytesGreater: 0 }, ["html1", "html2"], "totalBytes and totalBytesLess and totalBytesGreater" ); // Check bad combination of totalBytes*. await checkSearch( { totalBytesLess: TXT_LEN, totalBytesGreater: HTML_LEN }, [], "bad totalBytesLess, totalBytesGreater combination" ); await checkSearch( { totalBytes: TXT_LEN, totalBytesGreater: HTML_LEN }, [], "bad totalBytes, totalBytesGreater combination" ); await checkSearch( { totalBytes: HTML_LEN, totalBytesLess: TXT_LEN }, [], "bad totalBytes, totalBytesLess combination" ); // Check mime. await checkSearch( { mime: "text/plain" }, ["txt1", "txt2", "txt3"], "mime text/plain" ); await checkSearch( { mime: "text/html" }, ["html1", "html2"], "mime text/htmlplain" ); await checkSearch({ mime: "video/webm" }, [], "mime video/webm"); // Check fileSize. await checkSearch({ fileSize: TXT_LEN }, ["txt1", "txt2"], "fileSize"); await 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. await 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). await checkSearch( { orderBy: ["startTime"] }, ["txt1", "txt2", "txt3", "html1", "html2"], "orderBy startTime", true ); await checkSearch( { orderBy: ["-startTime"] }, ["html2", "html1", "txt3", "txt2", "txt1"], "orderBy -startTime", true ); // Check orderBy with multiple fields. // NB: TXT_URL and HTML_URL differ only in extension and .html precedes .txt // EMPTY_URL begins with e which precedes f await checkSearch( { orderBy: ["url", "-startTime"] }, ["txt3", "html2", "html1", "txt2", "txt1"], "orderBy with multiple fields", true ); // Check orderBy with limit. await checkSearch( { orderBy: ["url"], limit: 1 }, ["txt3"], "orderBy with limit", true ); // Check bad arguments. async function checkBadSearch(query, pattern, description) { let item = await search(query); equal(item.status, "error", "search() failed"); ok( pattern.test(item.errmsg), `error message for ${description} was correct (${item.errmsg}).` ); } await checkBadSearch( "myquery", /Incorrect argument type/, "query is not an object" ); await checkBadSearch( { bogus: "boo" }, /Unexpected property/, "query contains an unknown field" ); await checkBadSearch( { query: "query string" }, /Expected array/, "query.query is a string" ); await checkBadSearch( { startedBefore: "i am not a time" }, /Type error/, "query.startedBefore is not a valid time" ); await checkBadSearch( { startedAfter: "i am not a time" }, /Type error/, "query.startedAfter is not a valid time" ); await checkBadSearch( { endedBefore: "i am not a time" }, /Type error/, "query.endedBefore is not a valid time" ); await checkBadSearch( { endedAfter: "i am not a time" }, /Type error/, "query.endedAfter is not a valid time" ); await checkBadSearch( { urlRegex: "[" }, /Invalid urlRegex/, "query.urlRegexp is not a valid regular expression" ); await checkBadSearch( { filenameRegex: "[" }, /Invalid filenameRegex/, "query.filenameRegexp is not a valid regular expression" ); await checkBadSearch( { orderBy: "startTime" }, /Expected array/, "query.orderBy is not an array" ); await checkBadSearch( { orderBy: ["bogus"] }, /Invalid orderBy field/, "query.orderBy references a non-existent field" ); await extension.unload(); }); // Test that downloads with totalBytes of -1 (ie, that have not yet started) // work properly. See bug 1519762 for details of a past regression in // this area. add_task(async function test_inprogress() { let resume, resumePromise = new Promise(resolve => { resume = resolve; }); let hit = false; server.registerPathHandler("/data/slow", async (request, response) => { hit = true; response.processAsync(); await resumePromise; response.setHeader("Content-type", "text/plain"); response.write(""); response.finish(); }); let extension = ExtensionTestUtils.loadExtension({ manifest: { permissions: ["downloads"], }, background() { browser.test.onMessage.addListener(async (msg, url) => { let id = await browser.downloads.download({ url }); let full = await browser.downloads.search({ id }); browser.test.assertEq( full.length, 1, "Found new download in search results" ); browser.test.assertEq( full[0].totalBytes, -1, "New download still has totalBytes == -1" ); browser.downloads.onChanged.addListener(info => { if (info.id == id && info.state && info.state.current == "complete") { browser.test.notifyPass("done"); } }); browser.test.sendMessage("started"); }); }, }); await extension.startup(); extension.sendMessage("go", `${BASE}/slow`); await extension.awaitMessage("started"); resume(); await extension.awaitFinish("done"); await extension.unload(); Assert.ok(hit, "slow path was actually hit"); });