Bug 1063217 - Support a PDF DownloadSaver. r=paolo
authorWes Johnston <wjohnston@mozilla.com>
Tue, 28 Oct 2014 16:05:32 -0700
changeset 219138 a8f9ed3d7554f4eb51cf6f0f6295f00dc22bc6a1
parent 219137 477d76b9d58d93cdbb183e76eee52c936ee4d53c
child 219139 00b588dac8bc373f019a400baa894808721d7fc2
push id10363
push userpaolo.mozmail@amadzone.org
push dateThu, 11 Dec 2014 14:26:04 +0000
treeherderfx-team@a8f9ed3d7554 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspaolo
bugs1063217
milestone37.0a1
Bug 1063217 - Support a PDF DownloadSaver. r=paolo
toolkit/components/jsdownloads/moz.build
toolkit/components/jsdownloads/src/DownloadCore.jsm
toolkit/components/jsdownloads/src/DownloadStore.jsm
toolkit/components/jsdownloads/test/browser/browser.ini
toolkit/components/jsdownloads/test/browser/browser_DownloadPDFSaver.js
toolkit/components/jsdownloads/test/browser/head.js
toolkit/components/jsdownloads/test/browser/testFile.html
toolkit/components/jsdownloads/test/unit/test_DownloadStore.js
toolkit/components/jsdownloads/test/unit/test_Downloads.js
--- a/toolkit/components/jsdownloads/moz.build
+++ b/toolkit/components/jsdownloads/moz.build
@@ -2,8 +2,9 @@
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 DIRS += ['public', 'src']
 
 XPCSHELL_TESTS_MANIFESTS += ['test/data/xpcshell.ini', 'test/unit/xpcshell.ini']
+BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini']
--- a/toolkit/components/jsdownloads/src/DownloadCore.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadCore.jsm
@@ -25,28 +25,34 @@
  * DownloadSaver
  * Template for an object that actually transfers the data for the download.
  *
  * DownloadCopySaver
  * Saver object that simply copies the entire source file to the target.
  *
  * DownloadLegacySaver
  * Saver object that integrates with the legacy nsITransfer interface.
+ *
+ * DownloadPDFSaver
+ * This DownloadSaver type creates a PDF file from the current document in a
+ * given window, specified using the windowRef property of the DownloadSource
+ * object associated with the download.
  */
 
 "use strict";
 
 this.EXPORTED_SYMBOLS = [
   "Download",
   "DownloadSource",
   "DownloadTarget",
   "DownloadError",
   "DownloadSaver",
   "DownloadCopySaver",
   "DownloadLegacySaver",
+  "DownloadPDFSaver",
 ];
 
 ////////////////////////////////////////////////////////////////////////////////
 //// Globals
 
 const Cc = Components.classes;
 const Ci = Components.interfaces;
 const Cu = Components.utils;
@@ -63,26 +69,31 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                   "resource://gre/modules/osfile.jsm")
 XPCOMUtils.defineLazyModuleGetter(this, "Promise",
                                   "resource://gre/modules/Promise.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Task",
                                   "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
+                                  "resource://gre/modules/PrivateBrowsingUtils.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this, "gDownloadHistory",
            "@mozilla.org/browser/download-history;1",
            Ci.nsIDownloadHistory);
 XPCOMUtils.defineLazyServiceGetter(this, "gExternalAppLauncher",
            "@mozilla.org/uriloader/external-helper-app-service;1",
            Ci.nsPIExternalAppLauncher);
 XPCOMUtils.defineLazyServiceGetter(this, "gExternalHelperAppService",
            "@mozilla.org/uriloader/external-helper-app-service;1",
            Ci.nsIExternalHelperAppService);
+XPCOMUtils.defineLazyServiceGetter(this, "gPrintSettingsService",
+           "@mozilla.org/gfx/printsettings-service;1",
+           Ci.nsIPrintSettingsService);
 
 const BackgroundFileSaverStreamListener = Components.Constructor(
       "@mozilla.org/network/background-file-saver;1?mode=streamlistener",
       "nsIBackgroundFileSaver");
 
 /**
  * Returns true if the given value is a primitive string or a String object.
  */
@@ -539,17 +550,17 @@ this.Download.prototype = {
    * @return {Promise}
    * @resolves When the instruction to launch the file has been
    *           successfully given to the operating system. Note that
    *           the OS might still take a while until the file is actually
    *           launched.
    * @rejects  JavaScript exception if there was an error trying to launch
    *           the file.
    */
-  launch: function() {
+  launch: function () {
     if (!this.succeeded) {
       return Promise.reject(
         new Error("launch can only be called if the download succeeded")
       );
     }
 
     return DownloadIntegration.launchDownload(this);
   },
@@ -906,21 +917,26 @@ this.Download.prototype = {
    */
   toSerializable: function ()
   {
     let serializable = {
       source: this.source.toSerializable(),
       target: this.target.toSerializable(),
     };
 
+    let saver = this.saver.toSerializable();
+    if (!saver) {
+      // If we are unable to serialize the saver, we won't persist the download.
+      return null;
+    }
+
     // Simplify the representation for the most common saver type.  If the saver
     // is an object instead of a simple string, we can't simplify it because we
     // need to persist all its properties, not only "type".  This may happen for
     // savers of type "copy" as well as other types.
-    let saver = this.saver.toSerializable();
     if (saver !== "copy") {
       serializable.saver = saver;
     }
 
     if (this.error) {
       serializable.errorObj = this.error.toSerializable();
     }
 
@@ -1121,16 +1137,20 @@ this.DownloadSource.prototype = {
  */
 this.DownloadSource.fromSerializable = function (aSerializable) {
   let source = new DownloadSource();
   if (isString(aSerializable)) {
     // Convert String objects to primitive strings at this point.
     source.url = aSerializable.toString();
   } else if (aSerializable instanceof Ci.nsIURI) {
     source.url = aSerializable.spec;
+  } else if (aSerializable instanceof Ci.nsIDOMWindow) {
+    source.url = aSerializable.location.href;
+    source.isPrivate = PrivateBrowsingUtils.isContentWindowPrivate(aSerializable);
+    source.windowRef = Cu.getWeakReference(aSerializable);
   } else {
     // Convert String objects to primitive strings at this point.
     source.url = aSerializable.url.toString();
     if ("isPrivate" in aSerializable) {
       source.isPrivate = aSerializable.isPrivate;
     }
     if ("referrer" in aSerializable) {
       source.referrer = aSerializable.referrer;
@@ -1521,16 +1541,19 @@ this.DownloadSaver.fromSerializable = fu
   let saver;
   switch (serializable.type) {
     case "copy":
       saver = DownloadCopySaver.fromSerializable(serializable);
       break;
     case "legacy":
       saver = DownloadLegacySaver.fromSerializable(serializable);
       break;
+    case "pdf":
+      saver = DownloadPDFSaver.fromSerializable(serializable);
+      break;
     default:
       throw new Error("Unrecoginzed download saver type.");
   }
   return saver;
 };
 
 ////////////////////////////////////////////////////////////////////////////////
 //// DownloadCopySaver
@@ -1944,17 +1967,17 @@ this.DownloadCopySaver.fromSerializable 
 ////////////////////////////////////////////////////////////////////////////////
 //// DownloadLegacySaver
 
 /**
  * Saver object that integrates with the legacy nsITransfer interface.
  *
  * For more background on the process, see the DownloadLegacyTransfer object.
  */
-this.DownloadLegacySaver = function()
+this.DownloadLegacySaver = function ()
 {
   this.deferExecuted = Promise.defer();
   this.deferCanceled = Promise.defer();
 }
 
 this.DownloadLegacySaver.prototype = {
   __proto__: DownloadSaver.prototype,
 
@@ -2297,8 +2320,164 @@ this.DownloadLegacySaver.prototype = {
 /**
  * Returns a new DownloadLegacySaver object.  This saver type has a
  * deserializable form only when creating a new object in memory, because it
  * cannot be serialized to disk.
  */
 this.DownloadLegacySaver.fromSerializable = function () {
   return new DownloadLegacySaver();
 };
+
+////////////////////////////////////////////////////////////////////////////////
+//// DownloadPDFSaver
+
+/**
+ * This DownloadSaver type creates a PDF file from the current document in a
+ * given window, specified using the windowRef property of the DownloadSource
+ * object associated with the download.
+ *
+ * In order to prevent the download from saving a different document than the one
+ * originally loaded in the window, any attempt to restart the download will fail.
+ *
+ * Since this DownloadSaver type requires a live document as a source, it cannot
+ * be persisted across sessions, unless the download already succeeded.
+ */
+this.DownloadPDFSaver = function () {
+}
+
+this.DownloadPDFSaver.prototype = {
+  __proto__: DownloadSaver.prototype,
+
+  /**
+   * An nsIWebBrowserPrint instance for printing this page.
+   * This is null when saving has not started or has completed,
+   * or while the operation is being canceled.
+   */
+  _webBrowserPrint: null,
+
+  /**
+   * Implements "DownloadSaver.execute".
+   */
+  execute: function (aSetProgressBytesFn, aSetPropertiesFn)
+  {
+    return Task.spawn(function task_DCS_execute() {
+      if (!this.download.source.windowRef) {
+        throw new DownloadError({
+          message: "PDF saver must be passed an open window, and cannot be restarted.",
+          becauseSourceFailed: true,
+        });
+      }
+
+      let win = this.download.source.windowRef.get();
+
+      // Set windowRef to null to avoid re-trying.
+      this.download.source.windowRef = null;
+
+      if (!win) {
+        throw new DownloadError({
+          message: "PDF saver can't save a window that has been closed.",
+          becauseSourceFailed: true,
+        });
+      }
+
+      this.addToHistory();
+
+      let targetPath = this.download.target.path;
+
+      // An empty target file must exist for the PDF printer to work correctly.
+      let file = yield OS.File.open(targetPath, { truncate: true });
+      yield file.close();
+
+      let printSettings = gPrintSettingsService.newPrintSettings;
+
+      printSettings.printToFile = true;
+      printSettings.outputFormat = Ci.nsIPrintSettings.kOutputFormatPDF;
+      printSettings.toFileName = targetPath;
+
+      printSettings.printSilent = true;
+      printSettings.showPrintProgress = false;
+
+      printSettings.printBGImages = true;
+      printSettings.printBGColors = true;
+      printSettings.printFrameType = Ci.nsIPrintSettings.kFramesAsIs;
+      printSettings.headerStrCenter = "";
+      printSettings.headerStrLeft = "";
+      printSettings.headerStrRight = "";
+      printSettings.footerStrCenter = "";
+      printSettings.footerStrLeft = "";
+      printSettings.footerStrRight = "";
+
+      this._webBrowserPrint = win.QueryInterface(Ci.nsIInterfaceRequestor)
+                                 .getInterface(Ci.nsIWebBrowserPrint);
+
+      try {
+        yield new Promise((resolve, reject) => {
+          this._webBrowserPrint.print(printSettings, {
+            onStateChange: function (webProgress, request, stateFlags, status) {
+              if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+                if (!Components.isSuccessCode(status)) {
+                  reject(new DownloadError({ result: status,
+                                             inferCause: true }));
+                } else {
+                  resolve();
+                }
+              }
+            },
+            onProgressChange: function (webProgress, request, curSelfProgress,
+                                        maxSelfProgress, curTotalProgress,
+                                        maxTotalProgress) {
+              aSetProgressBytesFn(curTotalProgress, maxTotalProgress, false);
+            },
+            onLocationChange: function () {},
+            onStatusChange: function () {},
+            onSecurityChange: function () {},
+          });
+        });
+      } finally {
+        // Remove the print object to avoid leaks
+        this._webBrowserPrint = null;
+      }
+
+      let fileInfo = yield OS.File.stat(targetPath);
+      aSetProgressBytesFn(fileInfo.size, fileInfo.size, false);
+    }.bind(this));
+  },
+
+  /**
+   * Implements "DownloadSaver.cancel".
+   */
+  cancel: function DCS_cancel()
+  {
+    if (this._webBrowserPrint) {
+      this._webBrowserPrint.cancel();
+      this._webBrowserPrint = null;
+    }
+  },
+
+  /**
+   * Implements "DownloadSaver.toSerializable".
+   */
+  toSerializable: function ()
+  {
+    if (this.download.succeeded) {
+      return DownloadCopySaver.prototype.toSerializable.call(this);
+    }
+
+    // This object needs a window to recreate itself. If it didn't succeded
+    // it will not be possible to restart. Returning null here will
+    // prevent us from serializing it at all.
+    return null;
+  },
+};
+
+/**
+ * Creates a new DownloadPDFSaver object, with its initial state derived from
+ * its serializable representation.
+ *
+ * @param aSerializable
+ *        Serializable representation of a DownloadPDFSaver object.
+ *
+ * @return The newly created DownloadPDFSaver object.
+ */
+this.DownloadPDFSaver.fromSerializable = function (aSerializable) {
+  return new DownloadPDFSaver();
+};
+
--- a/toolkit/components/jsdownloads/src/DownloadStore.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadStore.jsm
@@ -159,17 +159,23 @@ this.DownloadStore.prototype = {
       // Take a static snapshot of the current state of all the downloads.
       let storeData = { list: [] };
       let atLeastOneDownload = false;
       for (let download of downloads) {
         try {
           if (!this.onsaveitem(download)) {
             continue;
           }
-          storeData.list.push(download.toSerializable());
+
+          let serializable = download.toSerializable();
+          if (!serializable) {
+            // This item cannot be persisted across sessions.
+            continue;
+          }
+          storeData.list.push(serializable);
           atLeastOneDownload = true;
         } catch (ex) {
           // If an item cannot be converted to a serializable form, don't
           // prevent others from being saved.
           Cu.reportError(ex);
         }
       }
 
new file mode 100644
--- /dev/null
+++ b/toolkit/components/jsdownloads/test/browser/browser.ini
@@ -0,0 +1,7 @@
+[DEFAULT]
+support-files =
+  head.js
+  testFile.html
+
+[browser_DownloadPDFSaver.js]
+skip-if = e10s || os != "win"
new file mode 100644
--- /dev/null
+++ b/toolkit/components/jsdownloads/test/browser/browser_DownloadPDFSaver.js
@@ -0,0 +1,101 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the PDF download saver, and tests using a window as a
+ * source for the copy download saver.
+ */
+
+"use strict";
+
+/**
+ * Helper function to make sure a window reference exists on the download source.
+ */
+function* test_download_windowRef(aTab, aDownload) {
+  ok(aDownload.source.windowRef, "Download source had a window reference");
+  ok(aDownload.source.windowRef instanceof Ci.xpcIJSWeakReference, "Download window reference is a weak ref");
+  is(aDownload.source.windowRef.get(), aTab.linkedBrowser.contentWindow, "Download window exists during test");
+}
+
+/**
+ * Helper function to check the state of a completed download.
+ */
+function* test_download_state_complete(aTab, aDownload, aPrivate, aCanceled) {
+  ok(aDownload.source, "Download has a source");
+  is(aDownload.source.url, aTab.linkedBrowser.contentWindow.location, "Download source has correct url");
+  is(aDownload.source.isPrivate, aPrivate, "Download source has correct private state");
+  ok(aDownload.stopped, "Download is stopped");
+  is(aCanceled, aDownload.canceled, "Download has correct canceled state");
+  is(!aCanceled, aDownload.succeeded, "Download has correct succeeded state");
+  is(aDownload.error, null, "Download error is not defined");
+}
+
+function* test_createDownload_common(aPrivate, aType) {
+  let tab = gBrowser.addTab(getRootDirectory(gTestPath) + "testFile.html");
+  yield promiseBrowserLoaded(tab.linkedBrowser);
+
+  if (aPrivate) {
+    tab.linkedBrowser.docShell.QueryInterface(Ci.nsILoadContext)
+                              .usePrivateBrowsing = true;
+  }
+
+  let download = yield Downloads.createDownload({
+    source: tab.linkedBrowser.contentWindow,
+    target: { path: getTempFile(TEST_TARGET_FILE_NAME_PDF).path },
+    saver: { type: aType },
+  });
+
+  yield test_download_windowRef(tab, download);
+  yield download.start();
+
+  yield test_download_state_complete(tab, download, aPrivate, false);
+  if (aType == "pdf") {
+    let signature = yield OS.File.read(download.target.path,
+                                       { bytes: 4, encoding: "us-ascii" });
+    is(signature, "%PDF", "File exists and signature matches");
+  } else {
+    ok((yield OS.File.exists(download.target.path)), "File exists");
+  }
+
+  gBrowser.removeTab(tab);
+}
+
+add_task(function* test_createDownload_pdf_private() {
+  yield test_createDownload_common(true, "pdf");
+});
+add_task(function* test_createDownload_pdf_not_private() {
+  yield test_createDownload_common(false, "pdf");
+});
+
+// Even for the copy saver, using a window should produce valid results
+add_task(function* test_createDownload_copy_private() {
+  yield test_createDownload_common(true, "copy");
+});
+add_task(function* test_createDownload_copy_not_private() {
+  yield test_createDownload_common(false, "copy");
+});
+
+add_task(function* test_cancel_pdf_download() {
+  let tab = gBrowser.addTab(getRootDirectory(gTestPath) + "testFile.html");
+  yield promiseBrowserLoaded(tab.linkedBrowser);
+
+  let download = yield Downloads.createDownload({
+    source: tab.linkedBrowser.contentWindow,
+    target: { path: getTempFile(TEST_TARGET_FILE_NAME_PDF).path },
+    saver: "pdf",
+  });
+
+  yield test_download_windowRef(tab, download);
+  download.start();
+
+  // Immediately cancel the download to test that it is erased correctly.
+  yield download.cancel();
+  yield test_download_state_complete(tab, download, false, true);
+
+  let exists = yield OS.File.exists(download.target.path)
+  ok(!exists, "Target file does not exist");
+
+  gBrowser.removeTab(tab);
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/components/jsdownloads/test/browser/head.js
@@ -0,0 +1,89 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Provides infrastructure for automated download components tests.
+ */
+
+"use strict";
+
+////////////////////////////////////////////////////////////////////////////////
+//// Globals
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+const Cr = Components.results;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "DownloadPaths",
+                                  "resource://gre/modules/DownloadPaths.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Downloads",
+                                  "resource://gre/modules/Downloads.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
+                                  "resource://gre/modules/FileUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+                                  "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "HttpServer",
+                                  "resource://testing-common/httpd.js");
+XPCOMUtils.defineLazyModuleGetter(this, "OS",
+                                  "resource://gre/modules/osfile.jsm");
+
+const TEST_TARGET_FILE_NAME_PDF = "test-download.pdf";
+
+////////////////////////////////////////////////////////////////////////////////
+//// Support functions
+
+// While the previous test file should have deleted all the temporary files it
+// used, on Windows these might still be pending deletion on the physical file
+// system.  Thus, start from a new base number every time, to make a collision
+// with a file that is still pending deletion highly unlikely.
+let gFileCounter = Math.floor(Math.random() * 1000000);
+
+/**
+ * Returns a reference to a temporary file, that is guaranteed not to exist, and
+ * to have never been created before.
+ *
+ * @param aLeafName
+ *        Suggested leaf name for the file to be created.
+ *
+ * @return nsIFile pointing to a non-existent file in a temporary directory.
+ *
+ * @note It is not enough to delete the file if it exists, or to delete the file
+ *       after calling nsIFile.createUnique, because on Windows the delete
+ *       operation in the file system may still be pending, preventing a new
+ *       file with the same name to be created.
+ */
+function getTempFile(aLeafName)
+{
+  // Prepend a serial number to the extension in the suggested leaf name.
+  let [base, ext] = DownloadPaths.splitBaseNameAndExtension(aLeafName);
+  let leafName = base + "-" + gFileCounter + ext;
+  gFileCounter++;
+
+  // Get a file reference under the temporary directory for this test file.
+  let file = FileUtils.getFile("TmpD", [leafName]);
+  ok(!file.exists(), "Temp file does not exist");
+
+  registerCleanupFunction(function () {
+    if (file.exists()) {
+      file.remove(false);
+    }
+  });
+
+  return file;
+}
+
+function promiseBrowserLoaded(browser) {
+  return new Promise(resolve => {
+    browser.addEventListener("load", function onLoad(event) {
+      if (event.target == browser.contentDocument) {
+        browser.removeEventListener("load", onLoad, true);
+        resolve();
+      }
+    }, true);
+  });
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/jsdownloads/test/browser/testFile.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Test Save as PDF</title>
+  </head>
+  <body>
+    <p>Save me as a PDF!</p>
+  </body>
+</html>
--- a/toolkit/components/jsdownloads/test/unit/test_DownloadStore.js
+++ b/toolkit/components/jsdownloads/test/unit/test_DownloadStore.js
@@ -52,23 +52,35 @@ add_task(function test_save_reload()
 
   listForSave.add(yield promiseNewDownload(httpUrl("source.txt")));
   listForSave.add(yield Downloads.createDownload({
     source: { url: httpUrl("empty.txt"),
               referrer: TEST_REFERRER_URL },
     target: getTempFile(TEST_TARGET_FILE_NAME),
   }));
 
+  // This PDF download should not be serialized because it never succeeds.
+  let pdfDownload = yield Downloads.createDownload({
+    source: { url: httpUrl("empty.txt"),
+              referrer: TEST_REFERRER_URL },
+    target: getTempFile(TEST_TARGET_FILE_NAME),
+    saver: "pdf",
+  });
+  listForSave.add(pdfDownload);
+
   let legacyDownload = yield promiseStartLegacyDownload();
   yield legacyDownload.cancel();
   listForSave.add(legacyDownload);
 
   yield storeForSave.save();
   yield storeForLoad.load();
 
+  // Remove the PDF download because it should not appear in this list.
+  listForSave.remove(pdfDownload);
+
   let itemsForSave = yield listForSave.getAll();
   let itemsForLoad = yield listForLoad.getAll();
 
   do_check_eq(itemsForSave.length, itemsForLoad.length);
 
   // Downloads should be reloaded in the same order.
   for (let i = 0; i < itemsForSave.length; i++) {
     // The reloaded downloads are different objects.
--- a/toolkit/components/jsdownloads/test/unit/test_Downloads.js
+++ b/toolkit/components/jsdownloads/test/unit/test_Downloads.js
@@ -56,16 +56,41 @@ add_task(function test_createDownload_pu
     source: { url: "about:blank" },
     target: { path: tempPath },
     saver: { type: "copy" }
   });
   do_check_false(download.source.isPrivate);
 });
 
 /**
+ * Tests createDownload for a pdf saver throws if only given a url.
+ */
+add_task(function test_createDownload_pdf()
+{
+  let download = yield Downloads.createDownload({
+    source: { url: "about:blank" },
+    target: { path: getTempFile(TEST_TARGET_FILE_NAME).path },
+    saver: { type: "pdf" },
+  });
+
+  try {
+    yield download.start();
+    do_throw("The download should have failed.");
+  } catch (ex if ex instanceof Downloads.Error && ex.becauseSourceFailed) { }
+
+  do_check_false(download.succeeded);
+  do_check_true(download.stopped);
+  do_check_false(download.canceled);
+  do_check_true(download.error !== null);
+  do_check_true(download.error.becauseSourceFailed);
+  do_check_false(download.error.becauseTargetFailed);
+  do_check_false(yield OS.File.exists(download.target.path));
+});
+
+/**
  * Tests "fetch" with nsIURI and nsIFile as arguments.
  */
 add_task(function test_fetch_uri_file_arguments()
 {
   let targetFile = getTempFile(TEST_TARGET_FILE_NAME);
   yield Downloads.fetch(NetUtil.newURI(httpUrl("source.txt")), targetFile);
   yield promiseVerifyContents(targetFile.path, TEST_DATA_SHORT);
 });