Bug 841495 - Implement background page thumbnail service (part 2: background thumbnail service). r=ttaubert
authorDrew Willcoxon <adw@mozilla.com>
Mon, 06 May 2013 21:10:28 -0700
changeset 142043 47a4fc52a1e53b8d45c49ba3123d57719f9c8b0d
parent 142042 78b49aecbefa5f4fc962fafb55895d363d403bbc
child 142044 9189df8e7d74503a6055b9d0b2dc3075c3f0b04f
push id2579
push userakeybl@mozilla.com
push dateMon, 24 Jun 2013 18:52:47 +0000
treeherdermozilla-beta@b69b7de8a05a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersttaubert
bugs841495
milestone23.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 841495 - Implement background page thumbnail service (part 2: background thumbnail service). r=ttaubert
toolkit/components/thumbnails/BackgroundPageThumbs.jsm
toolkit/components/thumbnails/Makefile.in
toolkit/components/thumbnails/PageThumbs.jsm
toolkit/components/thumbnails/content/backgroundPageThumbsContent.js
toolkit/components/thumbnails/jar.mn
toolkit/components/thumbnails/test/Makefile.in
toolkit/components/thumbnails/test/browser_thumbnails_background.js
toolkit/components/thumbnails/test/thumbnails_background.sjs
new file mode 100644
--- /dev/null
+++ b/toolkit/components/thumbnails/BackgroundPageThumbs.jsm
@@ -0,0 +1,299 @@
+/* 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/. */
+
+const EXPORTED_SYMBOLS = [
+  "BackgroundPageThumbs",
+];
+
+const DEFAULT_CAPTURE_TIMEOUT = 30000; // ms
+const DESTROY_BROWSER_TIMEOUT = 60000; // ms
+const FRAME_SCRIPT_URL = "chrome://global/content/backgroundPageThumbsContent.js";
+
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/PageThumbs.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/PrivateBrowsingUtils.jsm");
+
+const BackgroundPageThumbs = {
+
+  /**
+   * Asynchronously captures a thumbnail of the given URL.
+   *
+   * The page is loaded anonymously, and plug-ins are disabled.
+   *
+   * @param url      The URL to capture.
+   * @param options  An optional object that configures the capture.  Its
+   *                 properties are the following, and all are optional:
+   * @opt onDone     A function that will be asynchronously called when the
+   *                 capture is complete or times out.  It's called as
+   *                   onDone(url),
+   *                 where `url` is the captured URL.
+   * @opt timeout    The capture will time out after this many milliseconds have
+   *                 elapsed after calling this method.  Defaults to 30000 (30
+   *                 seconds).
+   */
+  capture: function (url, options={}) {
+    let cap = new Capture(url, this._onCaptureOrTimeout.bind(this), options);
+    this._captureQueue = this._captureQueue || [];
+    this._captureQueue.push(cap);
+    this._processCaptureQueue();
+  },
+
+  /**
+   * Ensures that initialization of the thumbnail browser's parent window has
+   * begun.
+   *
+   * @return  True if the parent window is completely initialized and can be
+   *          used, and false if initialization has started but not completed.
+   */
+  _ensureParentWindowReady: function () {
+    if (this._parentWin)
+      // Already fully initialized.
+      return true;
+    if (this._startedParentWinInit)
+      // Already started initializing.
+      return false;
+
+    this._startedParentWinInit = true;
+
+    PrivateBrowsingUtils.whenHiddenPrivateWindowReady(function (parentWin) {
+      parentWin.addEventListener("unload", function (event) {
+        if (event.target == parentWin.document)
+          this._destroy();
+      }.bind(this), true);
+
+      if (canHostBrowser(parentWin)) {
+        this._parentWin = parentWin;
+        this._processCaptureQueue();
+        return;
+      }
+
+      // Otherwise, create an html:iframe, stick it in the parent document, and
+      // use it to host the browser.  about:blank will not have the system
+      // principal, so it can't host, but a document with a chrome URI will.
+      let iframe = parentWin.document.createElementNS(HTML_NS, "iframe");
+      iframe.setAttribute("src", "chrome://global/content/mozilla.xhtml");
+      let onLoad = function onLoadFn() {
+        iframe.removeEventListener("load", onLoad, true);
+        this._parentWin = iframe.contentWindow;
+        this._processCaptureQueue();
+      }.bind(this);
+      iframe.addEventListener("load", onLoad, true);
+      parentWin.document.documentElement.appendChild(iframe);
+      this._hostIframe = iframe;
+    }.bind(this));
+
+    return false;
+  },
+
+  /**
+   * Destroys the service.  Queued and pending captures will never complete, and
+   * their consumer callbacks will never be called.
+   */
+  _destroy: function () {
+    if (this._captureQueue)
+      this._captureQueue.forEach(cap => cap.destroy());
+    this._destroyBrowser();
+    if (this._hostIframe)
+      this._hostIframe.remove();
+    delete this._captureQueue;
+    delete this._hostIframe;
+    delete this._startedParentWinInit;
+    delete this._parentWin;
+  },
+
+  /**
+   * Creates the thumbnail browser if it doesn't already exist.
+   */
+  _ensureBrowser: function () {
+    if (this._thumbBrowser)
+      return;
+
+    let browser = this._parentWin.document.createElementNS(XUL_NS, "browser");
+    browser.setAttribute("type", "content");
+    browser.setAttribute("remote", "true");
+    browser.setAttribute("privatebrowsing", "true");
+    this._parentWin.document.documentElement.appendChild(browser);
+
+    browser.messageManager.loadFrameScript(FRAME_SCRIPT_URL, false);
+    this._thumbBrowser = browser;
+  },
+
+  _destroyBrowser: function () {
+    if (!this._thumbBrowser)
+      return;
+    this._thumbBrowser.remove();
+    delete this._thumbBrowser;
+  },
+
+  /**
+   * Starts the next capture if the queue is not empty and the service is fully
+   * initialized.
+   */
+  _processCaptureQueue: function () {
+    if (!this._captureQueue.length ||
+        this._captureQueue[0].pending ||
+        !this._ensureParentWindowReady())
+      return;
+
+    // Ready to start the first capture in the queue.
+    this._ensureBrowser();
+    this._captureQueue[0].start(this._thumbBrowser.messageManager);
+    if (this._destroyBrowserTimer) {
+      this._destroyBrowserTimer.cancel();
+      delete this._destroyBrowserTimer;
+    }
+  },
+
+  /**
+   * Called when a capture completes or times out.
+   */
+  _onCaptureOrTimeout: function (capture) {
+    // Since timeouts are configurable per capture, and a capture's timeout
+    // timer starts when it's created, it's possible for any capture to time
+    // out regardless of its position in the queue.
+    let idx = this._captureQueue.indexOf(capture);
+    if (idx < 0)
+      throw new Error("The capture should be in the queue.");
+    this._captureQueue.splice(idx, 1);
+
+    // Start the destroy-browser timer *before* processing the capture queue.
+    let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+    timer.initWithCallback(this._destroyBrowser.bind(this),
+                           this._destroyBrowserTimeout,
+                           Ci.nsITimer.TYPE_ONE_SHOT);
+    this._destroyBrowserTimer = timer;
+
+    this._processCaptureQueue();
+  },
+
+  _destroyBrowserTimeout: DESTROY_BROWSER_TIMEOUT,
+};
+
+/**
+ * Represents a single capture request in the capture queue.
+ *
+ * @param url              The URL to capture.
+ * @param captureCallback  A function you want called when the capture
+ *                         completes.
+ * @param options          The capture options.
+ */
+function Capture(url, captureCallback, options) {
+  this.url = url;
+  this.captureCallback = captureCallback;
+  this.options = options;
+  this.id = Capture.nextID++;
+
+  // The timeout starts when the consumer requests the capture, not when the
+  // capture is dequeued and started.
+  let timeout = typeof(options.timeout) == "number" ? options.timeout :
+                DEFAULT_CAPTURE_TIMEOUT;
+  this._timeoutTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+  this._timeoutTimer.initWithCallback(this, timeout, Ci.nsITimer.TYPE_ONE_SHOT);
+}
+
+Capture.prototype = {
+
+  get pending() {
+    return !!this._msgMan;
+  },
+
+  /**
+   * Sends a message to the content script to start the capture.
+   *
+   * @param messageManager  The nsIMessageSender of the thumbnail browser.
+   */
+  start: function (messageManager) {
+    this._msgMan = messageManager;
+    this._msgMan.sendAsyncMessage("BackgroundPageThumbs:capture",
+                                  { id: this.id, url: this.url });
+    this._msgMan.addMessageListener("BackgroundPageThumbs:didCapture", this);
+  },
+
+  /**
+   * The only intended external use of this method is by the service when it's
+   * uninitializing and doing things like destroying the thumbnail browser.  In
+   * that case the consumer's completion callback will never be called.
+   *
+   * This method is not idempotent.  It's an error to call it more than once on
+   * the same capture.
+   */
+  destroy: function () {
+    this._timeoutTimer.cancel();
+    delete this._timeoutTimer;
+    if (this._msgMan) {
+      // The capture may have never started, so _msgMan may be undefined.
+      this._msgMan.removeMessageListener("BackgroundPageThumbs:didCapture",
+                                         this);
+      delete this._msgMan;
+    }
+    delete this.captureCallback;
+  },
+
+  // Called when the didCapture message is received.
+  receiveMessage: function (msg) {
+    // A different timed-out capture may have finally successfully completed, so
+    // discard messages that aren't meant for this capture.
+    if (msg.json.id == this.id)
+      this._done(msg.json);
+  },
+
+  // Called when the timeout timer fires.
+  notify: function () {
+    this._done(null);
+  },
+
+  _done: function (data) {
+    // Note that _done will be called only once, by either receiveMessage or
+    // notify, since it calls destroy, which cancels the timeout timer and
+    // removes the didCapture message listener.
+
+    this.captureCallback(this);
+    this.destroy();
+
+    let callOnDone = function callOnDoneFn() {
+      if (!("onDone" in this.options))
+        return;
+      try {
+        this.options.onDone(this.url);
+      }
+      catch (err) {
+        Cu.reportError(err);
+      }
+    }.bind(this);
+
+    if (!data) {
+      callOnDone();
+      return;
+    }
+    PageThumbs._store(this.url, data.finalURL, data.imageData).then(callOnDone);
+  },
+};
+
+Capture.nextID = 0;
+
+/**
+ * Returns true if the given window is suitable for hosting our xul:browser.
+ *
+ * @param win  The window.
+ * @return     True if the window can host the browser, false otherwise.
+ */
+function canHostBrowser(win) {
+  // The host document needs to have the system principal since, like all code
+  // intended to be used in chrome, the browser binding does lots of things that
+  // assume it has it.  The document must also allow XUL children.  So check for
+  // both the system principal and the "allowXULXBL" permission.  (It turns out
+  // that allowXULXBL is satisfied by the system principal alone, making that
+  // check not strictly necessary, but it's here for robustness.)
+  let principal = win.document.nodePrincipal;
+  if (!Services.scriptSecurityManager.isSystemPrincipal(principal))
+    return false;
+  let permResult = Services.perms.testPermissionFromPrincipal(principal,
+                                                              "allowXULXBL");
+  return permResult == Ci.nsIPermissionManager.ALLOW_ACTION;
+}
--- a/toolkit/components/thumbnails/Makefile.in
+++ b/toolkit/components/thumbnails/Makefile.in
@@ -10,13 +10,14 @@ VPATH		= @srcdir@
 include $(DEPTH)/config/autoconf.mk
 
 EXTRA_COMPONENTS = \
 	BrowserPageThumbs.manifest \
 	PageThumbsProtocol.js \
 	$(NULL)
 
 EXTRA_JS_MODULES = \
+	BackgroundPageThumbs.jsm \
 	PageThumbsWorker.js \
 	PageThumbs.jsm \
 	$(NULL)
 
 include $(topsrcdir)/config/rules.mk
--- a/toolkit/components/thumbnails/PageThumbs.jsm
+++ b/toolkit/components/thumbnails/PageThumbs.jsm
@@ -232,36 +232,41 @@ this.PageThumbs = {
 
   /**
    * Captures a thumbnail from a given window and draws it to the given canvas.
    * @param aWindow The DOM window to capture a thumbnail from.
    * @param aCanvas The canvas to draw to.
    */
   captureToCanvas: function PageThumbs_captureToCanvas(aWindow, aCanvas) {
     let telemetryCaptureTime = new Date();
+    this._captureToCanvas(aWindow, aCanvas);
+    let telemetry = Services.telemetry;
+    telemetry.getHistogramById("FX_THUMBNAILS_CAPTURE_TIME_MS")
+      .add(new Date() - telemetryCaptureTime);
+  },
+
+  // The background thumbnail service captures to canvas but doesn't want to
+  // participate in this service's telemetry, which is why this method exists.
+  _captureToCanvas: function PageThumbs__captureToCanvas(aWindow, aCanvas) {
     let [sw, sh, scale] = this._determineCropSize(aWindow, aCanvas);
     let ctx = aCanvas.getContext("2d");
 
     // Scale the canvas accordingly.
     ctx.save();
     ctx.scale(scale, scale);
 
     try {
       // Draw the window contents to the canvas.
       ctx.drawWindow(aWindow, 0, 0, sw, sh, THUMBNAIL_BG_COLOR,
                      ctx.DRAWWINDOW_DO_NOT_FLUSH);
     } catch (e) {
       // We couldn't draw to the canvas for some reason.
     }
 
     ctx.restore();
-
-    let telemetry = Services.telemetry;
-    telemetry.getHistogramById("FX_THUMBNAILS_CAPTURE_TIME_MS")
-      .add(new Date() - telemetryCaptureTime);
   },
 
   /**
    * Captures a thumbnail for the given browser and stores it to the cache.
    * @param aBrowser The browser to capture a thumbnail for.
    * @param aCallback The function to be called when finished (optional).
    */
   captureAndStore: function PageThumbs_captureAndStore(aBrowser, aCallback) {
@@ -273,47 +278,60 @@ this.PageThumbs = {
     let channel = aBrowser.docShell.currentDocumentChannel;
     let originalURL = channel.originalURI.spec;
 
     TaskUtils.spawn((function task() {
       let isSuccess = true;
       try {
         let blob = yield this.captureToBlob(aBrowser.contentWindow);
         let buffer = yield TaskUtils.readBlob(blob);
-
-        let telemetryStoreTime = new Date();
-        yield PageThumbsStorage.writeData(url, new Uint8Array(buffer));
-
-        Services.telemetry.getHistogramById("FX_THUMBNAILS_STORE_TIME_MS")
-          .add(new Date() - telemetryStoreTime);
-
-        // We've been redirected. Create a copy of the current thumbnail for
-        // the redirect source. We need to do this because:
-        //
-        // 1) Users can drag any kind of links onto the newtab page. If those
-        //    links redirect to a different URL then we want to be able to
-        //    provide thumbnails for both of them.
-        //
-        // 2) The newtab page should actually display redirect targets, only.
-        //    Because of bug 559175 this information can get lost when using
-        //    Sync and therefore also redirect sources appear on the newtab
-        //    page. We also want thumbnails for those.
-        if (url != originalURL) {
-          yield PageThumbsStorage.copy(url, originalURL);
-        }
+        yield this._store(originalURL, url, buffer);
       } catch (_) {
         isSuccess = false;
       }
       if (aCallback) {
         aCallback(isSuccess);
       }
     }).bind(this));
   },
 
   /**
+   * Stores data to disk for the given URLs.
+   *
+   * NB: The background thumbnail service calls this, too.
+   *
+   * @param aOriginalURL The URL with which the capture was initiated.
+   * @param aFinalURL The URL to which aOriginalURL ultimately resolved.
+   * @param aData An ArrayBuffer containing the image data.
+   * @return {Promise}
+   */
+  _store: function PageThumbs__store(aOriginalURL, aFinalURL, aData) {
+    return TaskUtils.spawn(function () {
+      let telemetryStoreTime = new Date();
+      yield PageThumbsStorage.writeData(aFinalURL, aData);
+      Services.telemetry.getHistogramById("FX_THUMBNAILS_STORE_TIME_MS")
+        .add(new Date() - telemetryStoreTime);
+
+      // We've been redirected. Create a copy of the current thumbnail for
+      // the redirect source. We need to do this because:
+      //
+      // 1) Users can drag any kind of links onto the newtab page. If those
+      //    links redirect to a different URL then we want to be able to
+      //    provide thumbnails for both of them.
+      //
+      // 2) The newtab page should actually display redirect targets, only.
+      //    Because of bug 559175 this information can get lost when using
+      //    Sync and therefore also redirect sources appear on the newtab
+      //    page. We also want thumbnails for those.
+      if (aFinalURL != aOriginalURL)
+        yield PageThumbsStorage.copy(aFinalURL, aOriginalURL);
+    });
+  },
+
+  /**
    * Register an expiration filter.
    *
    * When thumbnails are going to expire, each registered filter is asked for a
    * list of thumbnails to keep.
    *
    * The filter (if it is a callable) or its filterForThumbnailExpiration method
    * (if the filter is an object) is called with a single argument.  The
    * argument is a callback function.  The filter must call the callback
@@ -369,20 +387,22 @@ this.PageThumbs = {
     if (scaledWidth > thumbnailWidth)
       sw -= Math.floor(Math.abs(scaledWidth - thumbnailWidth) * scale);
 
     return [sw, sh, scale];
   },
 
   /**
    * Creates a new hidden canvas element.
+   * @param aWindow The document of this window will be used to create the
+   *                canvas.  If not given, the hidden window will be used.
    * @return The newly created canvas.
    */
-  _createCanvas: function PageThumbs_createCanvas() {
-    let doc = Services.appShell.hiddenDOMWindow.document;
+  _createCanvas: function PageThumbs_createCanvas(aWindow) {
+    let doc = (aWindow || Services.appShell.hiddenDOMWindow).document;
     let canvas = doc.createElementNS(HTML_NAMESPACE, "canvas");
     canvas.mozOpaque = true;
     canvas.mozImageSmoothingEnabled = true;
     let [thumbnailWidth, thumbnailHeight] = this._getThumbnailSize();
     canvas.width = thumbnailWidth;
     canvas.height = thumbnailHeight;
     return canvas;
   },
@@ -448,25 +468,26 @@ this.PageThumbsStorage = {
   getFilePathForURL: function Storage_getFilePathForURL(aURL) {
     return OS.Path.join(this.path, this.getLeafNameForURL(aURL));
   },
 
   /**
    * Write the contents of a thumbnail, off the main thread.
    *
    * @param {string} aURL The url for which to store a thumbnail.
-   * @param {string} aData The data to store in the thumbnail, as
+   * @param {ArrayBuffer} aData The data to store in the thumbnail, as
    * an ArrayBuffer. This array buffer is neutered and cannot be
    * reused after the copy.
    *
    * @return {Promise}
    */
-  writeData: function Storage_write(aURL, aData) {
+  writeData: function Storage_writeData(aURL, aData) {
     let path = this.getFilePathForURL(aURL);
     this.ensurePath();
+    aData = new Uint8Array(aData);
     let msg = [
       path,
       aData,
       {
         tmpPath: path + ".tmp",
         bytes: aData.byteLength,
         flush: false /*thumbnails do not require the level of guarantee provided by flush*/
       }];
new file mode 100644
--- /dev/null
+++ b/toolkit/components/thumbnails/content/backgroundPageThumbsContent.js
@@ -0,0 +1,76 @@
+/* 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/. */
+
+(function () { // bug 673569 workaround :(
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/PageThumbs.jsm");
+
+const backgroundPageThumbsContent = {
+
+  init: function () {
+    // Stop about:blank from loading.  If it finishes loading after a capture
+    // request is received, it could trigger the capture's load listener.
+    this._webNav.stop(Ci.nsIWebNavigation.STOP_NETWORK);
+    addMessageListener("BackgroundPageThumbs:capture",
+                       this._onCapture.bind(this));
+  },
+
+  get _webNav() {
+    return docShell.QueryInterface(Ci.nsIWebNavigation);
+  },
+
+  _onCapture: function (msg) {
+    if (this._onLoad) {
+      this._webNav.stop(Ci.nsIWebNavigation.STOP_NETWORK);
+      removeEventListener("load", this._onLoad, true);
+    }
+
+    this._onLoad = function onLoad(event) {
+      if (event.target != content.document)
+        return;
+      removeEventListener("load", this._onLoad, true);
+      delete this._onLoad;
+
+      // The viewport is always reset to a small size after load, so resize it.
+      this._sizeViewport();
+      let canvas = PageThumbs._createCanvas(content);
+      PageThumbs._captureToCanvas(content, canvas);
+
+      let finalURL = this._webNav.currentURI.spec;
+      let fileReader = Cc["@mozilla.org/files/filereader;1"].
+                       createInstance(Ci.nsIDOMFileReader);
+      fileReader.onloadend = function onArrayBufferLoad() {
+        sendAsyncMessage("BackgroundPageThumbs:didCapture", {
+          id: msg.json.id,
+          imageData: fileReader.result,
+          finalURL: finalURL,
+        });
+      };
+      canvas.toBlob(blob => fileReader.readAsArrayBuffer(blob));
+    }.bind(this);
+
+    addEventListener("load", this._onLoad, true);
+    this._webNav.loadURI(msg.json.url, Ci.nsIWebNavigation.LOAD_FLAGS_NONE,
+                         null, null, null);
+  },
+
+  _sizeViewport: function () {
+    let width = {};
+    let height = {};
+    Cc["@mozilla.org/gfx/screenmanager;1"].
+      getService(Ci.nsIScreenManager).
+      primaryScreen.
+      GetRect({}, {}, width, height);
+    content.
+      QueryInterface(Ci.nsIInterfaceRequestor).
+      getInterface(Ci.nsIDOMWindowUtils).
+      setCSSViewport(width.value, height.value);
+  },
+};
+
+backgroundPageThumbsContent.init();
+
+})();
new file mode 100644
--- /dev/null
+++ b/toolkit/components/thumbnails/jar.mn
@@ -0,0 +1,6 @@
+# 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/.
+
+toolkit.jar:
++ content/global/backgroundPageThumbsContent.js (content/backgroundPageThumbsContent.js)
--- a/toolkit/components/thumbnails/test/Makefile.in
+++ b/toolkit/components/thumbnails/test/Makefile.in
@@ -7,25 +7,27 @@ topsrcdir	= @top_srcdir@
 srcdir		= @srcdir@
 VPATH		= @srcdir@
 relativesrcdir  = @relativesrcdir@
 
 include $(DEPTH)/config/autoconf.mk
 include $(topsrcdir)/config/rules.mk
 
 _BROWSER_FILES = \
+	browser_thumbnails_background.js \
 	browser_thumbnails_capture.js \
 	browser_thumbnails_expiration.js \
 	browser_thumbnails_privacy.js \
 	browser_thumbnails_redirect.js \
 	browser_thumbnails_storage.js \
 	browser_thumbnails_storage_migrate3.js \
 	browser_thumbnails_bug726727.js \
 	browser_thumbnails_bug727765.js \
 	head.js \
 	background_red.html \
 	background_red_scroll.html \
 	background_red_redirect.sjs \
 	privacy_cache_control.sjs \
+	thumbnails_background.sjs \
 	$(NULL)
 
 libs::	$(_BROWSER_FILES)
 	$(INSTALL) $(foreach f,$^,"$f") $(DEPTH)/_tests/testing/mochitest/browser/$(relativesrcdir)
new file mode 100644
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_background.js
@@ -0,0 +1,212 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_PAGE_URL = "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_background.sjs";
+
+const imports = {};
+Cu.import("resource://gre/modules/BackgroundPageThumbs.jsm", imports);
+Cu.import("resource://gre/modules/PageThumbs.jsm", imports);
+Cu.import("resource://gre/modules/Task.jsm", imports);
+Cu.import("resource://gre/modules/commonjs/sdk/core/promise.js", imports);
+registerCleanupFunction(function () {
+  imports.BackgroundPageThumbs._destroy();
+});
+
+function test() {
+  waitForExplicitFinish();
+  spawnNextTest();
+}
+
+function spawnNextTest() {
+  if (!tests.length) {
+    finish();
+    return;
+  }
+  imports.Task.spawn(tests.shift()).then(spawnNextTest, function onError(err) {
+    ok(false, err);
+    spawnNextTest();
+  });
+}
+
+let tests = [
+
+  function basic() {
+    let url = "http://www.example.com/";
+    let file = fileForURL(url);
+    ok(!file.exists(), "Thumbnail should not be cached yet.");
+
+    let capturedURL = yield capture(url);
+    is(capturedURL, url, "Captured URL should be URL passed to capture");
+
+    ok(file.exists(), "Thumbnail should be cached after capture: " + file.path);
+    file.remove(false);
+  },
+
+  function queueing() {
+    let deferred = imports.Promise.defer();
+    let urls = [
+      "http://www.example.com/0",
+      "http://www.example.com/1",
+      "http://www.example.com/2",
+    ];
+    let files = urls.map(fileForURL);
+    files.forEach(f => ok(!f.exists(), "Thumbnail should not be cached yet."));
+    urls.forEach(function (url) {
+      imports.BackgroundPageThumbs.capture(url, {
+        onDone: function onDone(capturedURL) {
+          ok(urls.length > 0, "onDone called, so URLs should still remain");
+          is(capturedURL, urls.shift(),
+             "Captured URL should be currently expected URL (i.e., " +
+             "capture() callbacks should be called in the correct order)");
+          let file = files.shift();
+          ok(file.exists(),
+             "Thumbnail should be cached after capture: " + file.path);
+          if (!urls.length)
+            deferred.resolve();
+        },
+      });
+    });
+    yield deferred.promise;
+  },
+
+  function timeout() {
+    let deferred = imports.Promise.defer();
+    let url = testPageURL();
+    let file = fileForURL(url);
+    ok(!file.exists(), "Thumbnail should not be cached already.");
+    let numCalls = 0;
+    imports.BackgroundPageThumbs.capture(testPageURL(), {
+      timeout: 0,
+      onDone: function onDone(capturedURL) {
+        is(capturedURL, url, "Captured URL should be URL passed to capture");
+        is(numCalls++, 0, "onDone should be called only once");
+        ok(!file.exists(),
+           "Capture timed out so thumbnail should not be cached: " + file.path);
+        deferred.resolve();
+      },
+    });
+    yield deferred.promise;
+  },
+
+  function timeoutQueueing() {
+    let deferred = imports.Promise.defer();
+    let urls = [
+      { url: testPageURL({ wait: 2000 }), timeout: 30000 },
+      { url: testPageURL({ wait: 2001 }), timeout: 1000 },
+      { url: testPageURL({ wait: 2002 }), timeout: 0 },
+    ];
+
+    // The expected callback order is the reverse of the above, and the reverse
+    // of the order in which the captures are made.
+    let expectedOrder = urls.slice();
+    expectedOrder.reverse();
+    expectedOrder = expectedOrder.map(u => u.url);
+
+    let files = expectedOrder.map(fileForURL);
+    files.forEach(f => ok(!f.exists(), "Thumbnail should not be cached yet."));
+
+    urls.forEach(function ({ url, timeout }) {
+      imports.BackgroundPageThumbs.capture(url, {
+        timeout: timeout,
+        onDone: function onDone(capturedURL) {
+          ok(expectedOrder.length > 0,
+             "onDone called, so URLs should still remain");
+          is(capturedURL, expectedOrder.shift(),
+             "Captured URL should be currently expected URL (i.e., " +
+             "capture() callbacks should be called in the correct order)");
+          let file = files.shift();
+          if (timeout > 2000)
+            ok(file.exists(),
+               "Thumbnail should be cached after capture: " + file.path);
+          else
+            ok(!file.exists(),
+               "Capture timed out so thumbnail should not be cached: " +
+               file.path);
+          if (!expectedOrder.length)
+            deferred.resolve();
+        },
+      });
+    });
+    yield deferred.promise;
+  },
+
+  function redirect() {
+    let finalURL = "http://example.com/redirected";
+    let originalURL = testPageURL({ redirect: finalURL });
+
+    let originalFile = fileForURL(originalURL);
+    ok(!originalFile.exists(),
+       "Thumbnail file for original URL should not exist yet.");
+
+    let finalFile = fileForURL(finalURL);
+    ok(!finalFile.exists(),
+       "Thumbnail file for final URL should not exist yet.");
+
+    let capturedURL = yield capture(originalURL);
+    is(capturedURL, originalURL,
+       "Captured URL should be URL passed to capture");
+    ok(originalFile.exists(),
+       "Thumbnail for original URL should be cached: " + originalFile.path);
+    ok(finalFile.exists(),
+       "Thumbnail for final URL should be cached: " + finalFile.path);
+
+    originalFile.remove(false);
+    finalFile.remove(false);
+  },
+
+  function destroyBrowserTimeout() {
+    let url1 = "http://example.com/1";
+    let file1 = fileForURL(url1);
+    ok(!file1.exists(), "First file should not exist yet.");
+
+    let url2 = "http://example.com/2";
+    let file2 = fileForURL(url2);
+    ok(!file2.exists(), "Second file should not exist yet.");
+
+    let defaultTimeout = imports.BackgroundPageThumbs._destroyBrowserTimeout;
+    imports.BackgroundPageThumbs._destroyBrowserTimeout = 1000;
+
+    yield capture(url1);
+    ok(file1.exists(), "First file should exist after capture.");
+
+    yield wait(2000);
+    is(imports.BackgroundPageThumbs._thumbBrowser, undefined,
+       "Thumb browser should be destroyed after timeout.");
+
+    yield capture(url2);
+    ok(file2.exists(), "Second file should exist after capture.");
+
+    imports.BackgroundPageThumbs._destroyBrowserTimeout = defaultTimeout;
+    isnot(imports.BackgroundPageThumbs._thumbBrowser, undefined,
+          "Thumb browser should exist immediately after capture.");
+  },
+];
+
+function capture(url, options) {
+  let deferred = imports.Promise.defer();
+  options = options || {};
+  options.onDone = function onDone(capturedURL) {
+    deferred.resolve(capturedURL);
+  };
+  imports.BackgroundPageThumbs.capture(url, options);
+  return deferred.promise;
+}
+
+function testPageURL(opts) {
+  return TEST_PAGE_URL + "?" + encodeURIComponent(JSON.stringify(opts || {}));
+}
+
+function fileForURL(url) {
+  let path = imports.PageThumbsStorage.getFilePathForURL(url);
+  let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+  file.initWithPath(path);
+  return file;
+}
+
+function wait(ms) {
+  let deferred = imports.Promise.defer();
+  setTimeout(function onTimeout() {
+    deferred.resolve();
+  }, ms);
+  return deferred.promise;
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/thumbnails/test/thumbnails_background.sjs
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// The timer never fires if it's not declared and set to this variable outside
+// handleRequest, as if it's getting GC'ed when handleRequest's scope goes away.
+// Shouldn't the timer thread hold a strong reference to it?
+var timer;
+
+function handleRequest(req, resp) {
+  resp.processAsync();
+  resp.setHeader("Cache-Control", "no-cache", false);
+  resp.setHeader("Content-Type", "text/html;charset=utf-8", false);
+
+  let opts = {};
+  try {
+    opts = JSON.parse(decodeURIComponent(req.queryString));
+  }
+  catch (err) {}
+
+  if (opts.redirect) {
+    resp.setHeader("Location", opts.redirect);
+    resp.setStatusLine(null, 303, null);
+    resp.finish();
+    return;
+  }
+
+  if (opts.wait) {
+    resp.write("Waiting " + opts.wait + " ms... ");
+    timer = Components.classes["@mozilla.org/timer;1"].
+            createInstance(Components.interfaces.nsITimer);
+    timer.init(function ding() {
+      resp.write("OK!");
+      resp.finish();
+    }, opts.wait, Components.interfaces.nsITimer.TYPE_ONE_SHOT);
+    return;
+  }
+
+  resp.write("<pre>" + JSON.stringify(opts, undefined, 2) + "</pre>");
+  resp.finish();
+}