Merge f-t to m-c
authorPhil Ringnalda <philringnalda@gmail.com>
Sun, 30 Mar 2014 16:31:41 -0700
changeset 194651 382f676d0ed98a144993c89a0eb21d1bd35a78c1
parent 194629 8b00b1a791ab7b5236e4047f0c085c0cbcb5ea59 (current diff)
parent 194650 05906fafbb0f5f5eaa84b24e7b62f59eb5aecfe0 (diff)
child 194653 747b04556894122d1c8dac13bd6aacce163a44f1
child 194665 43663582cfdb29e408adf621a52661d55d5fe9fb
child 194669 c60580cafbef696f65c1d238949e54eb00e8294e
push id3624
push userasasaki@mozilla.com
push dateMon, 09 Jun 2014 21:49:01 +0000
treeherdermozilla-beta@b1a5da15899a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
milestone31.0a1
first release with
nightly linux32
382f676d0ed9 / 31.0a1 / 20140331030201 / files
nightly linux64
382f676d0ed9 / 31.0a1 / 20140331030201 / files
nightly mac
382f676d0ed9 / 31.0a1 / 20140331030201 / files
nightly win32
382f676d0ed9 / 31.0a1 / 20140331030201 / files
nightly win64
382f676d0ed9 / 31.0a1 / 20140331030201 / files
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
releases
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Merge f-t to m-c
browser/themes/linux/newtab/controls.png
browser/themes/osx/newtab/controls.png
browser/themes/windows/newtab/controls.png
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1200,16 +1200,19 @@ pref("devtools.scratchpad.enableCodeFold
 // Enable the Style Editor.
 pref("devtools.styleeditor.enabled", true);
 pref("devtools.styleeditor.source-maps-enabled", false);
 pref("devtools.styleeditor.autocompletion-enabled", true);
 
 // Enable the Shader Editor.
 pref("devtools.shadereditor.enabled", false);
 
+// Enable the Canvas Debugger.
+pref("devtools.canvasdebugger.enabled", false);
+
 // Enable tools for Chrome development.
 pref("devtools.chrome.enabled", false);
 
 // Default theme ("dark" or "light")
 pref("devtools.theme", "light");
 
 // Display the introductory text
 pref("devtools.gcli.hideIntro", false);
--- a/browser/components/tabview/test/browser.ini
+++ b/browser/components/tabview/test/browser.ini
@@ -83,16 +83,17 @@ skip-if = true # Bug 921984, hopefully f
 [browser_tabview_bug625424.js]
 [browser_tabview_bug625955.js]
 [browser_tabview_bug626368.js]
 [browser_tabview_bug626455.js]
 [browser_tabview_bug626525.js]
 [browser_tabview_bug626791.js]
 [browser_tabview_bug627736.js]
 [browser_tabview_bug628061.js]
+skip-if = os == 'linux'&&debug # bug 989083
 [browser_tabview_bug628165.js]
 [browser_tabview_bug628270.js]
 [browser_tabview_bug628887.js]
 [browser_tabview_bug629189.js]
 [browser_tabview_bug629195.js]
 skip-if = os == 'linux'&&debug # bug 981703
 [browser_tabview_bug630102.js]
 [browser_tabview_bug630157.js]
@@ -113,16 +114,17 @@ skip-if = true # Bug 922422
 skip-if = os == 'linux'&&debug # bug 989083
 [browser_tabview_bug644097.js]
 [browser_tabview_bug648882.js]
 skip-if = true # Bug 752862
 [browser_tabview_bug649006.js]
 [browser_tabview_bug649307.js]
 [browser_tabview_bug649319.js]
 [browser_tabview_bug650280_perwindowpb.js]
+skip-if = os == 'linux'&&debug # bug 989083
 [browser_tabview_bug650573.js]
 [browser_tabview_bug651311.js]
 [browser_tabview_bug654295.js]
 [browser_tabview_bug654721.js]
 [browser_tabview_bug654941.js]
 skip-if = true # Bug 754222
 [browser_tabview_bug655269.js]
 [browser_tabview_bug656778.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/canvasdebugger.js
@@ -0,0 +1,1270 @@
+/* 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/. */
+"use strict";
+
+const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource:///modules/devtools/SideMenuWidget.jsm");
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+
+const require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require;
+const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
+const EventEmitter = require("devtools/toolkit/event-emitter");
+const { CallWatcherFront } = require("devtools/server/actors/call-watcher");
+const { CanvasFront } = require("devtools/server/actors/canvas");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+  "resource://gre/modules/Task.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
+  "resource://gre/modules/PluralForm.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
+  "resource://gre/modules/FileUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+  "resource://gre/modules/NetUtil.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "DevToolsUtils",
+  "resource://gre/modules/devtools/DevToolsUtils.jsm");
+
+// The panel's window global is an EventEmitter firing the following events:
+const EVENTS = {
+  // When the UI is reset from tab navigation.
+  UI_RESET: "CanvasDebugger:UIReset",
+
+  // When all the animation frame snapshots are removed by the user.
+  SNAPSHOTS_LIST_CLEARED: "CanvasDebugger:SnapshotsListCleared",
+
+  // When an animation frame snapshot starts/finishes being recorded.
+  SNAPSHOT_RECORDING_STARTED: "CanvasDebugger:SnapshotRecordingStarted",
+  SNAPSHOT_RECORDING_FINISHED: "CanvasDebugger:SnapshotRecordingFinished",
+
+  // When an animation frame snapshot was selected and all its data displayed.
+  SNAPSHOT_RECORDING_SELECTED: "CanvasDebugger:SnapshotRecordingSelected",
+
+  // After all the function calls associated with an animation frame snapshot
+  // are displayed in the UI.
+  CALL_LIST_POPULATED: "CanvasDebugger:CallListPopulated",
+
+  // After the stack associated with a call in an animation frame snapshot
+  // is displayed in the UI.
+  CALL_STACK_DISPLAYED: "CanvasDebugger:CallStackDisplayed",
+
+  // After a screenshot associated with a call in an animation frame snapshot
+  // is displayed in the UI.
+  CALL_SCREENSHOT_DISPLAYED: "CanvasDebugger:ScreenshotDisplayed",
+
+  // After all the thumbnails associated with an animation frame snapshot
+  // are displayed in the UI.
+  THUMBNAILS_DISPLAYED: "CanvasDebugger:ThumbnailsDisplayed",
+
+  // When a source is shown in the JavaScript Debugger at a specific location.
+  SOURCE_SHOWN_IN_JS_DEBUGGER: "CanvasDebugger:SourceShownInJsDebugger",
+  SOURCE_NOT_FOUND_IN_JS_DEBUGGER: "CanvasDebugger:SourceNotFoundInJsDebugger"
+};
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+const STRINGS_URI = "chrome://browser/locale/devtools/canvasdebugger.properties"
+
+const SNAPSHOT_START_RECORDING_DELAY = 10; // ms
+const SNAPSHOT_DATA_EXPORT_MAX_BLOCK = 1000; // ms
+const SNAPSHOT_DATA_DISPLAY_DELAY = 10; // ms
+const SCREENSHOT_DISPLAY_DELAY = 100; // ms
+const STACK_FUNC_INDENTATION = 14; // px
+
+// This identifier string is simply used to tentatively ascertain whether or not
+// a JSON loaded from disk is actually something generated by this tool or not.
+// It isn't, of course, a definitive verification, but a Good Enough™
+// approximation before continuing the import. Don't localize this.
+const CALLS_LIST_SERIALIZER_IDENTIFIER = "Recorded Animation Frame Snapshot";
+const CALLS_LIST_SERIALIZER_VERSION = 1;
+const CALLS_LIST_SLOW_SAVE_DELAY = 100; // ms
+
+/**
+ * The current target and the Canvas front, set by this tool's host.
+ */
+let gToolbox, gTarget, gFront;
+
+/**
+ * Initializes the canvas debugger controller and views.
+ */
+function startupCanvasDebugger() {
+  return promise.all([
+    EventsHandler.initialize(),
+    SnapshotsListView.initialize(),
+    CallsListView.initialize()
+  ]);
+}
+
+/**
+ * Destroys the canvas debugger controller and views.
+ */
+function shutdownCanvasDebugger() {
+  return promise.all([
+    EventsHandler.destroy(),
+    SnapshotsListView.destroy(),
+    CallsListView.destroy()
+  ]);
+}
+
+/**
+ * Functions handling target-related lifetime events.
+ */
+let EventsHandler = {
+  /**
+   * Listen for events emitted by the current tab target.
+   */
+  initialize: function() {
+    this._onTabNavigated = this._onTabNavigated.bind(this);
+    gTarget.on("will-navigate", this._onTabNavigated);
+    gTarget.on("navigate", this._onTabNavigated);
+  },
+
+  /**
+   * Remove events emitted by the current tab target.
+   */
+  destroy: function() {
+    gTarget.off("will-navigate", this._onTabNavigated);
+    gTarget.off("navigate", this._onTabNavigated);
+  },
+
+  /**
+   * Called for each location change in the debugged tab.
+   */
+  _onTabNavigated: function(event) {
+    if (event != "will-navigate") {
+      return;
+    }
+    // Make sure the backend is prepared to handle <canvas> contexts.
+    gFront.setup({ reload: false });
+
+    // Reset UI.
+    SnapshotsListView.empty();
+    CallsListView.empty();
+
+    $("#record-snapshot").removeAttribute("checked");
+    $("#record-snapshot").removeAttribute("disabled");
+    $("#record-snapshot").hidden = false;
+
+    $("#reload-notice").hidden = true;
+    $("#empty-notice").hidden = false;
+    $("#import-notice").hidden = true;
+
+    $("#debugging-pane-contents").hidden = true;
+    $("#screenshot-container").hidden = true;
+    $("#snapshot-filmstrip").hidden = true;
+
+    window.emit(EVENTS.UI_RESET);
+  }
+};
+
+/**
+ * Functions handling the recorded animation frame snapshots UI.
+ */
+let SnapshotsListView = Heritage.extend(WidgetMethods, {
+  /**
+   * Initialization function, called when the tool is started.
+   */
+  initialize: function() {
+    this.widget = new SideMenuWidget($("#snapshots-list"), {
+      showArrows: true
+    });
+
+    this._onSelect = this._onSelect.bind(this);
+    this._onClearButtonClick = this._onClearButtonClick.bind(this);
+    this._onRecordButtonClick = this._onRecordButtonClick.bind(this);
+    this._onImportButtonClick = this._onImportButtonClick.bind(this);
+    this._onSaveButtonClick = this._onSaveButtonClick.bind(this);
+
+    this.emptyText = L10N.getStr("noSnapshotsText");
+    this.widget.addEventListener("select", this._onSelect, false);
+  },
+
+  /**
+   * Destruction function, called when the tool is closed.
+   */
+  destroy: function() {
+    this.widget.removeEventListener("select", this._onSelect, false);
+  },
+
+  /**
+   * Adds a snapshot entry to this container.
+   *
+   * @return object
+   *         The newly inserted item.
+   */
+  addSnapshot: function() {
+    let contents = document.createElement("hbox");
+    contents.className = "snapshot-item";
+
+    let thumbnail = document.createElementNS(HTML_NS, "canvas");
+    thumbnail.className = "snapshot-item-thumbnail";
+    thumbnail.width = CanvasFront.THUMBNAIL_HEIGHT;
+    thumbnail.height = CanvasFront.THUMBNAIL_HEIGHT;
+
+    let title = document.createElement("label");
+    title.className = "plain snapshot-item-title";
+    title.setAttribute("value",
+      L10N.getFormatStr("snapshotsList.itemLabel", this.itemCount + 1));
+
+    let calls = document.createElement("label");
+    calls.className = "plain snapshot-item-calls";
+    calls.setAttribute("value",
+      L10N.getStr("snapshotsList.loadingLabel"));
+
+    let save = document.createElement("label");
+    save.className = "plain snapshot-item-save";
+    save.addEventListener("click", this._onSaveButtonClick, false);
+
+    let spacer = document.createElement("spacer");
+    spacer.setAttribute("flex", "1");
+
+    let footer = document.createElement("hbox");
+    footer.className = "snapshot-item-footer";
+    footer.appendChild(save);
+
+    let details = document.createElement("vbox");
+    details.className = "snapshot-item-details";
+    details.appendChild(title);
+    details.appendChild(calls);
+    details.appendChild(spacer);
+    details.appendChild(footer);
+
+    contents.appendChild(thumbnail);
+    contents.appendChild(details);
+
+    // Append a recorded snapshot item to this container.
+    return this.push([contents], {
+      attachment: {
+        // The snapshot and function call actors, along with the thumbnails
+        // will be available as soon as recording finishes.
+        actor: null,
+        calls: null,
+        thumbnails: null,
+        screenshot: null
+      }
+    });
+  },
+
+  /**
+   * Customizes a shapshot in this container.
+   *
+   * @param Item snapshotItem
+   *        An item inserted via `SnapshotsListView.addSnapshot`.
+   * @param object snapshotActor
+   *        The frame snapshot actor received from the backend.
+   * @param object snapshotOverview
+   *        Additional data about the snapshot received from the backend.
+   */
+  customizeSnapshot: function(snapshotItem, snapshotActor, snapshotOverview) {
+    // Make sure the function call actors are stored on the item,
+    // to be used when populating the CallsListView.
+    snapshotItem.attachment.actor = snapshotActor;
+    let functionCalls = snapshotItem.attachment.calls = snapshotOverview.calls;
+    let thumbnails = snapshotItem.attachment.thumbnails = snapshotOverview.thumbnails;
+    let screenshot = snapshotItem.attachment.screenshot = snapshotOverview.screenshot;
+
+    let lastThumbnail = thumbnails[thumbnails.length - 1];
+    let { width, height, flipped, pixels } = lastThumbnail;
+
+    let thumbnailNode = $(".snapshot-item-thumbnail", snapshotItem.target);
+    thumbnailNode.setAttribute("flipped", flipped);
+    drawImage(thumbnailNode, width, height, pixels, { centered: true });
+
+    let callsNode = $(".snapshot-item-calls", snapshotItem.target);
+    let drawCalls = functionCalls.filter(e => CanvasFront.DRAW_CALLS.has(e.name));
+
+    let drawCallsStr = PluralForm.get(drawCalls.length,
+      L10N.getStr("snapshotsList.drawCallsLabel"));
+    let funcCallsStr = PluralForm.get(functionCalls.length,
+      L10N.getStr("snapshotsList.functionCallsLabel"));
+
+    callsNode.setAttribute("value",
+      drawCallsStr.replace("#1", drawCalls.length) + ", " +
+      funcCallsStr.replace("#1", functionCalls.length));
+
+    let saveNode = $(".snapshot-item-save", snapshotItem.target);
+    saveNode.setAttribute("disabled", !!snapshotItem.isLoadedFromDisk);
+    saveNode.setAttribute("value", snapshotItem.isLoadedFromDisk
+      ? L10N.getStr("snapshotsList.loadedLabel")
+      : L10N.getStr("snapshotsList.saveLabel"));
+
+    // Make sure there's always a selected item available.
+    if (!this.selectedItem) {
+      this.selectedIndex = 0;
+    }
+  },
+
+  /**
+   * The select listener for this container.
+   */
+  _onSelect: function({ detail: snapshotItem }) {
+    if (!snapshotItem) {
+      return;
+    }
+    let { calls, thumbnails, screenshot } = snapshotItem.attachment;
+
+    $("#reload-notice").hidden = true;
+    $("#empty-notice").hidden = true;
+    $("#import-notice").hidden = false;
+
+    $("#debugging-pane-contents").hidden = true;
+    $("#screenshot-container").hidden = true;
+    $("#snapshot-filmstrip").hidden = true;
+
+    Task.spawn(function*() {
+      // Wait for a few milliseconds between presenting the function calls,
+      // screenshot and thumbnails, to allow each component being
+      // sequentially drawn. This gives the illusion of snappiness.
+
+      yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
+      CallsListView.showCalls(calls);
+      $("#debugging-pane-contents").hidden = false;
+      $("#import-notice").hidden = true;
+
+      yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
+      CallsListView.showThumbnails(thumbnails);
+      $("#snapshot-filmstrip").hidden = false;
+
+      yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
+      CallsListView.showScreenshot(screenshot);
+      $("#screenshot-container").hidden = false;
+
+      window.emit(EVENTS.SNAPSHOT_RECORDING_SELECTED);
+    });
+  },
+
+  /**
+   * The click listener for the "clear" button in this container.
+   */
+  _onClearButtonClick: function() {
+    Task.spawn(function*() {
+      SnapshotsListView.empty();
+      CallsListView.empty();
+
+      $("#reload-notice").hidden = true;
+      $("#empty-notice").hidden = true;
+      $("#import-notice").hidden = true;
+
+      if (yield gFront.isInitialized()) {
+        $("#empty-notice").hidden = false;
+      } else {
+        $("#reload-notice").hidden = false;
+      }
+
+      $("#debugging-pane-contents").hidden = true;
+      $("#screenshot-container").hidden = true;
+      $("#snapshot-filmstrip").hidden = true;
+
+      window.emit(EVENTS.SNAPSHOTS_LIST_CLEARED);
+    });
+  },
+
+  /**
+   * The click listener for the "record" button in this container.
+   */
+  _onRecordButtonClick: function() {
+    Task.spawn(function*() {
+      $("#record-snapshot").setAttribute("checked", "true");
+      $("#record-snapshot").setAttribute("disabled", "true");
+
+      // Insert a "dummy" snapshot item in the view, to hint that recording
+      // has now started. However, wait for a few milliseconds before actually
+      // starting the recording, since that might block rendering and prevent
+      // the dummy snapshot item from being drawn.
+      let snapshotItem = this.addSnapshot();
+
+      // If this is the first item, immediately show the "Loading…" notice.
+      if (this.itemCount == 1) {
+        $("#empty-notice").hidden = true;
+        $("#import-notice").hidden = false;
+      }
+
+      yield DevToolsUtils.waitForTime(SNAPSHOT_START_RECORDING_DELAY);
+      window.emit(EVENTS.SNAPSHOT_RECORDING_STARTED);
+
+      let snapshotActor = yield gFront.recordAnimationFrame();
+      let snapshotOverview = yield snapshotActor.getOverview();
+      this.customizeSnapshot(snapshotItem, snapshotActor, snapshotOverview);
+
+      $("#record-snapshot").removeAttribute("checked");
+      $("#record-snapshot").removeAttribute("disabled");
+
+      window.emit(EVENTS.SNAPSHOT_RECORDING_FINISHED);
+    }.bind(this));
+  },
+
+  /**
+   * The click listener for the "import" button in this container.
+   */
+  _onImportButtonClick: function() {
+    let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+    fp.init(window, L10N.getStr("snapshotsList.saveDialogTitle"), Ci.nsIFilePicker.modeOpen);
+    fp.appendFilter(L10N.getStr("snapshotsList.saveDialogJSONFilter"), "*.json");
+    fp.appendFilter(L10N.getStr("snapshotsList.saveDialogAllFilter"), "*.*");
+
+    if (fp.show() != Ci.nsIFilePicker.returnOK) {
+      return;
+    }
+
+    let channel = NetUtil.newChannel(fp.file);
+    channel.contentType = "text/plain";
+
+    NetUtil.asyncFetch(channel, (inputStream, status) => {
+      if (!Components.isSuccessCode(status)) {
+        console.error("Could not import recorded animation frame snapshot file.");
+        return;
+      }
+      try {
+        let string = NetUtil.readInputStreamToString(inputStream, inputStream.available());
+        var data = JSON.parse(string);
+      } catch (e) {
+        console.error("Could not read animation frame snapshot file.");
+        return;
+      }
+      if (data.fileType != CALLS_LIST_SERIALIZER_IDENTIFIER) {
+        console.error("Unrecognized animation frame snapshot file.");
+        return;
+      }
+
+      // Add a `isLoadedFromDisk` flag on everything to avoid sending invalid
+      // requests to the backend, since we're not dealing with actors anymore.
+      let snapshotItem = this.addSnapshot();
+      snapshotItem.isLoadedFromDisk = true;
+      data.calls.forEach(e => e.isLoadedFromDisk = true);
+
+      // Create array buffers from the parsed pixel arrays.
+      for (let thumbnail of data.thumbnails) {
+        let thumbnailPixelsArray = thumbnail.pixels.split(",");
+        thumbnail.pixels = new Uint32Array(thumbnailPixelsArray);
+      }
+      let screenshotPixelsArray = data.screenshot.pixels.split(",");
+      data.screenshot.pixels = new Uint32Array(screenshotPixelsArray);
+
+      this.customizeSnapshot(snapshotItem, data.calls, data);
+    });
+  },
+
+  /**
+   * The click listener for the "save" button of each item in this container.
+   */
+  _onSaveButtonClick: function(e) {
+    let snapshotItem = this.getItemForElement(e.target);
+
+    let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+    fp.init(window, L10N.getStr("snapshotsList.saveDialogTitle"), Ci.nsIFilePicker.modeSave);
+    fp.appendFilter(L10N.getStr("snapshotsList.saveDialogJSONFilter"), "*.json");
+    fp.appendFilter(L10N.getStr("snapshotsList.saveDialogAllFilter"), "*.*");
+    fp.defaultString = "snapshot.json";
+
+    // Start serializing all the function call actors for the specified snapshot,
+    // while the nsIFilePicker dialog is being opened. Snappy.
+    let serialized = Task.spawn(function*() {
+      let data = {
+        fileType: CALLS_LIST_SERIALIZER_IDENTIFIER,
+        version: CALLS_LIST_SERIALIZER_VERSION,
+        calls: [],
+        thumbnails: [],
+        screenshot: null
+      };
+      let functionCalls = snapshotItem.attachment.calls;
+      let thumbnails = snapshotItem.attachment.thumbnails;
+      let screenshot = snapshotItem.attachment.screenshot;
+
+      // Prepare all the function calls for serialization.
+      yield DevToolsUtils.yieldingEach(functionCalls, (call, i) => {
+        let { type, name, file, line, argsPreview, callerPreview } = call;
+        return call.getDetails().then(({ stack }) => {
+          data.calls[i] = {
+            type: type,
+            name: name,
+            file: file,
+            line: line,
+            stack: stack,
+            argsPreview: argsPreview,
+            callerPreview: callerPreview
+          };
+        });
+      });
+
+      // Prepare all the thumbnails for serialization.
+      yield DevToolsUtils.yieldingEach(thumbnails, (thumbnail, i) => {
+        let { index, width, height, flipped, pixels } = thumbnail;
+        data.thumbnails.push({
+          index: index,
+          width: width,
+          height: height,
+          flipped: flipped,
+          pixels: Array.join(pixels, ",")
+        });
+      });
+
+      // Prepare the screenshot for serialization.
+      let { index, width, height, flipped, pixels } = screenshot;
+      data.screenshot = {
+        index: index,
+        width: width,
+        height: height,
+        flipped: flipped,
+        pixels: Array.join(pixels, ",")
+      };
+
+      let string = JSON.stringify(data);
+      let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"].
+        createInstance(Ci.nsIScriptableUnicodeConverter);
+
+      converter.charset = "UTF-8";
+      return converter.convertToInputStream(string);
+    });
+
+    // Open the nsIFilePicker and wait for the function call actors to finish
+    // being serialized, in order to save the generated JSON data to disk.
+    fp.open({ done: result => {
+      if (result == Ci.nsIFilePicker.returnCancel) {
+        return;
+      }
+      let footer = $(".snapshot-item-footer", snapshotItem.target);
+      let save = $(".snapshot-item-save", snapshotItem.target);
+
+      // Show a throbber and a "Saving…" label if serializing isn't immediate.
+      setNamedTimeout("call-list-save", CALLS_LIST_SLOW_SAVE_DELAY, () => {
+        footer.setAttribute("saving", "");
+        save.setAttribute("disabled", "true");
+        save.setAttribute("value", L10N.getStr("snapshotsList.savingLabel"));
+      });
+
+      serialized.then(inputStream => {
+        let outputStream = FileUtils.openSafeFileOutputStream(fp.file);
+
+        NetUtil.asyncCopy(inputStream, outputStream, status => {
+          if (!Components.isSuccessCode(status)) {
+            console.error("Could not save recorded animation frame snapshot file.");
+          }
+          clearNamedTimeout("call-list-save");
+          footer.removeAttribute("saving");
+          save.removeAttribute("disabled");
+          save.setAttribute("value", L10N.getStr("snapshotsList.saveLabel"));
+        });
+      });
+    }});
+  }
+});
+
+/**
+ * Functions handling details about a single recorded animation frame snapshot
+ * (the calls list, rendering preview, thumbnails filmstrip etc.).
+ */
+let CallsListView = Heritage.extend(WidgetMethods, {
+  /**
+   * Initialization function, called when the tool is started.
+   */
+  initialize: function() {
+    this.widget = new SideMenuWidget($("#calls-list"));
+    this._slider = $("#calls-slider");
+    this._searchbox = $("#calls-searchbox");
+    this._filmstrip = $("#snapshot-filmstrip");
+
+    this._onSelect = this._onSelect.bind(this);
+    this._onSlideMouseDown = this._onSlideMouseDown.bind(this);
+    this._onSlideMouseUp = this._onSlideMouseUp.bind(this);
+    this._onSlide = this._onSlide.bind(this);
+    this._onSearch = this._onSearch.bind(this);
+    this._onScroll = this._onScroll.bind(this);
+    this._onExpand = this._onExpand.bind(this);
+    this._onStackFileClick = this._onStackFileClick.bind(this);
+    this._onThumbnailClick = this._onThumbnailClick.bind(this);
+
+    this.widget.addEventListener("select", this._onSelect, false);
+    this._slider.addEventListener("mousedown", this._onSlideMouseDown, false);
+    this._slider.addEventListener("mouseup", this._onSlideMouseUp, false);
+    this._slider.addEventListener("change", this._onSlide, false);
+    this._searchbox.addEventListener("input", this._onSearch, false);
+    this._filmstrip.addEventListener("wheel", this._onScroll, false);
+  },
+
+  /**
+   * Destruction function, called when the tool is closed.
+   */
+  destroy: function() {
+    this.widget.removeEventListener("select", this._onSelect, false);
+    this._slider.removeEventListener("mousedown", this._onSlideMouseDown, false);
+    this._slider.removeEventListener("mouseup", this._onSlideMouseUp, false);
+    this._slider.removeEventListener("change", this._onSlide, false);
+    this._searchbox.removeEventListener("input", this._onSearch, false);
+    this._filmstrip.removeEventListener("wheel", this._onScroll, false);
+  },
+
+  /**
+   * Populates this container with a list of function calls.
+   *
+   * @param array functionCalls
+   *        A list of function call actors received from the backend.
+   */
+  showCalls: function(functionCalls) {
+    this.empty();
+
+    for (let i = 0, len = functionCalls.length; i < len; i++) {
+      let call = functionCalls[i];
+
+      let view = document.createElement("vbox");
+      view.className = "call-item-view devtools-monospace";
+      view.setAttribute("flex", "1");
+
+      let contents = document.createElement("hbox");
+      contents.className = "call-item-contents";
+      contents.setAttribute("align", "center");
+      contents.addEventListener("dblclick", this._onExpand);
+      view.appendChild(contents);
+
+      let index = document.createElement("label");
+      index.className = "plain call-item-index";
+      index.setAttribute("flex", "1");
+      index.setAttribute("value", i + 1);
+
+      let gutter = document.createElement("hbox");
+      gutter.className = "call-item-gutter";
+      gutter.appendChild(index);
+      contents.appendChild(gutter);
+
+      // Not all function calls have a caller that was stringified (e.g.
+      // context calls have a "gl" or "ctx" caller preview).
+      if (call.callerPreview) {
+        let context = document.createElement("label");
+        context.className = "plain call-item-context";
+        context.setAttribute("value", call.callerPreview);
+        contents.appendChild(context);
+
+        let separator = document.createElement("label");
+        separator.className = "plain call-item-separator";
+        separator.setAttribute("value", ".");
+        contents.appendChild(separator);
+      }
+
+      let name = document.createElement("label");
+      name.className = "plain call-item-name";
+      name.setAttribute("value", call.name);
+      contents.appendChild(name);
+
+      let argsPreview = document.createElement("label");
+      argsPreview.className = "plain call-item-args";
+      argsPreview.setAttribute("crop", "end");
+      argsPreview.setAttribute("flex", "100");
+      // Getters and setters are displayed differently from regular methods.
+      if (call.type == CallWatcherFront.METHOD_FUNCTION) {
+        argsPreview.setAttribute("value", "(" + call.argsPreview + ")");
+      } else {
+        argsPreview.setAttribute("value", " = " + call.argsPreview);
+      }
+      contents.appendChild(argsPreview);
+
+      let location = document.createElement("label");
+      location.className = "plain call-item-location";
+      location.setAttribute("value", getFileName(call.file) + ":" + call.line);
+      location.setAttribute("crop", "start");
+      location.setAttribute("flex", "1");
+      location.addEventListener("mousedown", this._onExpand);
+      contents.appendChild(location);
+
+      // Append a function call item to this container.
+      this.push([view], {
+        staged: true,
+        attachment: {
+          actor: call
+        }
+      });
+
+      // Highlight certain calls that are probably more interesting than
+      // everything else, making it easier to quickly glance over them.
+      if (CanvasFront.DRAW_CALLS.has(call.name)) {
+        view.setAttribute("draw-call", "");
+      }
+      if (CanvasFront.INTERESTING_CALLS.has(call.name)) {
+        view.setAttribute("interesting-call", "");
+      }
+    }
+
+    // Flushes all the prepared function call items into this container.
+    this.commit();
+    window.emit(EVENTS.CALL_LIST_POPULATED);
+
+    // Resetting the function selection slider's value (shown in this
+    // container's toolbar) would trigger a selection event, which should be
+    // ignored in this case.
+    this._ignoreSliderChanges = true;
+    this._slider.value = 0;
+    this._slider.max = functionCalls.length - 1;
+    this._ignoreSliderChanges = false;
+  },
+
+  /**
+   * Displays an image in the rendering preview of this container, generated
+   * for the specified draw call in the recorded animation frame snapshot.
+   *
+   * @param array screenshot
+   *        A single "snapshot-image" instance received from the backend.
+   */
+  showScreenshot: function(screenshot) {
+    let { index, width, height, flipped, pixels } = screenshot;
+
+    let screenshotNode = $("#screenshot-image");
+    screenshotNode.setAttribute("flipped", flipped);
+    drawBackground("screenshot-rendering", width, height, pixels);
+
+    let dimensionsNode = $("#screenshot-dimensions");
+    dimensionsNode.setAttribute("value", ~~width + " x " + ~~height);
+
+    window.emit(EVENTS.CALL_SCREENSHOT_DISPLAYED);
+  },
+
+  /**
+   * Populates this container's footer with a list of thumbnails, one generated
+   * for each draw call in the recorded animation frame snapshot.
+   *
+   * @param array thumbnails
+   *        An array of "snapshot-image" instances received from the backend.
+   */
+  showThumbnails: function(thumbnails) {
+    while (this._filmstrip.hasChildNodes()) {
+      this._filmstrip.firstChild.remove();
+    }
+    for (let thumbnail of thumbnails) {
+      this.appendThumbnail(thumbnail);
+    }
+
+    window.emit(EVENTS.THUMBNAILS_DISPLAYED);
+  },
+
+  /**
+   * Displays an image in the thumbnails list of this container, generated
+   * for the specified draw call in the recorded animation frame snapshot.
+   *
+   * @param array thumbnail
+   *        A single "snapshot-image" instance received from the backend.
+   */
+  appendThumbnail: function(thumbnail) {
+    let { index, width, height, flipped, pixels } = thumbnail;
+
+    let thumbnailNode = document.createElementNS(HTML_NS, "canvas");
+    thumbnailNode.setAttribute("flipped", flipped);
+    thumbnailNode.width = Math.max(CanvasFront.THUMBNAIL_HEIGHT, width);
+    thumbnailNode.height = Math.max(CanvasFront.THUMBNAIL_HEIGHT, height);
+    drawImage(thumbnailNode, width, height, pixels, { centered: true });
+
+    thumbnailNode.className = "filmstrip-thumbnail";
+    thumbnailNode.onmousedown = e => this._onThumbnailClick(e, index);
+    thumbnailNode.setAttribute("index", index);
+    this._filmstrip.appendChild(thumbnailNode);
+  },
+
+  /**
+   * Sets the currently highlighted thumbnail in this container.
+   * A screenshot will always correlate to a thumbnail in the filmstrip,
+   * both being identified by the same 'index' of the context function call.
+   *
+   * @param number index
+   *        The context function call's index.
+   */
+  set highlightedThumbnail(index) {
+    let currHighlightedThumbnail = $(".filmstrip-thumbnail[index='" + index + "']");
+    if (currHighlightedThumbnail == null) {
+      return;
+    }
+
+    let prevIndex = this._highlightedThumbnailIndex
+    let prevHighlightedThumbnail = $(".filmstrip-thumbnail[index='" + prevIndex + "']");
+    if (prevHighlightedThumbnail) {
+      prevHighlightedThumbnail.removeAttribute("highlighted");
+    }
+
+    currHighlightedThumbnail.setAttribute("highlighted", "");
+    currHighlightedThumbnail.scrollIntoView();
+    this._highlightedThumbnailIndex = index;
+  },
+
+  /**
+   * Gets the currently highlighted thumbnail in this container.
+   * @return number
+   */
+  get highlightedThumbnail() {
+    return this._highlightedThumbnailIndex;
+  },
+
+  /**
+   * The select listener for this container.
+   */
+  _onSelect: function({ detail: callItem }) {
+    if (!callItem) {
+      return;
+    }
+
+    // Some of the stepping buttons don't make sense specifically while the
+    // last function call is selected.
+    if (this.selectedIndex == this.itemCount - 1) {
+      $("#resume").setAttribute("disabled", "true");
+      $("#step-over").setAttribute("disabled", "true");
+      $("#step-out").setAttribute("disabled", "true");
+    } else {
+      $("#resume").removeAttribute("disabled");
+      $("#step-over").removeAttribute("disabled");
+      $("#step-out").removeAttribute("disabled");
+    }
+
+    // Correlate the currently selected item with the function selection
+    // slider's value. Avoid triggering a redundant selection event.
+    this._ignoreSliderChanges = true;
+    this._slider.value = this.selectedIndex;
+    this._ignoreSliderChanges = false;
+
+    // Can't generate screenshots for function call actors loaded from disk.
+    // XXX: Bug 984844.
+    if (callItem.attachment.actor.isLoadedFromDisk) {
+      return;
+    }
+
+    // To keep continuous selection buttery smooth (for example, while pressing
+    // the DOWN key or moving the slider), only display the screenshot after
+    // any kind of user input stops.
+    setConditionalTimeout("screenshot-display", SCREENSHOT_DISPLAY_DELAY, () => {
+      return !this._isSliding;
+    }, () => {
+      let frameSnapshot = SnapshotsListView.selectedItem.attachment.actor
+      let functionCall = callItem.attachment.actor;
+      frameSnapshot.generateScreenshotFor(functionCall).then(screenshot => {
+        this.showScreenshot(screenshot);
+        this.highlightedThumbnail = screenshot.index;
+      });
+    });
+  },
+
+  /**
+   * The mousedown listener for the call selection slider.
+   */
+  _onSlideMouseDown: function() {
+    this._isSliding = true;
+  },
+
+  /**
+   * The mouseup listener for the call selection slider.
+   */
+  _onSlideMouseUp: function() {
+    this._isSliding = false;
+  },
+
+  /**
+   * The change listener for the call selection slider.
+   */
+  _onSlide: function() {
+    // Avoid performing any operations when programatically changing the value.
+    if (this._ignoreSliderChanges) {
+      return;
+    }
+    let selectedFunctionCallIndex = this.selectedIndex = this._slider.value;
+
+    // While sliding, immediately show the most relevant thumbnail for a
+    // function call, for a nice diff-like animation effect between draws.
+    let thumbnails = SnapshotsListView.selectedItem.attachment.thumbnails;
+    let thumbnail = getThumbnailForCall(thumbnails, selectedFunctionCallIndex);
+
+    // Avoid drawing and highlighting if the selected function call has the
+    // same thumbnail as the last one.
+    if (thumbnail.index == this.highlightedThumbnail) {
+      return;
+    }
+    // If a thumbnail wasn't found (e.g. the backend avoids creating thumbnails
+    // when rendering offscreen), simply defer to the first available one.
+    if (thumbnail.index == -1) {
+      thumbnail = thumbnails[0];
+    }
+
+    let { index, width, height, flipped, pixels } = thumbnail;
+    this.highlightedThumbnail = index;
+
+    let screenshotNode = $("#screenshot-image");
+    screenshotNode.setAttribute("flipped", flipped);
+    drawBackground("screenshot-rendering", width, height, pixels);
+  },
+
+  /**
+   * The input listener for the calls searchbox.
+   */
+  _onSearch: function(e) {
+    let lowerCaseSearchToken = this._searchbox.value.toLowerCase();
+
+    this.filterContents(e => {
+      let call = e.attachment.actor;
+      let name = call.name.toLowerCase();
+      let file = call.file.toLowerCase();
+      let line = call.line.toString().toLowerCase();
+      let args = call.argsPreview.toLowerCase();
+
+      return name.contains(lowerCaseSearchToken) ||
+             file.contains(lowerCaseSearchToken) ||
+             line.contains(lowerCaseSearchToken) ||
+             args.contains(lowerCaseSearchToken);
+    });
+  },
+
+  /**
+   * The wheel listener for the filmstrip that contains all the thumbnails.
+   */
+  _onScroll: function(e) {
+    this._filmstrip.scrollLeft += e.deltaX;
+  },
+
+  /**
+   * The click/dblclick listener for an item or location url in this container.
+   * When expanding an item, it's corresponding call stack will be displayed.
+   */
+  _onExpand: function(e) {
+    let callItem = this.getItemForElement(e.target);
+    let view = $(".call-item-view", callItem.target);
+
+    // If the call stack nodes were already created, simply re-show them
+    // or jump to the corresponding file and line in the Debugger if a
+    // location link was clicked.
+    if (view.hasAttribute("call-stack-populated")) {
+      let isExpanded = view.getAttribute("call-stack-expanded") == "true";
+
+      // If clicking on the location, jump to the Debugger.
+      if (e.target.classList.contains("call-item-location")) {
+        let { file, line } = callItem.attachment.actor;
+        viewSourceInDebugger(file, line);
+        return;
+      }
+      // Otherwise hide the call stack.
+      else {
+        view.setAttribute("call-stack-expanded", !isExpanded);
+        $(".call-item-stack", view).hidden = isExpanded;
+        return;
+      }
+    }
+
+    let list = document.createElement("vbox");
+    list.className = "call-item-stack";
+    view.setAttribute("call-stack-populated", "");
+    view.setAttribute("call-stack-expanded", "true");
+    view.appendChild(list);
+
+    /**
+     * Creates a function call nodes in this container for a stack.
+     */
+    let display = stack => {
+      for (let i = 1; i < stack.length; i++) {
+        let call = stack[i];
+
+        let contents = document.createElement("hbox");
+        contents.className = "call-item-stack-fn";
+        contents.style.MozPaddingStart = (i * STACK_FUNC_INDENTATION) + "px";
+
+        let name = document.createElement("label");
+        name.className = "plain call-item-stack-fn-name";
+        name.setAttribute("value", "↳ " + call.name + "()");
+        contents.appendChild(name);
+
+        let spacer = document.createElement("spacer");
+        spacer.setAttribute("flex", "100");
+        contents.appendChild(spacer);
+
+        let location = document.createElement("label");
+        location.className = "plain call-item-stack-fn-location";
+        location.setAttribute("value", getFileName(call.file) + ":" + call.line);
+        location.setAttribute("crop", "start");
+        location.setAttribute("flex", "1");
+        location.addEventListener("mousedown", e => this._onStackFileClick(e, call));
+        contents.appendChild(location);
+
+        list.appendChild(contents);
+      }
+
+      window.emit(EVENTS.CALL_STACK_DISPLAYED);
+    };
+
+    // If this animation snapshot is loaded from disk, there are no corresponding
+    // backend actors available and the data is immediately available.
+    let functionCall = callItem.attachment.actor;
+    if (functionCall.isLoadedFromDisk) {
+      display(functionCall.stack);
+    }
+    // ..otherwise we need to request the function call stack from the backend.
+    else {
+      callItem.attachment.actor.getDetails().then(fn => display(fn.stack));
+    }
+  },
+
+  /**
+   * The click listener for a location link in the call stack.
+   *
+   * @param string file
+   *        The url of the source owning the function.
+   * @param number line
+   *        The line of the respective function.
+   */
+  _onStackFileClick: function(e, { file, line }) {
+    viewSourceInDebugger(file, line);
+  },
+
+  /**
+   * The click listener for a thumbnail in the filmstrip.
+   *
+   * @param number index
+   *        The function index in the recorded animation frame snapshot.
+   */
+  _onThumbnailClick: function(e, index) {
+    this.selectedIndex = index;
+  },
+
+  /**
+   * The click listener for the "resume" button in this container's toolbar.
+   */
+  _onResume: function() {
+    // Jump to the next draw call in the recorded animation frame snapshot.
+    let drawCall = getNextDrawCall(this.items, this.selectedItem);
+    if (drawCall) {
+      this.selectedItem = drawCall;
+      return;
+    }
+
+    // If there are no more draw calls, just jump to the last context call.
+    this._onStepOut();
+  },
+
+  /**
+   * The click listener for the "step over" button in this container's toolbar.
+   */
+  _onStepOver: function() {
+    this.selectedIndex++;
+  },
+
+  /**
+   * The click listener for the "step in" button in this container's toolbar.
+   */
+  _onStepIn: function() {
+    if (this.selectedIndex == -1) {
+      this._onResume();
+      return;
+    }
+    let callItem = this.selectedItem;
+    let { file, line } = callItem.attachment.actor;
+    viewSourceInDebugger(file, line);
+  },
+
+  /**
+   * The click listener for the "step out" button in this container's toolbar.
+   */
+  _onStepOut: function() {
+    this.selectedIndex = this.itemCount - 1;
+  }
+});
+
+/**
+ * Localization convenience methods.
+ */
+let L10N = new ViewHelpers.L10N(STRINGS_URI);
+
+/**
+ * Convenient way of emitting events from the panel window.
+ */
+EventEmitter.decorate(this);
+
+/**
+ * DOM query helpers.
+ */
+function $(selector, target = document) target.querySelector(selector);
+function $all(selector, target = document) target.querySelectorAll(selector);
+
+/**
+ * Helper for getting an nsIURL instance out of a string.
+ */
+function nsIURL(url, store = nsIURL.store) {
+  if (store.has(url)) {
+    return store.get(url);
+  }
+  let uri = Services.io.newURI(url, null, null).QueryInterface(Ci.nsIURL);
+  store.set(url, uri);
+  return uri;
+}
+
+// The cache used in the `nsIURL` function.
+nsIURL.store = new Map();
+
+/**
+ * Gets the fileName part of a string which happens to be an URL.
+ */
+function getFileName(url) {
+  try {
+    let { fileName } = nsIURL(url);
+    return fileName || "/";
+  } catch (e) {
+    // This doesn't look like a url, or nsIURL can't handle it.
+    return "";
+  }
+}
+
+/**
+ * Gets an image data object containing a buffer large enough to hold
+ * width * height pixels.
+ *
+ * This method avoids allocating memory and tries to reuse a common buffer
+ * as much as possible.
+ *
+ * @param number w
+ *        The desired image data storage width.
+ * @param number h
+ *        The desired image data storage height.
+ * @return ImageData
+ *         The requested image data buffer.
+ */
+function getImageDataStorage(ctx, w, h) {
+  let storage = getImageDataStorage.cache;
+  if (storage && storage.width == w && storage.height == h) {
+    return storage;
+  }
+  return getImageDataStorage.cache = ctx.createImageData(w, h);
+}
+
+// The cache used in the `getImageDataStorage` function.
+getImageDataStorage.cache = null;
+
+/**
+ * Draws image data into a canvas.
+ *
+ * This method makes absolutely no assumptions about the canvas element
+ * dimensions, or pre-existing rendering. It's a dumb proxy that copies pixels.
+ *
+ * @param HTMLCanvasElement canvas
+ *        The canvas element to put the image data into.
+ * @param number width
+ *        The image data width.
+ * @param number height
+ *        The image data height.
+ * @param pixels
+ *        An array buffer view of the image data.
+ * @param object options
+ *        Additional options supported by this operation:
+ *          - centered: specifies whether the image data should be centered
+ *                      when copied in the canvas; this is useful when the
+ *                      supplied pixels don't completely cover the canvas.
+ */
+function drawImage(canvas, width, height, pixels, options = {}) {
+  let ctx = canvas.getContext("2d");
+
+  // FrameSnapshot actors return "snapshot-image" type instances with just an
+  // empty pixel array if the source image is completely transparent.
+  if (pixels.length <= 1) {
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
+    return;
+  }
+
+  let arrayBuffer = new Uint8Array(pixels.buffer);
+  let imageData = getImageDataStorage(ctx, width, height);
+  imageData.data.set(arrayBuffer);
+
+  if (options.centered) {
+    let left = (canvas.width - width) / 2;
+    let top = (canvas.height - height) / 2;
+    ctx.putImageData(imageData, left, top);
+  } else {
+    ctx.putImageData(imageData, 0, 0);
+  }
+}
+
+/**
+ * Draws image data into a canvas, and sets that as the rendering source for
+ * an element with the specified id as the -moz-element background image.
+ *
+ * @param string id
+ *        The id of the -moz-element background image.
+ * @param number width
+ *        The image data width.
+ * @param number height
+ *        The image data height.
+ * @param pixels
+ *        An array buffer view of the image data.
+ */
+function drawBackground(id, width, height, pixels) {
+  let canvas = document.createElementNS(HTML_NS, "canvas");
+  canvas.width = width;
+  canvas.height = height;
+
+  drawImage(canvas, width, height, pixels);
+  document.mozSetImageElement(id, canvas);
+
+  // Used in tests. Not emitting an event because this shouldn't be "interesting".
+  if (window._onMozSetImageElement) {
+    window._onMozSetImageElement(pixels);
+  }
+}
+
+/**
+ * Iterates forward to find the next draw call in a snapshot.
+ */
+function getNextDrawCall(calls, call) {
+  for (let i = calls.indexOf(call) + 1, len = calls.length; i < len; i++) {
+    let nextCall = calls[i];
+    let name = nextCall.attachment.actor.name;
+    if (CanvasFront.DRAW_CALLS.has(name)) {
+      return nextCall;
+    }
+  }
+  return null;
+}
+
+/**
+ * Iterates backwards to find the most recent screenshot for a function call
+ * in a snapshot loaded from disk.
+ */
+function getScreenshotFromCallLoadedFromDisk(calls, call) {
+  for (let i = calls.indexOf(call); i >= 0; i--) {
+    let prevCall = calls[i];
+    let screenshot = prevCall.screenshot;
+    if (screenshot) {
+      return screenshot;
+    }
+  }
+  return CanvasFront.INVALID_SNAPSHOT_IMAGE;
+}
+
+/**
+ * Iterates backwards to find the most recent thumbnail for a function call.
+ */
+function getThumbnailForCall(thumbnails, index) {
+  for (let i = thumbnails.length - 1; i >= 0; i--) {
+    let thumbnail = thumbnails[i];
+    if (thumbnail.index <= index) {
+      return thumbnail;
+    }
+  }
+  return CanvasFront.INVALID_SNAPSHOT_IMAGE;
+}
+
+/**
+ * Opens/selects the debugger in this toolbox and jumps to the specified
+ * file name and line number.
+ */
+function viewSourceInDebugger(url, line) {
+  let showSource = ({ DebuggerView }) => {
+    if (DebuggerView.Sources.containsValue(url)) {
+      DebuggerView.setEditorLocation(url, line, { noDebug: true }).then(() => {
+        window.emit(EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER);
+      }, () => {
+        window.emit(EVENTS.SOURCE_NOT_FOUND_IN_JS_DEBUGGER);
+      });
+    }
+  }
+
+  // If the Debugger was already open, switch to it and try to show the
+  // source immediately. Otherwise, initialize it and wait for the sources
+  // to be added first.
+  let debuggerAlreadyOpen = gToolbox.getPanel("jsdebugger");
+  gToolbox.selectTool("jsdebugger").then(({ panelWin: dbg }) => {
+    if (debuggerAlreadyOpen) {
+      showSource(dbg);
+    } else {
+      dbg.once(dbg.EVENTS.SOURCES_ADDED, () => showSource(dbg));
+    }
+  });
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/canvasdebugger.xul
@@ -0,0 +1,131 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+<?xml-stylesheet href="chrome://browser/skin/" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/content/devtools/widgets.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/devtools/common.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/devtools/widgets.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/devtools/canvasdebugger.css" type="text/css"?>
+<!DOCTYPE window [
+  <!ENTITY % canvasDebuggerDTD SYSTEM "chrome://browser/locale/devtools/canvasdebugger.dtd">
+  %canvasDebuggerDTD;
+]>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+  <script src="chrome://browser/content/devtools/theme-switching.js"/>
+  <script type="application/javascript" src="canvasdebugger.js"/>
+
+  <hbox class="theme-body" flex="1">
+    <vbox id="snapshots-pane">
+      <toolbar id="snapshots-toolbar"
+               class="devtools-toolbar">
+        <hbox id="snapshots-controls"
+              class="devtools-toolbarbutton-group">
+          <toolbarbutton id="record-snapshot"
+                         class="devtools-toolbarbutton"
+                         oncommand="SnapshotsListView._onRecordButtonClick()"
+                         tooltiptext="&canvasDebuggerUI.recordSnapshot.tooltip;"
+                         hidden="true"/>
+          <toolbarbutton id="import-snapshot"
+                         class="devtools-toolbarbutton"
+                         oncommand="SnapshotsListView._onImportButtonClick()"
+                         label="&canvasDebuggerUI.importSnapshot;"/>
+          <toolbarbutton id="clear-snapshots"
+                         class="devtools-toolbarbutton"
+                         oncommand="SnapshotsListView._onClearButtonClick()"
+                         label="&canvasDebuggerUI.clearSnapshots;"/>
+        </hbox>
+      </toolbar>
+      <vbox id="snapshots-list" flex="1"/>
+    </vbox>
+
+    <vbox id="debugging-pane" flex="1">
+      <hbox id="reload-notice"
+            class="notice-container"
+            align="center"
+            pack="center"
+            flex="1">
+        <button id="reload-notice-button"
+                class="devtools-toolbarbutton"
+                label="&canvasDebuggerUI.reloadNotice1;"
+                oncommand="gFront.setup({ reload: true })"/>
+        <label id="reload-notice-label"
+               class="plain"
+               value="&canvasDebuggerUI.reloadNotice2;"/>
+      </hbox>
+
+      <hbox id="empty-notice"
+            class="notice-container"
+            align="center"
+            pack="center"
+            flex="1"
+            hidden="true">
+        <label value="&canvasDebuggerUI.emptyNotice1;"/>
+        <button id="canvas-debugging-empty-notice-button"
+                class="devtools-toolbarbutton"
+                oncommand="SnapshotsListView._onRecordButtonClick()"/>
+        <label value="&canvasDebuggerUI.emptyNotice2;"/>
+      </hbox>
+
+      <hbox id="import-notice"
+            class="notice-container"
+            align="center"
+            pack="center"
+            flex="1"
+            hidden="true">
+        <label value="&canvasDebuggerUI.importNotice;"/>
+      </hbox>
+
+      <box id="debugging-pane-contents"
+           class="devtools-responsive-container"
+           flex="1"
+           hidden="true">
+        <vbox id="calls-list-container" flex="1">
+          <toolbar id="debugging-toolbar"
+                   class="devtools-toolbar">
+            <hbox id="debugging-controls"
+                  class="devtools-toolbarbutton-group">
+              <toolbarbutton id="resume"
+                             class="devtools-toolbarbutton"
+                             oncommand="CallsListView._onResume()"/>
+              <toolbarbutton id="step-over"
+                             class="devtools-toolbarbutton"
+                             oncommand="CallsListView._onStepOver()"/>
+              <toolbarbutton id="step-in"
+                             class="devtools-toolbarbutton"
+                             oncommand="CallsListView._onStepIn()"/>
+              <toolbarbutton id="step-out"
+                             class="devtools-toolbarbutton"
+                             oncommand="CallsListView._onStepOut()"/>
+            </hbox>
+            <toolbarbutton id="debugging-toolbar-sizer-button"
+                           class="devtools-toolbarbutton"
+                           label=""/>
+            <scale id="calls-slider"
+                   movetoclick="true"
+                   flex="100"/>
+            <textbox id="calls-searchbox"
+                     class="devtools-searchinput"
+                     placeholder="&canvasDebuggerUI.searchboxPlaceholder;"
+                     type="search"
+                     flex="1"/>
+          </toolbar>
+          <vbox id="calls-list" flex="1"/>
+        </vbox>
+
+        <splitter class="devtools-side-splitter"/>
+
+        <vbox id="screenshot-container"
+              hidden="true">
+          <vbox id="screenshot-image" flex="1"/>
+          <label id="screenshot-dimensions" class="plain"/>
+        </vbox>
+      </box>
+
+      <hbox id="snapshot-filmstrip"
+            hidden="true"/>
+    </vbox>
+
+  </hbox>
+</window>
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/moz.build
@@ -0,0 +1,12 @@
+# 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/.
+
+TEST_DIRS += ['test']
+
+JS_MODULES_PATH = 'modules/devtools/canvasdebugger'
+
+EXTRA_JS_MODULES += [
+    'panel.js'
+]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/panel.js
@@ -0,0 +1,72 @@
+/* -*- Mode: javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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/. */
+"use strict";
+
+const { Cc, Ci, Cu, Cr } = require("chrome");
+const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
+const EventEmitter = require("devtools/toolkit/event-emitter");
+const { CanvasFront } = require("devtools/server/actors/canvas");
+const { DevToolsUtils } = Cu.import("resource://gre/modules/devtools/DevToolsUtils.jsm", {});
+
+function CanvasDebuggerPanel(iframeWindow, toolbox) {
+  this.panelWin = iframeWindow;
+  this._toolbox = toolbox;
+  this._destroyer = null;
+
+  EventEmitter.decorate(this);
+};
+
+exports.CanvasDebuggerPanel = CanvasDebuggerPanel;
+
+CanvasDebuggerPanel.prototype = {
+  /**
+   * Open is effectively an asynchronous constructor.
+   *
+   * @return object
+   *         A promise that is resolved when the Canvas Debugger completes opening.
+   */
+  open: function() {
+    let targetPromise;
+
+    // Local debugging needs to make the target remote.
+    if (!this.target.isRemote) {
+      targetPromise = this.target.makeRemote();
+    } else {
+      targetPromise = promise.resolve(this.target);
+    }
+
+    return targetPromise
+      .then(() => {
+        this.panelWin.gToolbox = this._toolbox;
+        this.panelWin.gTarget = this.target;
+        this.panelWin.gFront = new CanvasFront(this.target.client, this.target.form);
+        return this.panelWin.startupCanvasDebugger();
+      })
+      .then(() => {
+        this.isReady = true;
+        this.emit("ready");
+        return this;
+      })
+      .then(null, function onError(aReason) {
+        DevToolsUtils.reportException("CanvasDebuggerPanel.prototype.open", aReason);
+      });
+  },
+
+  // DevToolPanel API
+
+  get target() this._toolbox.target,
+
+  destroy: function() {
+    // Make sure this panel is not already destroyed.
+    if (this._destroyer) {
+      return this._destroyer;
+    }
+
+    return this._destroyer = this.panelWin.shutdownCanvasDebugger().then(() => {
+      this.emit("destroyed");
+    });
+  }
+};
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser.ini
@@ -0,0 +1,34 @@
+[DEFAULT]
+support-files =
+  doc_simple-canvas.html
+  doc_simple-canvas-deep-stack.html
+  doc_simple-canvas-transparent.html
+  head.js
+
+[browser_canvas-actor-test-01.js]
+[browser_canvas-actor-test-02.js]
+[browser_canvas-actor-test-03.js]
+[browser_canvas-actor-test-04.js]
+[browser_canvas-actor-test-05.js]
+[browser_canvas-actor-test-06.js]
+[browser_canvas-actor-test-07.js]
+[browser_canvas-frontend-call-highlight.js]
+[browser_canvas-frontend-call-list.js]
+[browser_canvas-frontend-call-search.js]
+[browser_canvas-frontend-call-stack-01.js]
+[browser_canvas-frontend-call-stack-02.js]
+[browser_canvas-frontend-call-stack-03.js]
+[browser_canvas-frontend-clear.js]
+[browser_canvas-frontend-img-screenshots.js]
+[browser_canvas-frontend-img-thumbnails-01.js]
+[browser_canvas-frontend-img-thumbnails-02.js]
+[browser_canvas-frontend-open.js]
+[browser_canvas-frontend-record-01.js]
+[browser_canvas-frontend-record-02.js]
+[browser_canvas-frontend-record-03.js]
+[browser_canvas-frontend-reload-01.js]
+[browser_canvas-frontend-reload-02.js]
+[browser_canvas-frontend-slider-01.js]
+[browser_canvas-frontend-slider-02.js]
+[browser_canvas-frontend-snapshot-select.js]
+[browser_canvas-frontend-stepping.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-actor-test-01.js
@@ -0,0 +1,18 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if the canvas debugger leaks on initialization and sudden destruction.
+ * You can also use this initialization format as a template for other tests.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, front] = yield initCallWatcherBackend(SIMPLE_CANVAS_URL);
+
+  ok(target, "Should have a target available.");
+  ok(debuggee, "Should have a debuggee available.");
+  ok(front, "Should have a protocol front available.");
+
+  yield removeTab(target.tab);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-actor-test-02.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if functions calls are recorded and stored for a canvas context,
+ * and that their stack is successfully retrieved.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, front] = yield initCallWatcherBackend(SIMPLE_CANVAS_URL);
+
+  let navigated = once(target, "navigate");
+
+  yield front.setup({
+    tracedGlobals: ["CanvasRenderingContext2D", "WebGLRenderingContext"],
+    startRecording: true,
+    performReload: true
+  });
+  ok(true, "The front was setup up successfully.");
+
+  yield navigated;
+  ok(true, "Target automatically navigated when the front was set up.");
+
+  // Allow the content to execute some functions.
+  yield waitForTick();
+
+  let functionCalls = yield front.pauseRecording();
+  ok(functionCalls,
+    "An array of function call actors was sent after reloading.");
+  ok(functionCalls.length > 0,
+    "There's at least one function call actor available.");
+
+  is(functionCalls[0].type, CallWatcherFront.METHOD_FUNCTION,
+    "The called function is correctly identified as a method.");
+  is(functionCalls[0].name, "clearRect",
+    "The called function's name is correct.");
+  is(functionCalls[0].file, SIMPLE_CANVAS_URL,
+    "The called function's file is correct.");
+  is(functionCalls[0].line, 25,
+    "The called function's line is correct.");
+
+  is(functionCalls[0].callerPreview, "ctx",
+    "The called function's caller preview is correct.");
+  is(functionCalls[0].argsPreview, "0, 0, 128, 128",
+    "The called function's args preview is correct.");
+
+  let details = yield functionCalls[1].getDetails();
+  ok(details,
+    "The first called function has some details available.")
+
+  is(details.stack.length, 3,
+    "The called function's stack depth is correct.");
+
+  is(details.stack[0].name, "fillStyle",
+    "The called function's stack is correct (1.1).");
+  is(details.stack[0].file, SIMPLE_CANVAS_URL,
+    "The called function's stack is correct (1.2).");
+  is(details.stack[0].line, 20,
+    "The called function's stack is correct (1.3).");
+
+  is(details.stack[1].name, "drawRect",
+    "The called function's stack is correct (2.1).");
+  is(details.stack[1].file, SIMPLE_CANVAS_URL,
+    "The called function's stack is correct (2.2).");
+  is(details.stack[1].line, 26,
+    "The called function's stack is correct (2.3).");
+
+  is(details.stack[2].name, "drawScene",
+    "The called function's stack is correct (3.1).");
+  is(details.stack[2].file, SIMPLE_CANVAS_URL,
+    "The called function's stack is correct (3.2).");
+  is(details.stack[2].line, 33,
+    "The called function's stack is correct (3.3).");
+
+  yield removeTab(target.tab);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-actor-test-03.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if functions inside a single animation frame are recorded and stored
+ * for a canvas context.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, front] = yield initCanavsDebuggerBackend(SIMPLE_CANVAS_URL);
+
+  let navigated = once(target, "navigate");
+
+  yield front.setup({ reload: true });
+  ok(true, "The front was setup up successfully.");
+
+  yield navigated;
+  ok(true, "Target automatically navigated when the front was set up.");
+
+  let snapshotActor = yield front.recordAnimationFrame();
+  ok(snapshotActor,
+    "A snapshot actor was sent after recording.");
+
+  let animationOverview = yield snapshotActor.getOverview();
+  ok(snapshotActor,
+    "An animation overview could be retrieved after recording.");
+
+  let functionCalls = animationOverview.calls;
+  ok(functionCalls,
+    "An array of function call actors was sent after recording.");
+  is(functionCalls.length, 8,
+    "The number of function call actors is correct.");
+
+  is(functionCalls[0].type, CallWatcherFront.METHOD_FUNCTION,
+    "The first called function is correctly identified as a method.");
+  is(functionCalls[0].name, "clearRect",
+    "The first called function's name is correct.");
+  is(functionCalls[0].file, SIMPLE_CANVAS_URL,
+    "The first called function's file is correct.");
+  is(functionCalls[0].line, 25,
+    "The first called function's line is correct.");
+  is(functionCalls[0].argsPreview, "0, 0, 128, 128",
+    "The first called function's args preview is correct.");
+  is(functionCalls[0].callerPreview, "ctx",
+    "The first called function's caller preview is correct.");
+
+  is(functionCalls[6].type, CallWatcherFront.METHOD_FUNCTION,
+    "The penultimate called function is correctly identified as a method.");
+  is(functionCalls[6].name, "fillRect",
+    "The penultimate called function's name is correct.");
+  is(functionCalls[6].file, SIMPLE_CANVAS_URL,
+    "The penultimate called function's file is correct.");
+  is(functionCalls[6].line, 21,
+    "The penultimate called function's line is correct.");
+  is(functionCalls[6].argsPreview, "10, 10, 55, 50",
+    "The penultimate called function's args preview is correct.");
+  is(functionCalls[6].callerPreview, "ctx",
+    "The penultimate called function's caller preview is correct.");
+
+  is(functionCalls[7].type, CallWatcherFront.METHOD_FUNCTION,
+    "The last called function is correctly identified as a method.");
+  is(functionCalls[7].name, "requestAnimationFrame",
+    "The last called function's name is correct.");
+  is(functionCalls[7].file, SIMPLE_CANVAS_URL,
+    "The last called function's file is correct.");
+  is(functionCalls[7].line, 30,
+    "The last called function's line is correct.");
+  ok(functionCalls[7].argsPreview.contains("Function"),
+    "The last called function's args preview is correct.");
+  is(functionCalls[7].callerPreview, "",
+    "The last called function's caller preview is correct.");
+
+  yield removeTab(target.tab);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-actor-test-04.js
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if draw calls inside a single animation frame generate and retrieve
+ * the correct thumbnails.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, front] = yield initCanavsDebuggerBackend(SIMPLE_CANVAS_URL);
+
+  let navigated = once(target, "navigate");
+
+  yield front.setup({ reload: true });
+  ok(true, "The front was setup up successfully.");
+
+  yield navigated;
+  ok(true, "Target automatically navigated when the front was set up.");
+
+  let snapshotActor = yield front.recordAnimationFrame();
+  ok(snapshotActor,
+    "A snapshot actor was sent after recording.");
+
+  let animationOverview = yield snapshotActor.getOverview();
+  ok(snapshotActor,
+    "An animation overview could be retrieved after recording.");
+
+  let thumbnails = animationOverview.thumbnails;
+  ok(thumbnails,
+    "An array of thumbnails was sent after recording.");
+  is(thumbnails.length, 4,
+    "The number of thumbnails is correct.");
+
+  is(thumbnails[0].index, 0,
+    "The first thumbnail's index is correct.");
+  is(thumbnails[0].width, 50,
+    "The first thumbnail's width is correct.");
+  is(thumbnails[0].height, 50,
+    "The first thumbnail's height is correct.");
+  is(thumbnails[0].flipped, false,
+    "The first thumbnail's flipped flag is correct.");
+  is([].find.call(thumbnails[0].pixels, e => e > 0), undefined,
+    "The first thumbnail's pixels seem to be completely transparent.");
+
+  is(thumbnails[1].index, 2,
+    "The second thumbnail's index is correct.");
+  is(thumbnails[1].width, 50,
+    "The second thumbnail's width is correct.");
+  is(thumbnails[1].height, 50,
+    "The second thumbnail's height is correct.");
+  is(thumbnails[1].flipped, false,
+    "The second thumbnail's flipped flag is correct.");
+  is([].find.call(thumbnails[1].pixels, e => e > 0), 4290822336,
+    "The second thumbnail's pixels seem to not be completely transparent.");
+
+  is(thumbnails[2].index, 4,
+    "The third thumbnail's index is correct.");
+  is(thumbnails[2].width, 50,
+    "The third thumbnail's width is correct.");
+  is(thumbnails[2].height, 50,
+    "The third thumbnail's height is correct.");
+  is(thumbnails[2].flipped, false,
+    "The third thumbnail's flipped flag is correct.");
+  is([].find.call(thumbnails[2].pixels, e => e > 0), 4290822336,
+    "The third thumbnail's pixels seem to not be completely transparent.");
+
+  is(thumbnails[3].index, 6,
+    "The fourth thumbnail's index is correct.");
+  is(thumbnails[3].width, 50,
+    "The fourth thumbnail's width is correct.");
+  is(thumbnails[3].height, 50,
+    "The fourth thumbnail's height is correct.");
+  is(thumbnails[3].flipped, false,
+    "The fourth thumbnail's flipped flag is correct.");
+  is([].find.call(thumbnails[3].pixels, e => e > 0), 4290822336,
+    "The fourth thumbnail's pixels seem to not be completely transparent.");
+
+  yield removeTab(target.tab);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-actor-test-05.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if draw calls inside a single animation frame generate and retrieve
+ * the correct "end result" screenshot.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, front] = yield initCanavsDebuggerBackend(SIMPLE_CANVAS_URL);
+
+  let navigated = once(target, "navigate");
+
+  yield front.setup({ reload: true });
+  ok(true, "The front was setup up successfully.");
+
+  yield navigated;
+  ok(true, "Target automatically navigated when the front was set up.");
+
+  let snapshotActor = yield front.recordAnimationFrame();
+  ok(snapshotActor,
+    "A snapshot actor was sent after recording.");
+
+  let animationOverview = yield snapshotActor.getOverview();
+  ok(snapshotActor,
+    "An animation overview could be retrieved after recording.");
+
+  let screenshot = animationOverview.screenshot;
+  ok(screenshot,
+    "A screenshot was sent after recording.");
+
+  is(screenshot.index, 6,
+    "The screenshot's index is correct.");
+  is(screenshot.width, 128,
+    "The screenshot's width is correct.");
+  is(screenshot.height, 128,
+    "The screenshot's height is correct.");
+  is(screenshot.flipped, false,
+    "The screenshot's flipped flag is correct.");
+  is([].find.call(screenshot.pixels, e => e > 0), 4290822336,
+    "The screenshot's pixels seem to not be completely transparent.");
+
+  yield removeTab(target.tab);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-actor-test-06.js
@@ -0,0 +1,95 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if screenshots for arbitrary draw calls are generated properly.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, front] = yield initCanavsDebuggerBackend(SIMPLE_CANVAS_TRANSPARENT_URL);
+
+  let navigated = once(target, "navigate");
+
+  yield front.setup({ reload: true });
+  ok(true, "The front was setup up successfully.");
+
+  yield navigated;
+  ok(true, "Target automatically navigated when the front was set up.");
+
+  let snapshotActor = yield front.recordAnimationFrame();
+  let animationOverview = yield snapshotActor.getOverview();
+
+  let functionCalls = animationOverview.calls;
+  ok(functionCalls,
+    "An array of function call actors was sent after recording.");
+  is(functionCalls.length, 8,
+    "The number of function call actors is correct.");
+
+  is(functionCalls[0].name, "clearRect",
+    "The first called function's name is correct.");
+  is(functionCalls[2].name, "fillRect",
+    "The second called function's name is correct.");
+  is(functionCalls[4].name, "fillRect",
+    "The third called function's name is correct.");
+  is(functionCalls[6].name, "fillRect",
+    "The fourth called function's name is correct.");
+
+  let firstDrawCallScreenshot = yield snapshotActor.generateScreenshotFor(functionCalls[0]);
+  let secondDrawCallScreenshot = yield snapshotActor.generateScreenshotFor(functionCalls[2]);
+  let thirdDrawCallScreenshot = yield snapshotActor.generateScreenshotFor(functionCalls[4]);
+  let fourthDrawCallScreenshot = yield snapshotActor.generateScreenshotFor(functionCalls[6]);
+
+  ok(firstDrawCallScreenshot,
+    "The first draw call has a screenshot attached.");
+  is(firstDrawCallScreenshot.index, 0,
+    "The first draw call has the correct screenshot index.");
+  is(firstDrawCallScreenshot.width, 128,
+    "The first draw call has the correct screenshot width.");
+  is(firstDrawCallScreenshot.height, 128,
+    "The first draw call has the correct screenshot height.");
+  is([].find.call(firstDrawCallScreenshot.pixels, e => e > 0), undefined,
+    "The first draw call's screenshot's pixels seems to be completely transparent.");
+
+  ok(secondDrawCallScreenshot,
+    "The second draw call has a screenshot attached.");
+  is(secondDrawCallScreenshot.index, 2,
+    "The second draw call has the correct screenshot index.");
+  is(secondDrawCallScreenshot.width, 128,
+    "The second draw call has the correct screenshot width.");
+  is(secondDrawCallScreenshot.height, 128,
+    "The second draw call has the correct screenshot height.");
+  is([].find.call(firstDrawCallScreenshot.pixels, e => e > 0), undefined,
+    "The second draw call's screenshot's pixels seems to be completely transparent.");
+
+  ok(thirdDrawCallScreenshot,
+    "The third draw call has a screenshot attached.");
+  is(thirdDrawCallScreenshot.index, 4,
+    "The third draw call has the correct screenshot index.");
+  is(thirdDrawCallScreenshot.width, 128,
+    "The third draw call has the correct screenshot width.");
+  is(thirdDrawCallScreenshot.height, 128,
+    "The third draw call has the correct screenshot height.");
+  is([].find.call(thirdDrawCallScreenshot.pixels, e => e > 0), 2160001024,
+    "The third draw call's screenshot's pixels seems to not be completely transparent.");
+
+  ok(fourthDrawCallScreenshot,
+    "The fourth draw call has a screenshot attached.");
+  is(fourthDrawCallScreenshot.index, 6,
+    "The fourth draw call has the correct screenshot index.");
+  is(fourthDrawCallScreenshot.width, 128,
+    "The fourth draw call has the correct screenshot width.");
+  is(fourthDrawCallScreenshot.height, 128,
+    "The fourth draw call has the correct screenshot height.");
+  is([].find.call(fourthDrawCallScreenshot.pixels, e => e > 0), 2147483839,
+    "The fourth draw call's screenshot's pixels seems to not be completely transparent.");
+
+  isnot(firstDrawCallScreenshot.pixels, secondDrawCallScreenshot.pixels,
+    "The screenshots taken on consecutive draw calls are different (1).");
+  isnot(secondDrawCallScreenshot.pixels, thirdDrawCallScreenshot.pixels,
+    "The screenshots taken on consecutive draw calls are different (2).");
+  isnot(thirdDrawCallScreenshot.pixels, fourthDrawCallScreenshot.pixels,
+    "The screenshots taken on consecutive draw calls are different (3).");
+
+  yield removeTab(target.tab);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-actor-test-07.js
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if screenshots for non-draw calls can still be retrieved properly,
+ * by deferring the the most recent previous draw-call.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, front] = yield initCanavsDebuggerBackend(SIMPLE_CANVAS_URL);
+
+  let navigated = once(target, "navigate");
+
+  yield front.setup({ reload: true });
+  ok(true, "The front was setup up successfully.");
+
+  yield navigated;
+  ok(true, "Target automatically navigated when the front was set up.");
+
+  let snapshotActor = yield front.recordAnimationFrame();
+  let animationOverview = yield snapshotActor.getOverview();
+
+  let functionCalls = animationOverview.calls;
+  ok(functionCalls,
+    "An array of function call actors was sent after recording.");
+  is(functionCalls.length, 8,
+    "The number of function call actors is correct.");
+
+  let firstNonDrawCall = yield functionCalls[1].getDetails();
+  let secondNonDrawCall = yield functionCalls[3].getDetails();
+  let lastNonDrawCall = yield functionCalls[7].getDetails();
+
+  is(firstNonDrawCall.name, "fillStyle",
+    "The first non-draw function's name is correct.");
+  is(secondNonDrawCall.name, "fillStyle",
+    "The second non-draw function's name is correct.");
+  is(lastNonDrawCall.name, "requestAnimationFrame",
+    "The last non-draw function's name is correct.");
+
+  let firstScreenshot = yield snapshotActor.generateScreenshotFor(functionCalls[1]);
+  let secondScreenshot = yield snapshotActor.generateScreenshotFor(functionCalls[3]);
+  let lastScreenshot = yield snapshotActor.generateScreenshotFor(functionCalls[7]);
+
+  ok(firstScreenshot,
+    "A screenshot was successfully retrieved for the first non-draw function.");
+  ok(secondScreenshot,
+    "A screenshot was successfully retrieved for the second non-draw function.");
+  ok(lastScreenshot,
+    "A screenshot was successfully retrieved for the last non-draw function.");
+
+  let firstActualScreenshot = yield snapshotActor.generateScreenshotFor(functionCalls[0]);
+  ok(sameArray(firstScreenshot.pixels, firstActualScreenshot.pixels),
+    "The screenshot for the first non-draw function is correct.");
+  is(firstScreenshot.width, 128,
+    "The screenshot for the first non-draw function has the correct width.");
+  is(firstScreenshot.height, 128,
+    "The screenshot for the first non-draw function has the correct height.");
+
+  let secondActualScreenshot =  yield snapshotActor.generateScreenshotFor(functionCalls[2]);
+  ok(sameArray(secondScreenshot.pixels, secondActualScreenshot.pixels),
+    "The screenshot for the second non-draw function is correct.");
+  is(secondScreenshot.width, 128,
+    "The screenshot for the second non-draw function has the correct width.");
+  is(secondScreenshot.height, 128,
+    "The screenshot for the second non-draw function has the correct height.");
+
+  let lastActualScreenshot = yield snapshotActor.generateScreenshotFor(functionCalls[6]);
+  ok(sameArray(lastScreenshot.pixels, lastActualScreenshot.pixels),
+    "The screenshot for the last non-draw function is correct.");
+  is(lastScreenshot.width, 128,
+    "The screenshot for the last non-draw function has the correct width.");
+  is(lastScreenshot.height, 128,
+    "The screenshot for the last non-draw function has the correct height.");
+
+  ok(!sameArray(firstScreenshot.pixels, secondScreenshot.pixels),
+    "The screenshots taken on consecutive draw calls are different (1).");
+  ok(!sameArray(secondScreenshot.pixels, lastScreenshot.pixels),
+    "The screenshots taken on consecutive draw calls are different (2).");
+
+  yield removeTab(target.tab);
+  finish();
+}
+
+function sameArray(a, b) {
+  if (a.length != b.length) {
+    return false;
+  }
+  for (let i = 0; i < a.length; i++) {
+    if (a[i] !== b[i]) {
+      return false;
+    }
+  }
+  return true;
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-call-highlight.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if certain function calls are properly highlighted in the UI.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_URL);
+  let { window, $, EVENTS, SnapshotsListView, CallsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  let callListPopulated = once(window, EVENTS.CALL_LIST_POPULATED);
+  SnapshotsListView._onRecordButtonClick();
+  yield promise.all([recordingFinished, callListPopulated]);
+
+  is(CallsListView.itemCount, 8,
+    "All the function calls should now be displayed in the UI.");
+
+  is($(".call-item-view", CallsListView.getItemAtIndex(0).target).hasAttribute("draw-call"), true,
+    "The first item's node should have a draw-call attribute.");
+  is($(".call-item-view", CallsListView.getItemAtIndex(1).target).hasAttribute("draw-call"), false,
+    "The second item's node should not have a draw-call attribute.");
+  is($(".call-item-view", CallsListView.getItemAtIndex(2).target).hasAttribute("draw-call"), true,
+    "The third item's node should have a draw-call attribute.");
+  is($(".call-item-view", CallsListView.getItemAtIndex(3).target).hasAttribute("draw-call"), false,
+    "The fourth item's node should not have a draw-call attribute.");
+  is($(".call-item-view", CallsListView.getItemAtIndex(4).target).hasAttribute("draw-call"), true,
+    "The fifth item's node should have a draw-call attribute.");
+  is($(".call-item-view", CallsListView.getItemAtIndex(5).target).hasAttribute("draw-call"), false,
+    "The sixth item's node should not have a draw-call attribute.");
+  is($(".call-item-view", CallsListView.getItemAtIndex(6).target).hasAttribute("draw-call"), true,
+    "The seventh item's node should have a draw-call attribute.");
+  is($(".call-item-view", CallsListView.getItemAtIndex(7).target).hasAttribute("draw-call"), false,
+    "The eigth item's node should not have a draw-call attribute.");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-call-list.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if all the function calls associated with an animation frame snapshot
+ * are properly displayed in the UI.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_URL);
+  let { window, $, EVENTS, SnapshotsListView, CallsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  let callListPopulated = once(window, EVENTS.CALL_LIST_POPULATED);
+  SnapshotsListView._onRecordButtonClick();
+  yield promise.all([recordingFinished, callListPopulated]);
+
+  is(CallsListView.itemCount, 8,
+    "All the function calls should now be displayed in the UI.");
+
+  testItem(CallsListView.getItemAtIndex(0),
+    "1", "ctx", "clearRect", "(0, 0, 128, 128)", "doc_simple-canvas.html:25");
+
+  testItem(CallsListView.getItemAtIndex(1),
+    "2", "ctx", "fillStyle", " = rgb(192, 192, 192)", "doc_simple-canvas.html:20");
+  testItem(CallsListView.getItemAtIndex(2),
+    "3", "ctx", "fillRect", "(0, 0, 128, 128)", "doc_simple-canvas.html:21");
+
+  testItem(CallsListView.getItemAtIndex(3),
+    "4", "ctx", "fillStyle", " = rgba(0, 0, 192, 0.5)", "doc_simple-canvas.html:20");
+  testItem(CallsListView.getItemAtIndex(4),
+    "5", "ctx", "fillRect", "(30, 30, 55, 50)", "doc_simple-canvas.html:21");
+
+  testItem(CallsListView.getItemAtIndex(5),
+    "6", "ctx", "fillStyle", " = rgba(192, 0, 0, 0.5)", "doc_simple-canvas.html:20");
+  testItem(CallsListView.getItemAtIndex(6),
+    "7", "ctx", "fillRect", "(10, 10, 55, 50)", "doc_simple-canvas.html:21");
+
+  testItem(CallsListView.getItemAtIndex(7),
+    "8", "", "requestAnimationFrame", "(Function)", "doc_simple-canvas.html:30");
+
+  function testItem(item, index, context, name, args, location) {
+    let i = CallsListView.indexOfItem(item);
+    is(i, index - 1,
+      "The item at index " + index + " is correctly displayed in the UI.");
+
+    is($(".call-item-index", item.target).getAttribute("value"), index,
+      "The item's gutter label has the correct text.");
+
+    if (context) {
+      is($(".call-item-context", item.target).getAttribute("value"), context,
+        "The item's context label has the correct text.");
+    } else {
+      is($(".call-item-context", item.target), null,
+        "The item's context label should not be available.");
+    }
+
+    is($(".call-item-name", item.target).getAttribute("value"), name,
+      "The item's name label has the correct text.");
+    is($(".call-item-args", item.target).getAttribute("value"), args,
+      "The item's args label has the correct text.");
+    is($(".call-item-location", item.target).getAttribute("value"), location,
+      "The item's location label has the correct text.");
+  }
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-call-search.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if filtering the items in the call list works properly.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_URL);
+  let { window, $, EVENTS, SnapshotsListView, CallsListView } = panel.panelWin;
+  let searchbox = $("#calls-searchbox");
+
+  yield reload(target);
+
+  let firstRecordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  let callListPopulated = once(window, EVENTS.CALL_LIST_POPULATED);
+  SnapshotsListView._onRecordButtonClick();
+  yield promise.all([firstRecordingFinished, callListPopulated]);
+
+  is(searchbox.value, "",
+    "The searchbox should be initially empty.");
+  is(CallsListView.visibleItems.length, 8,
+    "All the items should be initially visible in the calls list.");
+
+  searchbox.focus();
+  EventUtils.sendString("clear", window);
+
+  is(searchbox.value, "clear",
+    "The searchbox should now contain the 'clear' string.");
+  is(CallsListView.visibleItems.length, 1,
+    "Only one item should now be visible in the calls list.");
+
+  is(CallsListView.visibleItems[0].attachment.actor.type, CallWatcherFront.METHOD_FUNCTION,
+    "The visible item's type has the expected value.");
+  is(CallsListView.visibleItems[0].attachment.actor.name, "clearRect",
+    "The visible item's name has the expected value.");
+  is(CallsListView.visibleItems[0].attachment.actor.file, SIMPLE_CANVAS_URL,
+    "The visible item's file has the expected value.");
+  is(CallsListView.visibleItems[0].attachment.actor.line, 25,
+    "The visible item's line has the expected value.");
+  is(CallsListView.visibleItems[0].attachment.actor.argsPreview, "0, 0, 128, 128",
+    "The visible item's args have the expected value.");
+  is(CallsListView.visibleItems[0].attachment.actor.callerPreview, "ctx",
+    "The visible item's caller has the expected value.");
+
+  let secondRecordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  let callListPopulated = once(window, EVENTS.CALL_LIST_POPULATED);
+
+  SnapshotsListView._onRecordButtonClick();
+  yield secondRecordingFinished;
+
+  SnapshotsListView.selectedIndex = 1;
+  yield callListPopulated;
+
+  is(searchbox.value, "clear",
+    "The searchbox should still contain the 'clear' string.");
+  is(CallsListView.visibleItems.length, 1,
+    "Only one item should still be visible in the calls list.");
+
+  for (let i = 0; i < 5; i++) {
+    searchbox.focus();
+    EventUtils.sendKey("BACK_SPACE", window);
+  }
+
+  is(searchbox.value, "",
+    "The searchbox should now be emptied.");
+  is(CallsListView.visibleItems.length, 8,
+    "All the items should be initially visible again in the calls list.");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-call-stack-01.js
@@ -0,0 +1,74 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if the a function call's stack is properly displayed in the UI.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_DEEP_STACK_URL);
+  let { window, $, $all, EVENTS, SnapshotsListView, CallsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  let callListPopulated = once(window, EVENTS.CALL_LIST_POPULATED);
+  SnapshotsListView._onRecordButtonClick();
+  yield promise.all([recordingFinished, callListPopulated]);
+
+  let callItem = CallsListView.getItemAtIndex(2);
+  let locationLink = $(".call-item-location", callItem.target);
+
+  is($(".call-item-stack", callItem.target), null,
+    "There should be no stack container available yet for the draw call.");
+
+  let callStackDisplayed = once(window, EVENTS.CALL_STACK_DISPLAYED);
+  EventUtils.sendMouseEvent({ type: "mousedown" }, locationLink, window);
+  yield callStackDisplayed;
+
+  isnot($(".call-item-stack", callItem.target), null,
+    "There should be a stack container available now for the draw call.");
+  is($all(".call-item-stack-fn", callItem.target).length, 4,
+    "There should be 4 functions on the stack for the draw call.");
+
+  ok($all(".call-item-stack-fn-name", callItem.target)[0].getAttribute("value")
+    .contains("C()"),
+    "The first function on the stack has the correct name.");
+  ok($all(".call-item-stack-fn-name", callItem.target)[1].getAttribute("value")
+    .contains("B()"),
+    "The second function on the stack has the correct name.");
+  ok($all(".call-item-stack-fn-name", callItem.target)[2].getAttribute("value")
+    .contains("A()"),
+    "The third function on the stack has the correct name.");
+  ok($all(".call-item-stack-fn-name", callItem.target)[3].getAttribute("value")
+    .contains("drawRect()"),
+    "The fourth function on the stack has the correct name.");
+
+  is($all(".call-item-stack-fn-location", callItem.target)[0].getAttribute("value"),
+    "doc_simple-canvas-deep-stack.html:26",
+    "The first function on the stack has the correct location.");
+  is($all(".call-item-stack-fn-location", callItem.target)[1].getAttribute("value"),
+    "doc_simple-canvas-deep-stack.html:28",
+    "The second function on the stack has the correct location.");
+  is($all(".call-item-stack-fn-location", callItem.target)[2].getAttribute("value"),
+    "doc_simple-canvas-deep-stack.html:30",
+    "The third function on the stack has the correct location.");
+  is($all(".call-item-stack-fn-location", callItem.target)[3].getAttribute("value"),
+    "doc_simple-canvas-deep-stack.html:35",
+    "The fourth function on the stack has the correct location.");
+
+  let jumpedToSource = once(window, EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER);
+  EventUtils.sendMouseEvent({ type: "mousedown" }, $(".call-item-stack-fn-location", callItem.target));
+  yield jumpedToSource;
+
+  let toolbox = yield gDevTools.getToolbox(target);
+  let { panelWin: { DebuggerView: view } } = toolbox.getPanel("jsdebugger");
+
+  is(view.Sources.selectedValue, SIMPLE_CANVAS_DEEP_STACK_URL,
+    "The expected source was shown in the debugger.");
+  is(view.editor.getCursor().line, 25,
+    "The expected source line is highlighted in the debugger.");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-call-stack-02.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if the a function call's stack is properly displayed in the UI
+ * and jumping to source in the debugger for the topmost call item works.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_DEEP_STACK_URL);
+  let { window, $, $all, EVENTS, SnapshotsListView, CallsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  let callListPopulated = once(window, EVENTS.CALL_LIST_POPULATED);
+  SnapshotsListView._onRecordButtonClick();
+  yield promise.all([recordingFinished, callListPopulated]);
+
+  let callItem = CallsListView.getItemAtIndex(2);
+  let locationLink = $(".call-item-location", callItem.target);
+
+  is($(".call-item-stack", callItem.target), null,
+    "There should be no stack container available yet for the draw call.");
+
+  let callStackDisplayed = once(window, EVENTS.CALL_STACK_DISPLAYED);
+  EventUtils.sendMouseEvent({ type: "mousedown" }, locationLink, window);
+  yield callStackDisplayed;
+
+  isnot($(".call-item-stack", callItem.target), null,
+    "There should be a stack container available now for the draw call.");
+  is($all(".call-item-stack-fn", callItem.target).length, 4,
+    "There should be 4 functions on the stack for the draw call.");
+
+  let jumpedToSource = once(window, EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER);
+  EventUtils.sendMouseEvent({ type: "mousedown" }, $(".call-item-location", callItem.target));
+  yield jumpedToSource;
+
+  let toolbox = yield gDevTools.getToolbox(target);
+  let { panelWin: { DebuggerView: view } } = toolbox.getPanel("jsdebugger");
+
+  is(view.Sources.selectedValue, SIMPLE_CANVAS_DEEP_STACK_URL,
+    "The expected source was shown in the debugger.");
+  is(view.editor.getCursor().line, 23,
+    "The expected source line is highlighted in the debugger.");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-call-stack-03.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if the a function call's stack can be shown/hidden by double-clicking
+ * on a function call item.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_DEEP_STACK_URL);
+  let { window, $, $all, EVENTS, SnapshotsListView, CallsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  let callListPopulated = once(window, EVENTS.CALL_LIST_POPULATED);
+  SnapshotsListView._onRecordButtonClick();
+  yield promise.all([recordingFinished, callListPopulated]);
+
+  let callItem = CallsListView.getItemAtIndex(2);
+  let view = $(".call-item-view", callItem.target);
+  let contents = $(".call-item-contents", callItem.target);
+
+  is(view.hasAttribute("call-stack-populated"), false,
+    "The call item's view should not have the stack populated yet.");
+  is(view.hasAttribute("call-stack-expanded"), false,
+    "The call item's view should not have the stack populated yet.");
+  is($(".call-item-stack", callItem.target), null,
+    "There should be no stack container available yet for the draw call.");
+
+  let callStackDisplayed = once(window, EVENTS.CALL_STACK_DISPLAYED);
+  EventUtils.sendMouseEvent({ type: "dblclick" }, contents, window);
+  yield callStackDisplayed;
+
+  is(view.hasAttribute("call-stack-populated"), true,
+    "The call item's view should have the stack populated now.");
+  is(view.getAttribute("call-stack-expanded"), "true",
+    "The call item's view should have the stack expanded now.");
+  isnot($(".call-item-stack", callItem.target), null,
+    "There should be a stack container available now for the draw call.");
+  is($(".call-item-stack", callItem.target).hidden, false,
+    "The stack container should now be visible.");
+  is($all(".call-item-stack-fn", callItem.target).length, 4,
+    "There should be 4 functions on the stack for the draw call.");
+
+  EventUtils.sendMouseEvent({ type: "dblclick" }, contents, window);
+
+  is(view.hasAttribute("call-stack-populated"), true,
+    "The call item's view should still have the stack populated.");
+  is(view.getAttribute("call-stack-expanded"), "false",
+    "The call item's view should not have the stack expanded anymore.");
+  isnot($(".call-item-stack", callItem.target), null,
+    "There should still be a stack container available for the draw call.");
+  is($(".call-item-stack", callItem.target).hidden, true,
+    "The stack container should now be hidden.");
+  is($all(".call-item-stack-fn", callItem.target).length, 4,
+    "There should still be 4 functions on the stack for the draw call.");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-clear.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if clearing the snapshots list works as expected.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_URL);
+  let { window, EVENTS, SnapshotsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  let firstRecordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  SnapshotsListView._onRecordButtonClick();
+
+  yield firstRecordingFinished;
+  ok(true, "Finished recording a snapshot of the animation loop.");
+
+  is(SnapshotsListView.itemCount, 1,
+    "There should be one item available in the snapshots list.");
+
+  let secondRecordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  SnapshotsListView._onRecordButtonClick();
+
+  yield secondRecordingFinished;
+  ok(true, "Finished recording another snapshot of the animation loop.");
+
+  is(SnapshotsListView.itemCount, 2,
+    "There should be two items available in the snapshots list.");
+
+  let clearingFinished = once(window, EVENTS.SNAPSHOTS_LIST_CLEARED);
+  SnapshotsListView._onClearButtonClick();
+
+  yield clearingFinished;
+  ok(true, "Finished recording all snapshots.");
+
+  is(SnapshotsListView.itemCount, 0,
+    "There should be no items available in the snapshots list.");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-img-screenshots.js
@@ -0,0 +1,34 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if screenshots are properly displayed in the UI.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_URL);
+  let { window, $, EVENTS, SnapshotsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  let callListPopulated = once(window, EVENTS.CALL_LIST_POPULATED);
+  let screenshotDisplayed = once(window, EVENTS.CALL_SCREENSHOT_DISPLAYED);
+  SnapshotsListView._onRecordButtonClick();
+  yield promise.all([recordingFinished, callListPopulated, screenshotDisplayed]);
+
+  is($("#screenshot-container").hidden, false,
+    "The screenshot container should now be visible.");
+
+  is($("#screenshot-dimensions").getAttribute("value"), "128 x 128",
+    "The screenshot dimensions label has the expected value.");
+
+  is($("#screenshot-image").getAttribute("flipped"), "false",
+    "The screenshot element should not be flipped vertically.");
+
+  ok(window.getComputedStyle($("#screenshot-image")).backgroundImage.contains("#screenshot-rendering"),
+    "The screenshot element should have an offscreen canvas element as a background.");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-img-thumbnails-01.js
@@ -0,0 +1,65 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if thumbnails are properly displayed in the UI.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_URL);
+  let { window, $, $all, EVENTS, SnapshotsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  let callListPopulated = once(window, EVENTS.CALL_LIST_POPULATED);
+  let thumbnailsDisplayed = once(window, EVENTS.THUMBNAILS_DISPLAYED);
+  SnapshotsListView._onRecordButtonClick();
+  yield promise.all([recordingFinished, callListPopulated, thumbnailsDisplayed]);
+
+  is($all(".filmstrip-thumbnail").length, 4,
+    "There should be 4 thumbnails displayed in the UI.");
+
+  let firstThumbnail = $(".filmstrip-thumbnail[index='0']");
+  ok(firstThumbnail,
+    "The first thumbnail element should be for the function call at index 0.");
+  is(firstThumbnail.width, 50,
+    "The first thumbnail's width is correct.");
+  is(firstThumbnail.height, 50,
+    "The first thumbnail's height is correct.");
+  is(firstThumbnail.getAttribute("flipped"), "false",
+    "The first thumbnail should not be flipped vertically.");
+
+  let secondThumbnail = $(".filmstrip-thumbnail[index='2']");
+  ok(secondThumbnail,
+    "The second thumbnail element should be for the function call at index 2.");
+  is(secondThumbnail.width, 50,
+    "The second thumbnail's width is correct.");
+  is(secondThumbnail.height, 50,
+    "The second thumbnail's height is correct.");
+  is(secondThumbnail.getAttribute("flipped"), "false",
+    "The second thumbnail should not be flipped vertically.");
+
+  let thirdThumbnail = $(".filmstrip-thumbnail[index='4']");
+  ok(thirdThumbnail,
+    "The third thumbnail element should be for the function call at index 4.");
+  is(thirdThumbnail.width, 50,
+    "The third thumbnail's width is correct.");
+  is(thirdThumbnail.height, 50,
+    "The third thumbnail's height is correct.");
+  is(thirdThumbnail.getAttribute("flipped"), "false",
+    "The third thumbnail should not be flipped vertically.");
+
+  let fourthThumbnail = $(".filmstrip-thumbnail[index='6']");
+  ok(fourthThumbnail,
+    "The fourth thumbnail element should be for the function call at index 6.");
+  is(fourthThumbnail.width, 50,
+    "The fourth thumbnail's width is correct.");
+  is(fourthThumbnail.height, 50,
+    "The fourth thumbnail's height is correct.");
+  is(fourthThumbnail.getAttribute("flipped"), "false",
+    "The fourth thumbnail should not be flipped vertically.");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-img-thumbnails-02.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if thumbnails are correctly linked with other UI elements like
+ * function call items and their respective screenshots.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_URL);
+  let { window, $, $all, EVENTS, SnapshotsListView, CallsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  let callListPopulated = once(window, EVENTS.CALL_LIST_POPULATED);
+  let thumbnailsDisplayed = once(window, EVENTS.THUMBNAILS_DISPLAYED);
+  let screenshotDisplayed = once(window, EVENTS.CALL_SCREENSHOT_DISPLAYED);
+  SnapshotsListView._onRecordButtonClick();
+  yield promise.all([
+    recordingFinished,
+    callListPopulated,
+    thumbnailsDisplayed,
+    screenshotDisplayed
+  ]);
+
+  is($all(".filmstrip-thumbnail[highlighted]").length, 0,
+    "There should be no highlighted thumbnail available yet.");
+  is(CallsListView.selectedIndex, -1,
+    "There should be no selected item in the calls list view.");
+
+  EventUtils.sendMouseEvent({ type: "mousedown" }, $all(".filmstrip-thumbnail")[0], window);
+  yield once(window, EVENTS.CALL_SCREENSHOT_DISPLAYED);
+  info("The first draw call was selected, by clicking the first thumbnail.");
+
+  isnot($(".filmstrip-thumbnail[highlighted][index='0']"), null,
+    "There should be a highlighted thumbnail available now, for the first draw call.");
+  is($all(".filmstrip-thumbnail[highlighted]").length, 1,
+    "There should be only one highlighted thumbnail available now.");
+  is(CallsListView.selectedIndex, 0,
+    "The first draw call should be selected in the calls list view.");
+
+  EventUtils.sendMouseEvent({ type: "mousedown" }, $all(".call-item-view")[1], window);
+  yield once(window, EVENTS.CALL_SCREENSHOT_DISPLAYED);
+  info("The second context call was selected, by clicking the second call item.");
+
+  isnot($(".filmstrip-thumbnail[highlighted][index='0']"), null,
+    "There should be a highlighted thumbnail available, for the first draw call.");
+  is($all(".filmstrip-thumbnail[highlighted]").length, 1,
+    "There should be only one highlighted thumbnail available.");
+  is(CallsListView.selectedIndex, 1,
+    "The second draw call should be selected in the calls list view.");
+
+  EventUtils.sendMouseEvent({ type: "mousedown" }, $all(".call-item-view")[2], window);
+  yield once(window, EVENTS.CALL_SCREENSHOT_DISPLAYED);
+  info("The second draw call was selected, by clicking the third call item.");
+
+  isnot($(".filmstrip-thumbnail[highlighted][index='2']"), null,
+    "There should be a highlighted thumbnail available, for the second draw call.");
+  is($all(".filmstrip-thumbnail[highlighted]").length, 1,
+    "There should be only one highlighted thumbnail available.");
+  is(CallsListView.selectedIndex, 2,
+    "The second draw call should be selected in the calls list view.");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-open.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests that the frontend UI is properly configured when opening the tool.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_URL);
+  let { $ } = panel.panelWin;
+
+  is($("#snapshots-pane").hasAttribute("hidden"), false,
+    "The snapshots pane should initially be visible.");
+  is($("#debugging-pane").hasAttribute("hidden"), false,
+    "The debugging pane should initially be visible.");
+
+  is($("#record-snapshot").getAttribute("hidden"), "true",
+    "The 'record snapshot' button should initially be hidden.");
+  is($("#import-snapshot").hasAttribute("hidden"), false,
+    "The 'import snapshot' button should initially be visible.");
+  is($("#clear-snapshots").hasAttribute("hidden"), false,
+    "The 'clear snapshots' button should initially be visible.");
+
+  is($("#reload-notice").hasAttribute("hidden"), false,
+    "The reload notice should initially be visible.");
+  is($("#empty-notice").getAttribute("hidden"), "true",
+    "The empty notice should initially be hidden.");
+  is($("#import-notice").getAttribute("hidden"), "true",
+    "The import notice should initially be hidden.");
+
+  is($("#screenshot-container").getAttribute("hidden"), "true",
+    "The screenshot container should initially be hidden.");
+  is($("#snapshot-filmstrip").getAttribute("hidden"), "true",
+    "The snapshot filmstrip should initially be hidden.");
+
+  is($("#debugging-pane-contents").getAttribute("hidden"), "true",
+    "The rest of the UI should initially be hidden.");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-record-01.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests whether the frontend behaves correctly while reording a snapshot.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_URL);
+  let { window, EVENTS, $, SnapshotsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  is($("#record-snapshot").hasAttribute("checked"), false,
+    "The 'record snapshot' button should initially be unchecked.");
+  is($("#record-snapshot").hasAttribute("disabled"), false,
+    "The 'record snapshot' button should initially be enabled.");
+  is($("#record-snapshot").hasAttribute("hidden"), false,
+    "The 'record snapshot' button should now be visible.");
+
+  is(SnapshotsListView.itemCount, 0,
+    "There should be no items available in the snapshots list view.");
+  is(SnapshotsListView.selectedIndex, -1,
+    "There should be no selected item in the snapshots list view.");
+
+  let recordingStarted = once(window, EVENTS.SNAPSHOT_RECORDING_STARTED);
+  let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  SnapshotsListView._onRecordButtonClick();
+
+  yield recordingStarted;
+  ok(true, "Started recording a snapshot of the animation loop.");
+
+  is($("#record-snapshot").getAttribute("checked"), "true",
+    "The 'record snapshot' button should now be checked.");
+  is($("#record-snapshot").getAttribute("disabled"), "true",
+    "The 'record snapshot' button should now be disabled.");
+  is($("#record-snapshot").hasAttribute("hidden"), false,
+    "The 'record snapshot' button should still be visible.");
+
+  is(SnapshotsListView.itemCount, 1,
+    "There should be one item available in the snapshots list view now.");
+  is(SnapshotsListView.selectedIndex, -1,
+    "There should be no selected item in the snapshots list view yet.");
+
+  yield recordingFinished;
+  ok(true, "Finished recording a snapshot of the animation loop.");
+
+  is($("#record-snapshot").hasAttribute("checked"), false,
+    "The 'record snapshot' button should now be unchecked.");
+  is($("#record-snapshot").hasAttribute("disabled"), false,
+    "The 'record snapshot' button should now be re-enabled.");
+  is($("#record-snapshot").hasAttribute("hidden"), false,
+    "The 'record snapshot' button should still be visible.");
+
+  is(SnapshotsListView.itemCount, 1,
+    "There should still be only one item available in the snapshots list view.");
+  is(SnapshotsListView.selectedIndex, 0,
+    "There should be one selected item in the snapshots list view now.");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-record-02.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests whether the frontend displays a placeholder snapshot while recording.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_URL);
+  let { window, EVENTS, L10N, $, SnapshotsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  let recordingStarted = once(window, EVENTS.SNAPSHOT_RECORDING_STARTED);
+  let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  let recordingSelected = once(window, EVENTS.SNAPSHOT_RECORDING_SELECTED);
+  SnapshotsListView._onRecordButtonClick();
+
+  yield recordingStarted;
+  ok(true, "Started recording a snapshot of the animation loop.");
+
+  let item = SnapshotsListView.getItemAtIndex(0);
+
+  is($(".snapshot-item-title", item.target).getAttribute("value"),
+    L10N.getFormatStr("snapshotsList.itemLabel", 1),
+    "The placeholder item's title label is correct.");
+
+  is($(".snapshot-item-calls", item.target).getAttribute("value"),
+    L10N.getStr("snapshotsList.loadingLabel"),
+    "The placeholder item's calls label is correct.");
+
+  is($(".snapshot-item-save", item.target).getAttribute("value"), "",
+    "The placeholder item's save label should not have a value yet.");
+
+  is($("#reload-notice").getAttribute("hidden"), "true",
+    "The reload notice should now be hidden.");
+  is($("#empty-notice").getAttribute("hidden"), "true",
+    "The empty notice should now be hidden.");
+  is($("#import-notice").hasAttribute("hidden"), false,
+    "The import notice should now be visible.");
+
+  is($("#screenshot-container").getAttribute("hidden"), "true",
+    "The screenshot container should still be hidden.");
+  is($("#snapshot-filmstrip").getAttribute("hidden"), "true",
+    "The snapshot filmstrip should still be hidden.");
+
+  is($("#debugging-pane-contents").getAttribute("hidden"), "true",
+    "The rest of the UI should still be hidden.");
+
+  yield recordingFinished;
+  ok(true, "Finished recording a snapshot of the animation loop.");
+
+  yield recordingSelected;
+  ok(true, "Finished selecting a snapshot of the animation loop.");
+
+  is($("#reload-notice").getAttribute("hidden"), "true",
+    "The reload notice should now be hidden.");
+  is($("#empty-notice").getAttribute("hidden"), "true",
+    "The empty notice should now be hidden.");
+  is($("#import-notice").getAttribute("hidden"), "true",
+    "The import notice should now be hidden.");
+
+  is($("#screenshot-container").hasAttribute("hidden"), false,
+    "The screenshot container should now be visible.");
+  is($("#snapshot-filmstrip").hasAttribute("hidden"), false,
+    "The snapshot filmstrip should now be visible.");
+
+  is($("#debugging-pane-contents").hasAttribute("hidden"), false,
+    "The rest of the UI should now be visible.");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-record-03.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests whether the frontend displays the correct info for a snapshot
+ * after finishing recording.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_URL);
+  let { window, EVENTS, $, SnapshotsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  SnapshotsListView._onRecordButtonClick();
+
+  yield recordingFinished;
+  ok(true, "Finished recording a snapshot of the animation loop.");
+
+  let item = SnapshotsListView.getItemAtIndex(0);
+
+  is(SnapshotsListView.selectedItem, item,
+    "The first item should now be selected in the snapshots list view (1).");
+  is(SnapshotsListView.selectedIndex, 0,
+    "The first item should now be selected in the snapshots list view (2).");
+
+  is($(".snapshot-item-calls", item.target).getAttribute("value"), "4 draws, 8 calls",
+    "The placeholder item's calls label is correct.");
+  is($(".snapshot-item-save", item.target).getAttribute("value"), "Save",
+    "The placeholder item's save label is correct.");
+  is($(".snapshot-item-save", item.target).getAttribute("disabled"), "false",
+    "The placeholder item's save label should be clickable.");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-reload-01.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests that the frontend UI is properly reconfigured after reloading.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_URL);
+  let { window, $, EVENTS } = panel.panelWin;
+
+  let reset = once(window, EVENTS.UI_RESET);
+  let navigated = reload(target);
+
+  yield reset;
+  ok(true, "The UI was reset after the refresh button was clicked.");
+
+  yield navigated;
+  ok(true, "The target finished reloading.");
+
+  is($("#snapshots-pane").hasAttribute("hidden"), false,
+    "The snapshots pane should still be visible.");
+  is($("#debugging-pane").hasAttribute("hidden"), false,
+    "The debugging pane should still be visible.");
+
+  is($("#record-snapshot").hasAttribute("checked"), false,
+    "The 'record snapshot' button should not be checked.");
+  is($("#record-snapshot").hasAttribute("disabled"), false,
+    "The 'record snapshot' button should not be disabled.");
+
+  is($("#record-snapshot").hasAttribute("hidden"), false,
+    "The 'record snapshot' button should now be visible.");
+  is($("#import-snapshot").hasAttribute("hidden"), false,
+    "The 'import snapshot' button should still be visible.");
+  is($("#clear-snapshots").hasAttribute("hidden"), false,
+    "The 'clear snapshots' button should still be visible.");
+
+  is($("#reload-notice").getAttribute("hidden"), "true",
+    "The reload notice should now be hidden.");
+  is($("#empty-notice").hasAttribute("hidden"), false,
+    "The empty notice should now be visible.");
+  is($("#import-notice").getAttribute("hidden"), "true",
+    "The import notice should now be hidden.");
+
+  is($("#snapshot-filmstrip").getAttribute("hidden"), "true",
+    "The snapshot filmstrip should still be hidden.");
+  is($("#screenshot-container").getAttribute("hidden"), "true",
+    "The screenshot container should still be hidden.");
+
+  is($("#debugging-pane-contents").getAttribute("hidden"), "true",
+    "The rest of the UI should still be hidden.");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-reload-02.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests that the frontend UI is properly reconfigured after reloading.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_URL);
+  let { window, $, $all, EVENTS, SnapshotsListView, CallsListView } = panel.panelWin;
+
+  is(SnapshotsListView.itemCount, 0,
+    "There should be no snapshots initially displayed in the UI.");
+  is(CallsListView.itemCount, 0,
+    "There should be no function calls initially displayed in the UI.");
+
+  is($("#screenshot-container").hidden, true,
+    "The screenshot should not be initially displayed in the UI.");
+  is($("#snapshot-filmstrip").hidden, true,
+    "There should be no thumbnails initially displayed in the UI (1).");
+  is($all(".filmstrip-thumbnail").length, 0,
+    "There should be no thumbnails initially displayed in the UI (2).");
+
+  yield reload(target);
+
+  let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  let callListPopulated = once(window, EVENTS.CALL_LIST_POPULATED);
+  let thumbnailsDisplayed = once(window, EVENTS.THUMBNAILS_DISPLAYED);
+  let screenshotDisplayed = once(window, EVENTS.CALL_SCREENSHOT_DISPLAYED);
+  SnapshotsListView._onRecordButtonClick();
+  yield promise.all([
+    recordingFinished,
+    callListPopulated,
+    thumbnailsDisplayed,
+    screenshotDisplayed
+  ]);
+
+  is(SnapshotsListView.itemCount, 1,
+    "There should be one snapshot displayed in the UI.");
+  is(CallsListView.itemCount, 8,
+    "All the function calls should now be displayed in the UI.");
+
+  is($("#screenshot-container").hidden, false,
+    "The screenshot should now be displayed in the UI.");
+  is($("#snapshot-filmstrip").hidden, false,
+    "All the thumbnails should now be displayed in the UI (1).");
+  is($all(".filmstrip-thumbnail").length, 4,
+    "All the thumbnails should now be displayed in the UI (2).");
+
+  let reset = once(window, EVENTS.UI_RESET);
+  let navigated = reload(target);
+
+  yield reset;
+  ok(true, "The UI was reset after the refresh button was clicked.");
+
+  is(SnapshotsListView.itemCount, 0,
+    "There should be no snapshots displayed in the UI after navigating.");
+  is(CallsListView.itemCount, 0,
+    "There should be no function calls displayed in the UI after navigating.");
+  is($("#snapshot-filmstrip").hidden, true,
+    "There should be no thumbnails displayed in the UI after navigating.");
+  is($("#screenshot-container").hidden, true,
+    "The screenshot should not be displayed in the UI after navigating.");
+
+  yield navigated;
+  ok(true, "The target finished reloading.");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-slider-01.js
@@ -0,0 +1,39 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if the slider in the calls list view works as advertised.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_URL);
+  let { window, $, EVENTS, SnapshotsListView, CallsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  let callListPopulated = once(window, EVENTS.CALL_LIST_POPULATED);
+  SnapshotsListView._onRecordButtonClick();
+  yield promise.all([recordingFinished, callListPopulated]);
+
+  is(CallsListView.selectedIndex, -1,
+    "No item in the function calls list should be initially selected.");
+
+  is($("#calls-slider").value, 0,
+    "The slider should be moved all the way to the start.");
+  is($("#calls-slider").min, 0,
+    "The slider minimum value should be 0.");
+  is($("#calls-slider").max, 7,
+    "The slider maximum value should be 7.");
+
+  CallsListView.selectedIndex = 1;
+  is($("#calls-slider").value, 1,
+    "The slider should be changed according to the current selection.");
+
+  $("#calls-slider").value = 2;
+  is(CallsListView.selectedIndex, 2,
+    "The calls selection should be changed according to the current slider value.");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-slider-02.js
@@ -0,0 +1,97 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if the slider in the calls list view works as advertised.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_URL);
+  let { window, $, EVENTS, gFront, SnapshotsListView, CallsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  let callListPopulated = once(window, EVENTS.CALL_LIST_POPULATED);
+  let thumbnailsDisplayed = once(window, EVENTS.THUMBNAILS_DISPLAYED);
+  SnapshotsListView._onRecordButtonClick();
+  yield promise.all([recordingFinished, callListPopulated, thumbnailsDisplayed]);
+
+  let firstSnapshot = SnapshotsListView.getItemAtIndex(0);
+  let firstSnapshotOverview = yield firstSnapshot.attachment.actor.getOverview();
+
+  let thumbnails = firstSnapshotOverview.thumbnails;
+  is(thumbnails.length, 4,
+    "There should be 4 thumbnails cached for the snapshot item.");
+
+  let thumbnailImageElementSet = waitForMozSetImageElement(window);
+  $("#calls-slider").value = 1;
+  let thumbnailPixels = yield thumbnailImageElementSet;
+
+  ok(sameArray(thumbnailPixels, thumbnails[0].pixels),
+    "The screenshot element should have a thumbnail as an immediate background.");
+
+  yield once(window, EVENTS.CALL_SCREENSHOT_DISPLAYED);
+  ok(true, "The full-sized screenshot was displayed for the item at index 1.");
+
+  let thumbnailImageElementSet = waitForMozSetImageElement(window);
+  $("#calls-slider").value = 2;
+  let thumbnailPixels = yield thumbnailImageElementSet;
+
+  ok(sameArray(thumbnailPixels, thumbnails[1].pixels),
+    "The screenshot element should have a thumbnail as an immediate background.");
+
+  yield once(window, EVENTS.CALL_SCREENSHOT_DISPLAYED);
+  ok(true, "The full-sized screenshot was displayed for the item at index 2.");
+
+  let thumbnailImageElementSet = waitForMozSetImageElement(window);
+  $("#calls-slider").value = 7;
+  let thumbnailPixels = yield thumbnailImageElementSet;
+
+  ok(sameArray(thumbnailPixels, thumbnails[3].pixels),
+    "The screenshot element should have a thumbnail as an immediate background.");
+
+  yield once(window, EVENTS.CALL_SCREENSHOT_DISPLAYED);
+  ok(true, "The full-sized screenshot was displayed for the item at index 7.");
+
+  let thumbnailImageElementSet = waitForMozSetImageElement(window);
+  $("#calls-slider").value = 4;
+  let thumbnailPixels = yield thumbnailImageElementSet;
+
+  ok(sameArray(thumbnailPixels, thumbnails[2].pixels),
+    "The screenshot element should have a thumbnail as an immediate background.");
+
+  yield once(window, EVENTS.CALL_SCREENSHOT_DISPLAYED);
+  ok(true, "The full-sized screenshot was displayed for the item at index 4.");
+
+  let thumbnailImageElementSet = waitForMozSetImageElement(window);
+  $("#calls-slider").value = 0;
+  let thumbnailPixels = yield thumbnailImageElementSet;
+
+  ok(sameArray(thumbnailPixels, thumbnails[0].pixels),
+    "The screenshot element should have a thumbnail as an immediate background.");
+
+  yield once(window, EVENTS.CALL_SCREENSHOT_DISPLAYED);
+  ok(true, "The full-sized screenshot was displayed for the item at index 0.");
+
+  yield teardown(panel);
+  finish();
+}
+
+function waitForMozSetImageElement(panel) {
+  let deferred = promise.defer();
+  panel._onMozSetImageElement = deferred.resolve;
+  return deferred.promise;
+}
+
+function sameArray(a, b) {
+  if (a.length != b.length) {
+    return false;
+  }
+  for (let i = 0; i < a.length; i++) {
+    if (a[i] !== b[i]) {
+      return false;
+    }
+  }
+  return true;
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-snapshot-select.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if selecting snapshots in the frontend displays the appropriate data
+ * respective to their recorded animation frame.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_URL);
+  let { window, $, EVENTS, SnapshotsListView, CallsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  yield recordAndWaitForFirstSnapshot();
+  info("First snapshot recorded.")
+
+  is(SnapshotsListView.selectedIndex, 0,
+    "A snapshot should be automatically selected after first recording.");
+  is(CallsListView.selectedIndex, -1,
+    "There should be no call item automatically selected in the snapshot.");
+
+  yield recordAndWaitForAnotherSnapshot();
+  info("Second snapshot recorded.")
+
+  is(SnapshotsListView.selectedIndex, 0,
+    "A snapshot should not be automatically selected after another recording.");
+  is(CallsListView.selectedIndex, -1,
+    "There should still be no call item automatically selected in the snapshot.");
+
+  let secondSnapshotTarget = SnapshotsListView.getItemAtIndex(1).target;
+  let snapshotSelected = waitForSnapshotSelection();
+  EventUtils.sendMouseEvent({ type: "mousedown" }, secondSnapshotTarget, window);
+
+  yield snapshotSelected;
+  info("Second snapshot selected.");
+
+  is(SnapshotsListView.selectedIndex, 1,
+    "The second snapshot should now be selected.");
+  is(CallsListView.selectedIndex, -1,
+    "There should still be no call item automatically selected in the snapshot.");
+
+  let firstDrawCallContents = $(".call-item-contents", CallsListView.getItemAtIndex(2).target);
+  let screenshotDisplayed = once(window, EVENTS.CALL_SCREENSHOT_DISPLAYED);
+  EventUtils.sendMouseEvent({ type: "mousedown" }, firstDrawCallContents, window);
+
+  yield screenshotDisplayed;
+  info("First draw call in the second snapshot selected.");
+
+  is(SnapshotsListView.selectedIndex, 1,
+    "The second snapshot should still be selected.");
+  is(CallsListView.selectedIndex, 2,
+    "The first draw call should now be selected in the snapshot.");
+
+  let firstSnapshotTarget = SnapshotsListView.getItemAtIndex(0).target;
+  let snapshotSelected = waitForSnapshotSelection();
+  EventUtils.sendMouseEvent({ type: "mousedown" }, firstSnapshotTarget, window);
+
+  yield snapshotSelected;
+  info("First snapshot re-selected.");
+
+  is(SnapshotsListView.selectedIndex, 0,
+    "The first snapshot should now be re-selected.");
+  is(CallsListView.selectedIndex, -1,
+    "There should still be no call item automatically selected in the snapshot.");
+
+  function recordAndWaitForFirstSnapshot() {
+    let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+    let snapshotSelected = waitForSnapshotSelection();
+    SnapshotsListView._onRecordButtonClick();
+    return promise.all([recordingFinished, snapshotSelected]);
+  }
+
+  function recordAndWaitForAnotherSnapshot() {
+    let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+    SnapshotsListView._onRecordButtonClick();
+    return recordingFinished;
+  }
+
+  function waitForSnapshotSelection() {
+    let callListPopulated = once(window, EVENTS.CALL_LIST_POPULATED);
+    let thumbnailsDisplayed = once(window, EVENTS.THUMBNAILS_DISPLAYED);
+    let screenshotDisplayed = once(window, EVENTS.CALL_SCREENSHOT_DISPLAYED);
+    return promise.all([
+      callListPopulated,
+      thumbnailsDisplayed,
+      screenshotDisplayed
+    ]);
+  }
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-stepping.js
@@ -0,0 +1,76 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if the stepping buttons in the call list toolbar work as advertised.
+ */
+
+function ifTestingSupported() {
+  let [target, debuggee, panel] = yield initCanavsDebuggerFrontend(SIMPLE_CANVAS_URL);
+  let { window, $, EVENTS, SnapshotsListView, CallsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  let callListPopulated = once(window, EVENTS.CALL_LIST_POPULATED);
+  SnapshotsListView._onRecordButtonClick();
+  yield promise.all([recordingFinished, callListPopulated]);
+
+  checkSteppingButtons(1, 1, 1, 1);
+  is(CallsListView.selectedIndex, -1,
+    "There should be no selected item in the calls list view initially.");
+
+  CallsListView._onResume();
+  checkSteppingButtons(1, 1, 1, 1);
+  is(CallsListView.selectedIndex, 0,
+    "The first draw call should now be selected.");
+
+  CallsListView._onResume();
+  checkSteppingButtons(1, 1, 1, 1);
+  is(CallsListView.selectedIndex, 2,
+    "The second draw call should now be selected.");
+
+  CallsListView._onStepOver();
+  checkSteppingButtons(1, 1, 1, 1);
+  is(CallsListView.selectedIndex, 3,
+    "The next context call should now be selected.");
+
+  CallsListView._onStepOut();
+  checkSteppingButtons(0, 0, 1, 0);
+  is(CallsListView.selectedIndex, 7,
+    "The last context call should now be selected.");
+
+  function checkSteppingButtons(resume, stepOver, stepIn, stepOut) {
+    if (!resume) {
+      is($("#resume").getAttribute("disabled"), "true",
+        "The resume button doesn't have the expected disabled state.");
+    } else {
+      is($("#resume").hasAttribute("disabled"), false,
+        "The resume button doesn't have the expected enabled state.");
+    }
+    if (!stepOver) {
+      is($("#step-over").getAttribute("disabled"), "true",
+        "The stepOver button doesn't have the expected disabled state.");
+    } else {
+      is($("#step-over").hasAttribute("disabled"), false,
+        "The stepOver button doesn't have the expected enabled state.");
+    }
+    if (!stepIn) {
+      is($("#step-in").getAttribute("disabled"), "true",
+        "The stepIn button doesn't have the expected disabled state.");
+    } else {
+      is($("#step-in").hasAttribute("disabled"), false,
+        "The stepIn button doesn't have the expected enabled state.");
+    }
+    if (!stepOut) {
+      is($("#step-out").getAttribute("disabled"), "true",
+        "The stepOut button doesn't have the expected disabled state.");
+    } else {
+      is($("#step-out").hasAttribute("disabled"), false,
+        "The stepOut button doesn't have the expected enabled state.");
+    }
+  }
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/doc_simple-canvas-deep-stack.html
@@ -0,0 +1,46 @@
+<!-- Any copyright is dedicated to the Public Domain.
+     http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype html>
+
+<html>
+  <head>
+    <meta charset="utf-8"/>
+    <title>Canvas inspector test page</title>
+  </head>
+
+  <body>
+    <canvas width="128" height="128"></canvas>
+
+    <script type="text/javascript;version=1.8">
+      "use strict";
+
+      var ctx = document.querySelector("canvas").getContext("2d");
+
+      function drawRect(fill, size) {
+        function A() {
+          function B() {
+            function C() {
+              ctx.fillStyle = fill;
+              ctx.fillRect(size[0], size[1], size[2], size[3]);
+            }
+            C();
+          }
+          B();
+        }
+        A();
+      }
+
+      function drawScene() {
+        ctx.clearRect(0, 0, 128, 128);
+        drawRect("rgb(192, 192, 192)", [0, 0, 128, 128]);
+        drawRect("rgba(0, 0, 192, 0.5)", [30, 30, 55, 50]);
+        drawRect("rgba(192, 0, 0, 0.5)", [10, 10, 55, 50]);
+
+        window.requestAnimationFrame(drawScene);
+      }
+
+      drawScene();
+    </script>
+  </body>
+
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/doc_simple-canvas-transparent.html
@@ -0,0 +1,37 @@
+<!-- Any copyright is dedicated to the Public Domain.
+     http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype html>
+
+<html>
+  <head>
+    <meta charset="utf-8"/>
+    <title>Canvas inspector test page</title>
+  </head>
+
+  <body>
+    <canvas width="128" height="128"></canvas>
+
+    <script type="text/javascript;version=1.8">
+      "use strict";
+
+      var ctx = document.querySelector("canvas").getContext("2d");
+
+      function drawRect(fill, size) {
+        ctx.fillStyle = fill;
+        ctx.fillRect(size[0], size[1], size[2], size[3]);
+      }
+
+      function drawScene() {
+        ctx.clearRect(0, 0, 128, 128);
+        drawRect("rgba(255, 255, 255, 0)", [0, 0, 128, 128]);
+        drawRect("rgba(0, 0, 192, 0.5)", [30, 30, 55, 50]);
+        drawRect("rgba(192, 0, 0, 0.5)", [10, 10, 55, 50]);
+
+        window.requestAnimationFrame(drawScene);
+      }
+
+      drawScene();
+    </script>
+  </body>
+
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/doc_simple-canvas.html
@@ -0,0 +1,37 @@
+<!-- Any copyright is dedicated to the Public Domain.
+     http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype html>
+
+<html>
+  <head>
+    <meta charset="utf-8"/>
+    <title>Canvas inspector test page</title>
+  </head>
+
+  <body>
+    <canvas width="128" height="128"></canvas>
+
+    <script type="text/javascript;version=1.8">
+      "use strict";
+
+      var ctx = document.querySelector("canvas").getContext("2d");
+
+      function drawRect(fill, size) {
+        ctx.fillStyle = fill;
+        ctx.fillRect(size[0], size[1], size[2], size[3]);
+      }
+
+      function drawScene() {
+        ctx.clearRect(0, 0, 128, 128);
+        drawRect("rgb(192, 192, 192)", [0, 0, 128, 128]);
+        drawRect("rgba(0, 0, 192, 0.5)", [30, 30, 55, 50]);
+        drawRect("rgba(192, 0, 0, 0.5)", [10, 10, 55, 50]);
+
+        window.requestAnimationFrame(drawScene);
+      }
+
+      drawScene();
+    </script>
+  </body>
+
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/head.js
@@ -0,0 +1,234 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
+
+let { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
+
+// Disable logging for all the tests. Both the debugger server and frontend will
+// be affected by this pref.
+let gEnableLogging = Services.prefs.getBoolPref("devtools.debugger.log");
+Services.prefs.setBoolPref("devtools.debugger.log", false);
+
+let { Task } = Cu.import("resource://gre/modules/Task.jsm", {});
+let { Promise: promise } = Cu.import("resource://gre/modules/Promise.jsm", {});
+let { gDevTools } = Cu.import("resource:///modules/devtools/gDevTools.jsm", {});
+let { devtools } = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+let { DebuggerServer } = Cu.import("resource://gre/modules/devtools/dbg-server.jsm", {});
+let { DebuggerClient } = Cu.import("resource://gre/modules/devtools/dbg-client.jsm", {});
+
+let { CallWatcherFront } = devtools.require("devtools/server/actors/call-watcher");
+let { CanvasFront } = devtools.require("devtools/server/actors/canvas");
+let TiltGL = devtools.require("devtools/tilt/tilt-gl");
+let TargetFactory = devtools.TargetFactory;
+let Toolbox = devtools.Toolbox;
+
+const EXAMPLE_URL = "http://example.com/browser/browser/devtools/canvasdebugger/test/";
+const SIMPLE_CANVAS_URL = EXAMPLE_URL + "doc_simple-canvas.html";
+const SIMPLE_CANVAS_TRANSPARENT_URL = EXAMPLE_URL + "doc_simple-canvas-transparent.html";
+const SIMPLE_CANVAS_DEEP_STACK_URL = EXAMPLE_URL + "doc_simple-canvas-deep-stack.html";
+
+// All tests are asynchronous.
+waitForExplicitFinish();
+
+let gToolEnabled = Services.prefs.getBoolPref("devtools.canvasdebugger.enabled");
+
+registerCleanupFunction(() => {
+  info("finish() was called, cleaning up...");
+  Services.prefs.setBoolPref("devtools.debugger.log", gEnableLogging);
+  Services.prefs.setBoolPref("devtools.canvasdebugger.enabled", gToolEnabled);
+
+  // Some of yhese tests use a lot of memory due to GL contexts, so force a GC
+  // to help fragmentation.
+  info("Forcing GC after canvas debugger test.");
+  Cu.forceGC();
+});
+
+function addTab(aUrl, aWindow) {
+  info("Adding tab: " + aUrl);
+
+  let deferred = promise.defer();
+  let targetWindow = aWindow || window;
+  let targetBrowser = targetWindow.gBrowser;
+
+  targetWindow.focus();
+  let tab = targetBrowser.selectedTab = targetBrowser.addTab(aUrl);
+  let linkedBrowser = tab.linkedBrowser;
+
+  linkedBrowser.addEventListener("load", function onLoad() {
+    linkedBrowser.removeEventListener("load", onLoad, true);
+    info("Tab added and finished loading: " + aUrl);
+    deferred.resolve(tab);
+  }, true);
+
+  return deferred.promise;
+}
+
+function removeTab(aTab, aWindow) {
+  info("Removing tab.");
+
+  let deferred = promise.defer();
+  let targetWindow = aWindow || window;
+  let targetBrowser = targetWindow.gBrowser;
+  let tabContainer = targetBrowser.tabContainer;
+
+  tabContainer.addEventListener("TabClose", function onClose(aEvent) {
+    tabContainer.removeEventListener("TabClose", onClose, false);
+    info("Tab removed and finished closing.");
+    deferred.resolve();
+  }, false);
+
+  targetBrowser.removeTab(aTab);
+  return deferred.promise;
+}
+
+function handleError(aError) {
+  ok(false, "Got an error: " + aError.message + "\n" + aError.stack);
+  finish();
+}
+
+let gRequiresWebGL = false;
+
+function ifTestingSupported() {
+  ok(false, "You need to define a 'ifTestingSupported' function.");
+  finish();
+}
+
+function ifTestingUnsupported() {
+  todo(false, "Skipping test because some required functionality isn't supported.");
+  finish();
+}
+
+function test() {
+  let generator = isTestingSupported() ? ifTestingSupported : ifTestingUnsupported;
+  Task.spawn(generator).then(null, handleError);
+}
+
+function createCanvas() {
+  return document.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
+}
+
+function isTestingSupported() {
+  if (!gRequiresWebGL) {
+    info("This test does not require WebGL support.");
+    return true;
+  }
+
+  let supported =
+    !TiltGL.isWebGLForceEnabled() &&
+     TiltGL.isWebGLSupported() &&
+     TiltGL.create3DContext(createCanvas());
+
+  info("This test requires WebGL support.");
+  info("Apparently, WebGL is" + (supported ? "" : " not") + " supported.");
+  return supported;
+}
+
+function once(aTarget, aEventName, aUseCapture = false) {
+  info("Waiting for event: '" + aEventName + "' on " + aTarget + ".");
+
+  let deferred = promise.defer();
+
+  for (let [add, remove] of [
+    ["on", "off"], // Use event emitter before DOM events for consistency
+    ["addEventListener", "removeEventListener"],
+    ["addListener", "removeListener"]
+  ]) {
+    if ((add in aTarget) && (remove in aTarget)) {
+      aTarget[add](aEventName, function onEvent(...aArgs) {
+        aTarget[remove](aEventName, onEvent, aUseCapture);
+        deferred.resolve(...aArgs);
+      }, aUseCapture);
+      break;
+    }
+  }
+
+  return deferred.promise;
+}
+
+function waitForTick() {
+  let deferred = promise.defer();
+  executeSoon(deferred.resolve);
+  return deferred.promise;
+}
+
+function navigateInHistory(aTarget, aDirection, aWaitForTargetEvent = "navigate") {
+  executeSoon(() => content.history[aDirection]());
+  return once(aTarget, aWaitForTargetEvent);
+}
+
+function navigate(aTarget, aUrl, aWaitForTargetEvent = "navigate") {
+  executeSoon(() => aTarget.activeTab.navigateTo(aUrl));
+  return once(aTarget, aWaitForTargetEvent);
+}
+
+function reload(aTarget, aWaitForTargetEvent = "navigate") {
+  executeSoon(() => aTarget.activeTab.reload());
+  return once(aTarget, aWaitForTargetEvent);
+}
+
+function initServer() {
+  if (!DebuggerServer.initialized) {
+    DebuggerServer.init(() => true);
+    DebuggerServer.addBrowserActors();
+  }
+}
+
+function initCallWatcherBackend(aUrl) {
+  info("Initializing a call watcher front.");
+  initServer();
+
+  return Task.spawn(function*() {
+    let tab = yield addTab(aUrl);
+    let target = TargetFactory.forTab(tab);
+    let debuggee = target.window.wrappedJSObject;
+
+    yield target.makeRemote();
+
+    let front = new CallWatcherFront(target.client, target.form);
+    return [target, debuggee, front];
+  });
+}
+
+function initCanavsDebuggerBackend(aUrl) {
+  info("Initializing a canvas debugger front.");
+  initServer();
+
+  return Task.spawn(function*() {
+    let tab = yield addTab(aUrl);
+    let target = TargetFactory.forTab(tab);
+    let debuggee = target.window.wrappedJSObject;
+
+    yield target.makeRemote();
+
+    let front = new CanvasFront(target.client, target.form);
+    return [target, debuggee, front];
+  });
+}
+
+function initCanavsDebuggerFrontend(aUrl) {
+  info("Initializing a canvas debugger pane.");
+
+  return Task.spawn(function*() {
+    let tab = yield addTab(aUrl);
+    let target = TargetFactory.forTab(tab);
+    let debuggee = target.window.wrappedJSObject;
+
+    yield target.makeRemote();
+
+    Services.prefs.setBoolPref("devtools.canvasdebugger.enabled", true);
+    let toolbox = yield gDevTools.showToolbox(target, "canvasdebugger");
+    let panel = toolbox.getCurrentPanel();
+    return [target, debuggee, panel];
+  });
+}
+
+function teardown(aPanel) {
+  info("Destroying the specified canvas debugger.");
+
+  return promise.all([
+    once(aPanel, "destroyed"),
+    removeTab(aPanel.target.tab)
+  ]);
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/moz.build
@@ -0,0 +1,6 @@
+# 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/.
+
+BROWSER_CHROME_MANIFESTS += ['browser.ini']
--- a/browser/devtools/debugger/debugger-view.js
+++ b/browser/devtools/debugger/debugger-view.js
@@ -385,18 +385,17 @@ let DebuggerView = {
     let histogram = Services.telemetry.getHistogramById(histogramId);
     let startTime = Date.now();
 
     let deferred = promise.defer();
 
     this._setEditorText(L10N.getStr("loadingText"));
     this._editorSource = { url: aSource.url, promise: deferred.promise };
 
-    DebuggerController.SourceScripts.getText(aSource)
-                                    .then(([, aText, aContentType]) => {
+    DebuggerController.SourceScripts.getText(aSource).then(([, aText, aContentType]) => {
       // Avoid setting an unexpected source. This may happen when switching
       // very fast between sources that haven't been fetched yet.
       if (this._editorSource.url != aSource.url) {
         return;
       }
 
       this._setEditorText(aText);
       this._setEditorMode(aSource.url, aContentType, aText);
@@ -464,18 +463,17 @@ let DebuggerView = {
       }
     }
 
     let sourceItem = this.Sources.getItemByValue(aUrl);
     let sourceForm = sourceItem.attachment.source;
 
     // Make sure the requested source client is shown in the editor, then
     // update the source editor's caret position and debug location.
-    return this._setEditorSource(sourceForm, aFlags)
-               .then(([,, aContentType]) => {
+    return this._setEditorSource(sourceForm, aFlags).then(([,, aContentType]) => {
       // Record the contentType learned from fetching
       sourceForm.contentType = aContentType;
       // Line numbers in the source editor should start from 1. If invalid
       // or not specified, then don't do anything.
       if (aLine < 1) {
         window.emit(EVENTS.EDITOR_LOCATION_SET);
         return;
       }
--- a/browser/devtools/debugger/debugger.xul
+++ b/browser/devtools/debugger/debugger.xul
@@ -429,17 +429,17 @@
               hidden="true">
         <tabs>
           <tab id="variables-tab" label="&debuggerUI.tabs.variables;"/>
           <tab id="events-tab" label="&debuggerUI.tabs.events;"/>
         </tabs>
         <tabpanels flex="1">
           <tabpanel id="variables-tabpanel">
             <vbox id="expressions"/>
-            <splitter class="devtools-horizontal-splitter devtools-invisible splitter"/>
+            <splitter class="devtools-horizontal-splitter devtools-invisible-splitter"/>
             <vbox id="variables" flex="1"/>
           </tabpanel>
           <tabpanel id="events-tabpanel">
             <vbox id="event-listeners" flex="1"/>
           </tabpanel>
         </tabpanels>
       </tabbox>
       <splitter id="vertical-layout-splitter"
--- a/browser/devtools/debugger/panel.js
+++ b/browser/devtools/debugger/panel.js
@@ -3,17 +3,16 @@
 /* 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/. */
 "use strict";
 
 const { Cc, Ci, Cu, Cr } = require("chrome");
 const promise = require("sdk/core/promise");
 const EventEmitter = require("devtools/toolkit/event-emitter");
-
 const { DevToolsUtils } = Cu.import("resource://gre/modules/devtools/DevToolsUtils.jsm", {});
 
 function DebuggerPanel(iframeWindow, toolbox) {
   this.panelWin = iframeWindow;
   this._toolbox = toolbox;
   this._destroyer = null;
 
   this._view = this.panelWin.DebuggerView;
@@ -55,17 +54,17 @@ DebuggerPanel.prototype = {
         this._toolbox.on("host-changed", this.handleHostChanged);
         this.target.on("thread-paused", this.highlightWhenPaused);
         this.target.on("thread-resumed", this.unhighlightWhenResumed);
         this.isReady = true;
         this.emit("ready");
         return this;
       })
       .then(null, function onError(aReason) {
-        DevToolsUtils.reportException("DebuggerPane.prototype.open", aReason);
+        DevToolsUtils.reportException("DebuggerPanel.prototype.open", aReason);
       });
   },
 
   // DevToolPanel API
 
   get target() this._toolbox.target,
 
   destroy: function() {
--- a/browser/devtools/debugger/test/head.js
+++ b/browser/devtools/debugger/test/head.js
@@ -240,18 +240,17 @@ function waitForSourceShown(aPanel, aUrl
       return waitForSourceShown(aPanel, aUrl);
     } else {
       ok(true, "The correct source has been shown.");
     }
   });
 }
 
 function waitForEditorLocationSet(aPanel) {
-  return waitForDebuggerEvents(aPanel,
-                               aPanel.panelWin.EVENTS.EDITOR_LOCATION_SET);
+  return waitForDebuggerEvents(aPanel, aPanel.panelWin.EVENTS.EDITOR_LOCATION_SET);
 }
 
 function ensureSourceIs(aPanel, aUrl, aWaitFlag = false) {
   if (aPanel.panelWin.DebuggerView.Sources.selectedValue.contains(aUrl)) {
     ok(true, "Expected source is shown: " + aUrl);
     return promise.resolve(null);
   }
   if (aWaitFlag) {
--- a/browser/devtools/jar.mn
+++ b/browser/devtools/jar.mn
@@ -55,16 +55,18 @@ browser.jar:
     content/browser/devtools/debugger.xul                              (debugger/debugger.xul)
     content/browser/devtools/debugger.css                              (debugger/debugger.css)
     content/browser/devtools/debugger-controller.js                    (debugger/debugger-controller.js)
     content/browser/devtools/debugger-view.js                          (debugger/debugger-view.js)
     content/browser/devtools/debugger-toolbar.js                       (debugger/debugger-toolbar.js)
     content/browser/devtools/debugger-panes.js                         (debugger/debugger-panes.js)
     content/browser/devtools/shadereditor.xul                          (shadereditor/shadereditor.xul)
     content/browser/devtools/shadereditor.js                           (shadereditor/shadereditor.js)
+    content/browser/devtools/canvasdebugger.xul                        (canvasdebugger/canvasdebugger.xul)
+    content/browser/devtools/canvasdebugger.js                         (canvasdebugger/canvasdebugger.js)
     content/browser/devtools/profiler.xul                              (profiler/profiler.xul)
     content/browser/devtools/cleopatra.html                            (profiler/cleopatra/cleopatra.html)
     content/browser/devtools/profiler/cleopatra/css/ui.css             (profiler/cleopatra/css/ui.css)
     content/browser/devtools/profiler/cleopatra/css/tree.css           (profiler/cleopatra/css/tree.css)
     content/browser/devtools/profiler/cleopatra/css/devtools.css       (profiler/cleopatra/css/devtools.css)
     content/browser/devtools/profiler/cleopatra/js/strings.js          (profiler/cleopatra/js/strings.js)
     content/browser/devtools/profiler/cleopatra/js/parser.js           (profiler/cleopatra/js/parser.js)
     content/browser/devtools/profiler/cleopatra/js/parserWorker.js     (profiler/cleopatra/js/parserWorker.js)
--- a/browser/devtools/main.js
+++ b/browser/devtools/main.js
@@ -23,35 +23,38 @@ let events = require("sdk/system/events"
 
 // Panels
 loader.lazyGetter(this, "OptionsPanel", () => require("devtools/framework/toolbox-options").OptionsPanel);
 loader.lazyGetter(this, "InspectorPanel", () => require("devtools/inspector/inspector-panel").InspectorPanel);
 loader.lazyGetter(this, "WebConsolePanel", () => require("devtools/webconsole/panel").WebConsolePanel);
 loader.lazyGetter(this, "DebuggerPanel", () => require("devtools/debugger/panel").DebuggerPanel);
 loader.lazyGetter(this, "StyleEditorPanel", () => require("devtools/styleeditor/styleeditor-panel").StyleEditorPanel);
 loader.lazyGetter(this, "ShaderEditorPanel", () => require("devtools/shadereditor/panel").ShaderEditorPanel);
+loader.lazyGetter(this, "CanvasDebuggerPanel", () => require("devtools/canvasdebugger/panel").CanvasDebuggerPanel);
 loader.lazyGetter(this, "ProfilerPanel", () => require("devtools/profiler/panel"));
 loader.lazyGetter(this, "NetMonitorPanel", () => require("devtools/netmonitor/panel").NetMonitorPanel);
 loader.lazyGetter(this, "ScratchpadPanel", () => require("devtools/scratchpad/scratchpad-panel").ScratchpadPanel);
 
 // Strings
 const toolboxProps = "chrome://browser/locale/devtools/toolbox.properties";
 const inspectorProps = "chrome://browser/locale/devtools/inspector.properties";
 const debuggerProps = "chrome://browser/locale/devtools/debugger.properties";
 const styleEditorProps = "chrome://browser/locale/devtools/styleeditor.properties";
 const shaderEditorProps = "chrome://browser/locale/devtools/shadereditor.properties";
+const canvasDebuggerProps = "chrome://browser/locale/devtools/canvasdebugger.properties";
 const webConsoleProps = "chrome://browser/locale/devtools/webconsole.properties";
 const profilerProps = "chrome://browser/locale/devtools/profiler.properties";
 const netMonitorProps = "chrome://browser/locale/devtools/netmonitor.properties";
 const scratchpadProps = "chrome://browser/locale/devtools/scratchpad.properties";
 loader.lazyGetter(this, "toolboxStrings", () => Services.strings.createBundle(toolboxProps));
 loader.lazyGetter(this, "webConsoleStrings", () => Services.strings.createBundle(webConsoleProps));
 loader.lazyGetter(this, "debuggerStrings", () => Services.strings.createBundle(debuggerProps));
 loader.lazyGetter(this, "styleEditorStrings", () => Services.strings.createBundle(styleEditorProps));
 loader.lazyGetter(this, "shaderEditorStrings", () => Services.strings.createBundle(shaderEditorProps));
+loader.lazyGetter(this, "canvasDebuggerStrings", () => Services.strings.createBundle(canvasDebuggerProps));
 loader.lazyGetter(this, "inspectorStrings", () => Services.strings.createBundle(inspectorProps));
 loader.lazyGetter(this, "profilerStrings",() => Services.strings.createBundle(profilerProps));
 loader.lazyGetter(this, "netMonitorStrings", () => Services.strings.createBundle(netMonitorProps));
 loader.lazyGetter(this, "scratchpadStrings", () => Services.strings.createBundle(scratchpadProps));
 
 let Tools = {};
 exports.Tools = Tools;
 
@@ -195,21 +198,41 @@ Tools.shaderEditor = {
   },
 
   build: function(iframeWindow, toolbox) {
     let panel = new ShaderEditorPanel(iframeWindow, toolbox);
     return panel.open();
   }
 };
 
+Tools.canvasDebugger = {
+  id: "canvasdebugger",
+  ordinal: 6,
+  visibilityswitch: "devtools.canvasdebugger.enabled",
+  icon: "chrome://browser/skin/devtools/tool-styleeditor.svg",
+  invertIconForLightTheme: true,
+  url: "chrome://browser/content/devtools/canvasdebugger.xul",
+  label: l10n("ToolboxCanvasDebugger.label", canvasDebuggerStrings),
+  tooltip: l10n("ToolboxCanvasDebugger.tooltip", canvasDebuggerStrings),
+
+  isTargetSupported: function(target) {
+    return true;
+  },
+
+  build: function(iframeWindow, toolbox) {
+    let panel = new CanvasDebuggerPanel(iframeWindow, toolbox);
+    return panel.open();
+  }
+};
+
 Tools.jsprofiler = {
   id: "jsprofiler",
   accesskey: l10n("profiler.accesskey", profilerStrings),
   key: l10n("profiler2.commandkey", profilerStrings),
-  ordinal: 6,
+  ordinal: 7,
   modifiers: "shift",
   visibilityswitch: "devtools.profiler.enabled",
   icon: "chrome://browser/skin/devtools/tool-profiler.svg",
   invertIconForLightTheme: true,
   url: "chrome://browser/content/devtools/profiler.xul",
   label: l10n("profiler.label", profilerStrings),
   tooltip: l10n("profiler.tooltip2", profilerStrings),
   inMenu: true,
@@ -223,17 +246,17 @@ Tools.jsprofiler = {
     return panel.open();
   }
 };
 
 Tools.netMonitor = {
   id: "netmonitor",
   accesskey: l10n("netmonitor.accesskey", netMonitorStrings),
   key: l10n("netmonitor.commandkey", netMonitorStrings),
-  ordinal: 7,
+  ordinal: 8,
   modifiers: osString == "Darwin" ? "accel,alt" : "accel,shift",
   visibilityswitch: "devtools.netmonitor.enabled",
   icon: "chrome://browser/skin/devtools/tool-network.svg",
   invertIconForLightTheme: true,
   url: "chrome://browser/content/devtools/netmonitor.xul",
   label: l10n("netmonitor.label", netMonitorStrings),
   tooltip: l10n("netmonitor.tooltip", netMonitorStrings),
   inMenu: true,
@@ -246,17 +269,17 @@ Tools.netMonitor = {
   build: function(iframeWindow, toolbox) {
     let panel = new NetMonitorPanel(iframeWindow, toolbox);
     return panel.open();
   }
 };
 
 Tools.scratchpad = {
   id: "scratchpad",
-  ordinal: 8,
+  ordinal: 9,
   visibilityswitch: "devtools.scratchpad.enabled",
   icon: "chrome://browser/skin/devtools/tool-scratchpad.svg",
   invertIconForLightTheme: true,
   url: "chrome://browser/content/devtools/scratchpad.xul",
   label: l10n("scratchpad.label", scratchpadStrings),
   tooltip: l10n("scratchpad.tooltip", scratchpadStrings),
   inMenu: false,
 
@@ -272,16 +295,17 @@ Tools.scratchpad = {
 
 let defaultTools = [
   Tools.options,
   Tools.webConsole,
   Tools.inspector,
   Tools.jsdebugger,
   Tools.styleEditor,
   Tools.shaderEditor,
+  Tools.canvasDebugger,
   Tools.jsprofiler,
   Tools.netMonitor,
   Tools.scratchpad
 ];
 
 exports.defaultTools = defaultTools;
 
 for (let definition of defaultTools) {
--- a/browser/devtools/moz.build
+++ b/browser/devtools/moz.build
@@ -1,16 +1,17 @@
 # -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
 # 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 += [
     'app-manager',
+    'canvasdebugger',
     'commandline',
     'debugger',
     'fontinspector',
     'framework',
     'inspector',
     'layoutview',
     'markupview',
     'netmonitor',
--- a/browser/devtools/netmonitor/netmonitor-controller.js
+++ b/browser/devtools/netmonitor/netmonitor-controller.js
@@ -112,42 +112,39 @@ const require = Cu.import("resource://gr
 const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
 const EventEmitter = require("devtools/toolkit/event-emitter");
 const Editor = require("devtools/sourceeditor/editor");
 const {Tooltip} = require("devtools/shared/widgets/Tooltip");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Chart",
   "resource:///modules/devtools/Chart.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "Curl",
+  "resource:///modules/devtools/Curl.jsm");
+
 XPCOMUtils.defineLazyModuleGetter(this, "Task",
   "resource://gre/modules/Task.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
   "resource://gre/modules/PluralForm.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "DevToolsUtils",
   "resource://gre/modules/devtools/DevToolsUtils.jsm");
 
-XPCOMUtils.defineLazyModuleGetter(this, "devtools",
-  "resource://gre/modules/devtools/Loader.jsm");
+XPCOMUtils.defineLazyServiceGetter(this, "clipboardHelper",
+  "@mozilla.org/widget/clipboardhelper;1", "nsIClipboardHelper");
 
 Object.defineProperty(this, "NetworkHelper", {
   get: function() {
-    return devtools.require("devtools/toolkit/webconsole/network-helper");
+    return require("devtools/toolkit/webconsole/network-helper");
   },
   configurable: true,
   enumerable: true
 });
 
-XPCOMUtils.defineLazyServiceGetter(this, "clipboardHelper",
-  "@mozilla.org/widget/clipboardhelper;1", "nsIClipboardHelper");
-
-XPCOMUtils.defineLazyModuleGetter(this, "Curl",
-  "resource:///modules/devtools/Curl.jsm");
-
 /**
  * Object defining the network monitor controller components.
  */
 let NetMonitorController = {
   /**
    * Initializes the view.
    *
    * @return object
--- a/browser/devtools/netmonitor/panel.js
+++ b/browser/devtools/netmonitor/panel.js
@@ -3,16 +3,17 @@
 /* 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/. */
 "use strict";
 
 const { Cc, Ci, Cu, Cr } = require("chrome");
 const { Promise: promise } = Cu.import("resource://gre/modules/Promise.jsm", {});
 const EventEmitter = require("devtools/toolkit/event-emitter");
+const { DevToolsUtils } = Cu.import("resource://gre/modules/devtools/DevToolsUtils.jsm", {});
 
 function NetMonitorPanel(iframeWindow, toolbox) {
   this.panelWin = iframeWindow;
   this._toolbox = toolbox;
   this._destroyer = null;
 
   this._view = this.panelWin.NetMonitorView;
   this._controller = this.panelWin.NetMonitorController;
@@ -44,18 +45,17 @@ NetMonitorPanel.prototype = {
       .then(() => this._controller.startupNetMonitor())
       .then(() => this._controller.connect())
       .then(() => {
         this.isReady = true;
         this.emit("ready");
         return this;
       })
       .then(null, function onError(aReason) {
-        Cu.reportError("NetMonitorPanel open failed. " +
-                       aReason.error + ": " + aReason.message);
+        DevToolsUtils.reportException("NetMonitorPanel.prototype.open", aReason);
       });
   },
 
   // DevToolPanel API
 
   get target() this._toolbox.target,
 
   destroy: function() {
--- a/browser/devtools/shadereditor/moz.build
+++ b/browser/devtools/shadereditor/moz.build
@@ -5,9 +5,8 @@
 
 TEST_DIRS += ['test']
 
 JS_MODULES_PATH = 'modules/devtools/shadereditor'
 
 EXTRA_JS_MODULES += [
     'panel.js'
 ]
-
--- a/browser/devtools/shadereditor/panel.js
+++ b/browser/devtools/shadereditor/panel.js
@@ -4,28 +4,35 @@
  * 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/. */
 "use strict";
 
 const { Cc, Ci, Cu, Cr } = require("chrome");
 const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
 const EventEmitter = require("devtools/toolkit/event-emitter");
 const { WebGLFront } = require("devtools/server/actors/webgl");
+const { DevToolsUtils } = Cu.import("resource://gre/modules/devtools/DevToolsUtils.jsm", {});
 
 function ShaderEditorPanel(iframeWindow, toolbox) {
   this.panelWin = iframeWindow;
   this._toolbox = toolbox;
   this._destroyer = null;
 
   EventEmitter.decorate(this);
 };
 
 exports.ShaderEditorPanel = ShaderEditorPanel;
 
 ShaderEditorPanel.prototype = {
+  /**
+   * Open is effectively an asynchronous constructor.
+   *
+   * @return object
+   *         A promise that is resolved when the Shader Editor completes opening.
+   */
   open: function() {
     let targetPromise;
 
     // Local debugging needs to make the target remote.
     if (!this.target.isRemote) {
       targetPromise = this.target.makeRemote();
     } else {
       targetPromise = promise.resolve(this.target);
@@ -39,18 +46,17 @@ ShaderEditorPanel.prototype = {
         return this.panelWin.startupShaderEditor();
       })
       .then(() => {
         this.isReady = true;
         this.emit("ready");
         return this;
       })
       .then(null, function onError(aReason) {
-        Cu.reportError("ShaderEditorPanel open failed. " +
-                       aReason.error + ": " + aReason.message);
+        DevToolsUtils.reportException("ShaderEditorPanel.prototype.open", aReason);
       });
   },
 
   // DevToolPanel API
 
   get target() this._toolbox.target,
 
   destroy: function() {
--- a/browser/devtools/shadereditor/shadereditor.js
+++ b/browser/devtools/shadereditor/shadereditor.js
@@ -3,17 +3,16 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
-Cu.import("resource://gre/modules/devtools/Loader.jsm");
 Cu.import("resource:///modules/devtools/SideMenuWidget.jsm");
 Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
 
 const require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require;
 const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
 const EventEmitter = require("devtools/toolkit/event-emitter");
 const {Tooltip} = require("devtools/shared/widgets/Tooltip");
 const Editor = require("devtools/sourceeditor/editor");
--- a/browser/devtools/shared/widgets/ViewHelpers.jsm
+++ b/browser/devtools/shared/widgets/ViewHelpers.jsm
@@ -15,17 +15,18 @@ const WIDGET_FOCUSABLE_NODES = new Set([
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Timer.jsm");
 Cu.import("resource://gre/modules/devtools/DevToolsUtils.jsm");
 
 this.EXPORTED_SYMBOLS = [
   "Heritage", "ViewHelpers", "WidgetMethods",
-  "setNamedTimeout", "clearNamedTimeout"
+  "setNamedTimeout", "clearNamedTimeout",
+  "setConditionalTimeout", "clearConditionalTimeout",
 ];
 
 /**
  * Inheritance helpers from the addon SDK's core/heritage.
  * Remove these when all devtools are loadered.
  */
 this.Heritage = {
   /**
@@ -52,38 +53,73 @@ this.Heritage = {
  *
  * @param string aId
  *        A string identifier for the named timeout.
  * @param number aWait
  *        The amount of milliseconds to wait after no more events are fired.
  * @param function aCallback
  *        Invoked when no more events are fired after the specified time.
  */
-this.setNamedTimeout = function(aId, aWait, aCallback) {
+this.setNamedTimeout = function setNamedTimeout(aId, aWait, aCallback) {
   clearNamedTimeout(aId);
 
   namedTimeoutsStore.set(aId, setTimeout(() =>
     namedTimeoutsStore.delete(aId) && aCallback(), aWait));
 };
 
 /**
  * Clears a named timeout.
  * @see setNamedTimeout
  *
  * @param string aId
  *        A string identifier for the named timeout.
  */
-this.clearNamedTimeout = function(aId) {
+this.clearNamedTimeout = function clearNamedTimeout(aId) {
   if (!namedTimeoutsStore) {
     return;
   }
   clearTimeout(namedTimeoutsStore.get(aId));
   namedTimeoutsStore.delete(aId);
 };
 
+/**
+ * Same as `setNamedTimeout`, but invokes the callback only if the provided
+ * predicate function returns true. Otherwise, the timeout is re-triggered.
+ *
+ * @param string aId
+ *        A string identifier for the conditional timeout.
+ * @param number aWait
+ *        The amount of milliseconds to wait after no more events are fired.
+ * @param function aPredicate
+ *        The predicate function used to determine whether the timeout restarts.
+ * @param function aCallback
+ *        Invoked when no more events are fired after the specified time, and
+ *        the provided predicate function returns true.
+ */
+this.setConditionalTimeout = function setConditionalTimeout(aId, aWait, aPredicate, aCallback) {
+  setNamedTimeout(aId, aWait, function maybeCallback() {
+    if (aPredicate()) {
+      aCallback();
+      return;
+    }
+    setConditionalTimeout(aId, aWait, aPredicate, aCallback);
+  });
+};
+
+/**
+ * Clears a conditional timeout.
+ * @see setConditionalTimeout
+ *
+ * @param string aId
+ *        A string identifier for the conditional timeout.
+ */
+this.clearConditionalTimeout = function clearConditionalTimeout(aId) {
+  clearNamedTimeout(aId);
+};
+
 XPCOMUtils.defineLazyGetter(this, "namedTimeoutsStore", () => new Map());
 
 /**
  * Helpers for creating and messaging between UI components.
  */
 this.ViewHelpers = {
   /**
    * Convenience method, dispatching a custom event.
--- a/browser/experiments/Experiments.jsm
+++ b/browser/experiments/Experiments.jsm
@@ -90,16 +90,19 @@ const TELEMETRY_LOG = {
   },
 };
 
 const gPrefs = new Preferences(PREF_BRANCH);
 const gPrefsTelemetry = new Preferences(PREF_BRANCH_TELEMETRY);
 let gExperimentsEnabled = false;
 let gExperiments = null;
 let gLogAppenderDump = null;
+let gPolicyCounter = 0;
+let gExperimentsCounter = 0;
+let gExperimentEntryCounter = 0;
 
 let gLogger;
 let gLogDumping = false;
 
 function configureLogging() {
   if (!gLogger) {
     gLogger = Log.repository.getLogger("Browser.Experiments");
     gLogger.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
@@ -231,28 +234,31 @@ let Experiments = {
 };
 
 /*
  * The policy object allows us to inject fake enviroment data from the
  * outside by monkey-patching.
  */
 
 Experiments.Policy = function () {
+  this._log = Log.repository.getLoggerWithMessagePrefix(
+    "Browser.Experiments.Policy",
+    "Policy #" + gPolicyCounter++ + "::");
 };
 
 Experiments.Policy.prototype = {
   now: function () {
     return new Date();
   },
 
   random: function () {
     let pref = gPrefs.get(PREF_FORCE_SAMPLE);
     if (pref !== undefined) {
       let val = Number.parseFloat(pref);
-      gLogger.debug("Experiments::Policy::random sample forced: " + val);
+      this._log.debug("random sample forced: " + val);
       if (IsNaN(val) || val < 0) {
         return 0;
       }
       if (val > 1) {
         return 1;
       }
       return val;
     }
@@ -296,16 +302,20 @@ Experiments.Policy.prototype = {
   },
 };
 
 /**
  * Manages the experiments and provides an interface to control them.
  */
 
 Experiments.Experiments = function (policy=new Experiments.Policy()) {
+  this._log = Log.repository.getLoggerWithMessagePrefix(
+    "Browser.Experiments.Experiments",
+    "Experiments #" + gExperimentsCounter++ + "::");
+
   this._policy = policy;
 
   // This is a Map of (string -> ExperimentEntry), keyed with the experiment id.
   // It holds both the current experiments and history.
   // Map() preserves insertion order, which means we preserve the manifest order.
   // This is null until we've successfully completed loading the cache from
   // disk the first time.
   this._experiments = null;
@@ -335,39 +345,39 @@ Experiments.Experiments = function (poli
 
 Experiments.Experiments.prototype = {
   QueryInterface: XPCOMUtils.generateQI([Ci.nsITimerCallback, Ci.nsIObserver]),
 
   init: function () {
     configureLogging();
 
     gExperimentsEnabled = gPrefs.get(PREF_ENABLED, false);
-    gLogger.trace("enabled="+gExperimentsEnabled+", "+this.enabled);
+    this._log.trace("enabled=" + gExperimentsEnabled + ", " + this.enabled);
 
     gPrefs.observe(PREF_LOGGING, configureLogging);
     gPrefs.observe(PREF_MANIFEST_URI, this.updateManifest, this);
     gPrefs.observe(PREF_ENABLED, this._toggleExperimentsEnabled, this);
 
     gPrefsTelemetry.observe(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this);
     gPrefsTelemetry.observe(PREF_TELEMETRY_PRERELEASE, this._telemetryStatusChanged, this);
 
     AsyncShutdown.profileBeforeChange.addBlocker("Experiments.jsm shutdown",
       this.uninit.bind(this));
 
     AddonManager.addAddonListener(this);
 
     this._loadTask = Task.spawn(this._loadFromCache.bind(this));
     this._loadTask.then(
       () => {
-        gLogger.trace("Experiments::_loadTask finished ok");
+        this._log.trace("_loadTask finished ok");
         this._loadTask = null;
         this._run();
       },
       (e) => {
-        gLogger.error("Experiments::_loadFromCache caught error: " + e);
+        this._log.error("_loadFromCache caught error: " + e);
       }
     );
   },
 
   /**
    * @return Promise<>
    *         The promise is fulfilled when all pending tasks are finished.
    */
@@ -409,22 +419,22 @@ Experiments.Experiments.prototype = {
   get enabled() {
     return gExperimentsEnabled;
   },
 
   /**
    * Toggle whether the experiments feature is enabled or not.
    */
   set enabled(enabled) {
-    gLogger.trace("Experiments::set enabled(" + enabled + ")");
+    this._log.trace("set enabled(" + enabled + ")");
     gPrefs.set(PREF_ENABLED, enabled);
   },
 
   _toggleExperimentsEnabled: function (enabled) {
-    gLogger.trace("Experiments::_toggleExperimentsEnabled(" + enabled + ")");
+    this._log.trace("_toggleExperimentsEnabled(" + enabled + ")");
     let wasEnabled = gExperimentsEnabled;
     gExperimentsEnabled = enabled && telemetryEnabled();
 
     if (wasEnabled == gExperimentsEnabled) {
       return;
     }
 
     if (gExperimentsEnabled) {
@@ -520,175 +530,180 @@ Experiments.Experiments.prototype = {
           return experiment;
         }
       }
       return null;
     }.bind(this));
   },
 
   _run: function() {
-    gLogger.trace("Experiments::_run");
+    this._log.trace("_run");
     this._checkForShutdown();
     if (!this._mainTask) {
       this._mainTask = Task.spawn(this._main.bind(this));
       this._mainTask.then(
         () => {
-          gLogger.trace("Experiments::_main finished, scheduling next run");
+          this._log.trace("_main finished, scheduling next run");
           this._mainTask = null;
           this._scheduleNextRun();
         },
         (e) => {
-          gLogger.error("Experiments::_main caught error: " + e);
+          this._log.error("_main caught error: " + e);
           this._mainTask = null;
         }
       );
     }
     return this._mainTask;
   },
 
   _main: function*() {
     do {
-      gLogger.trace("Experiments::_main iteration");
+      this._log.trace("_main iteration");
       yield this._loadTask;
       if (this._refresh) {
         yield this._loadManifest();
       }
       yield this._evaluateExperiments();
       if (this._dirty) {
         yield this._saveToCache();
       }
       // If somebody called .updateManifest() or disableExperiment()
       // while we were running, go again right now.
     }
     while (this._refresh || this._terminateReason);
   },
 
   _loadManifest: function*() {
-    gLogger.trace("Experiments::_loadManifest");
+    this._log.trace("_loadManifest");
     let uri = Services.urlFormatter.formatURLPref(PREF_BRANCH + PREF_MANIFEST_URI);
 
     this._checkForShutdown();
 
     this._refresh = false;
     try {
       let responseText = yield this._httpGetRequest(uri);
-      gLogger.trace("Experiments::_loadManifest() - responseText=\"" + responseText + "\"");
+      this._log.trace("_loadManifest() - responseText=\"" + responseText + "\"");
 
       if (this._shutdown) {
         return;
       }
 
       let data = JSON.parse(responseText);
       this._updateExperiments(data);
     } catch (e) {
-      gLogger.error("Experiments::_loadManifest - failure to fetch/parse manifest (continuing anyway): " + e);
+      this._log.error("_loadManifest - failure to fetch/parse manifest (continuing anyway): " + e);
     }
   },
 
   /**
    * Fetch an updated list of experiments and trigger experiment updates.
    * Do only use when experiments are enabled.
    *
    * @return Promise<>
    *         The promise is resolved when the manifest and experiment list is updated.
    */
   updateManifest: function () {
-    gLogger.trace("Experiments::updateManifest()");
+    this._log.trace("updateManifest()");
 
     if (!gExperimentsEnabled) {
       return Promise.reject(new Error("experiments are disabled"));
     }
 
     if (this._shutdown) {
       return Promise.reject(Error("uninit() alrady called"));
     }
 
     this._refresh = true;
     return this._run();
   },
 
   notify: function (timer) {
-    gLogger.trace("Experiments::notify()");
+    this._log.trace("notify()");
     this._checkForShutdown();
     return this._run();
   },
 
+  // START OF ADD-ON LISTENERS
+
   onDisabled: function (addon) {
-    gLogger.trace("Experiments::onDisabled() - addon id: " + addon.id);
+    this._log.trace("onDisabled() - addon id: " + addon.id);
     if (addon.id == this._pendingUninstall) {
       return;
     }
     let activeExperiment = this._getActiveExperiment();
     if (!activeExperiment || activeExperiment._addonId != addon.id) {
       return;
     }
     this.disableExperiment();
   },
 
   onUninstalled: function (addon) {
-    gLogger.trace("Experiments::onUninstalled() - addon id: " + addon.id);
+    this._log.trace("onUninstalled() - addon id: " + addon.id);
     if (addon.id == this._pendingUninstall) {
-      gLogger.trace("onUninstalled: matches pending uninstall");
+      this._log.trace("matches pending uninstall");
       return;
     }
     let activeExperiment = this._getActiveExperiment();
     if (!activeExperiment || activeExperiment._addonId != addon.id) {
       return;
     }
     this.disableExperiment();
   },
 
+  // END OF ADD-ON LISTENERS.
+
   _getExperimentByAddonId: function (addonId) {
     for (let [, entry] of this._experiments) {
       if (entry._addonId === addonId) {
         return entry;
       }
     }
 
     return null;
   },
 
   /*
    * Helper function to make HTTP GET requests. Returns a promise that is resolved with
    * the responseText when the request is complete.
    */
   _httpGetRequest: function (url) {
-    gLogger.trace("Experiments::httpGetRequest(" + url + ")");
+    this._log.trace("httpGetRequest(" + url + ")");
     let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
     try {
       xhr.open("GET", url);
     } catch (e) {
-      gLogger.error("Experiments::httpGetRequest() - Error opening request to " + url + ": " + e);
+      this._log.error("httpGetRequest() - Error opening request to " + url + ": " + e);
       return Promise.reject(new Error("Experiments - Error opening XHR for " + url));
     }
 
     let deferred = Promise.defer();
 
+    let log = this._log;
     xhr.onerror = function (e) {
-      gLogger.error("Experiments::httpGetRequest::onError() - Error making request to " + url + ": " + e.error);
+      log.error("httpGetRequest::onError() - Error making request to " + url + ": " + e.error);
       deferred.reject(new Error("Experiments - XHR error for " + url + " - " + e.error));
     };
 
     xhr.onload = function (event) {
       if (xhr.status !== 200 && xhr.state !== 0) {
-        gLogger.error("Experiments::httpGetRequest::onLoad() - Request to " + url + " returned status " + xhr.status);
+        log.error("httpGetRequest::onLoad() - Request to " + url + " returned status " + xhr.status);
         deferred.reject(new Error("Experiments - XHR status for " + url + " is " + xhr.status));
         return;
       }
 
       let certs = null;
       if (gPrefs.get(PREF_MANIFEST_CHECKCERT, true)) {
         certs = CertUtils.readCertPrefs(PREF_BRANCH + "manifest.certs.");
       }
       try {
         let allowNonBuiltin = !gPrefs.get(PREF_MANIFEST_REQUIREBUILTIN, true);
         CertUtils.checkCert(xhr.channel, allowNonBuiltin, certs);
       }
       catch (e) {
-        gLogger.error("Experiments: manifest fetch failed certificate checks", [e]);
+        log.error("manifest fetch failed certificate checks", [e]);
         deferred.reject(new Error("Experiments - manifest fetch failed certificate checks: " + e));
         return;
       }
 
       deferred.resolve(xhr.responseText);
     };
 
     if (xhr.channel instanceof Ci.nsISupportsPriority) {
@@ -705,48 +720,48 @@ Experiments.Experiments.prototype = {
   get _cacheFilePath() {
     return OS.Path.join(OS.Constants.Path.profileDir, FILE_CACHE);
   },
 
   /*
    * Part of the main task to save the cache to disk, called from _main.
    */
   _saveToCache: function* () {
-    gLogger.trace("Experiments::_saveToCache");
+    this._log.trace("_saveToCache");
     let path = this._cacheFilePath;
     let textData = JSON.stringify({
       version: CACHE_VERSION,
       data: [e[1].toJSON() for (e of this._experiments.entries())],
     });
 
     let encoder = new TextEncoder();
     let data = encoder.encode(textData);
     let options = { tmpPath: path + ".tmp", compression: "lz4" };
     yield OS.File.writeAtomic(path, data, options);
     this._dirty = false;
-    gLogger.debug("Experiments._saveToCache saved to " + path);
+    this._log.debug("_saveToCache saved to " + path);
   },
 
   /*
    * Task function, load the cached experiments manifest file from disk.
    */
   _loadFromCache: function*() {
-    gLogger.trace("Experiments::_loadFromCache");
+    this._log.trace("_loadFromCache");
     let path = this._cacheFilePath;
     try {
       let result = yield loadJSONAsync(path, { compression: "lz4" });
       this._populateFromCache(result);
     } catch (e if e instanceof OS.File.Error && e.becauseNoSuchFile) {
       // No cached manifest yet.
       this._experiments = new Map();
     }
   },
 
   _populateFromCache: function (data) {
-    gLogger.trace("Experiments::populateFromCache() - data: " + JSON.stringify(data));
+    this._log.trace("populateFromCache() - data: " + JSON.stringify(data));
 
     // If the user has a newer cache version than we can understand, we fail
     // hard; no experiments should be active in this older client.
     if (CACHE_VERSION !== data.version) {
       throw new Error("Experiments::_populateFromCache() - invalid cache version");
     }
 
     let experiments = new Map();
@@ -761,31 +776,31 @@ Experiments.Experiments.prototype = {
     this._experiments = experiments;
   },
 
   /*
    * Update the experiment entries from the experiments
    * array in the manifest
    */
   _updateExperiments: function (manifestObject) {
-    gLogger.trace("Experiments::_updateExperiments() - experiments: " + JSON.stringify(manifestObject));
+    this._log.trace("_updateExperiments() - experiments: " + JSON.stringify(manifestObject));
 
     if (manifestObject.version !== MANIFEST_VERSION) {
-      gLogger.warning("Experiments::updateExperiments() - unsupported version " + manifestObject.version);
+      this._log.warning("updateExperiments() - unsupported version " + manifestObject.version);
     }
 
     let experiments = new Map(); // The new experiments map
 
     // Collect new and updated experiments.
     for (let data of manifestObject.experiments) {
       let entry = this._experiments.get(data.id);
 
       if (entry) {
         if (!entry.updateFromManifestData(data)) {
-          gLogger.error("Experiments::updateExperiments() - Invalid manifest data for " + data.id);
+          this._log.error("updateExperiments() - Invalid manifest data for " + data.id);
           continue;
         }
       } else {
         entry = new Experiments.ExperimentEntry(this._policy);
         if (!entry.initFromManifestData(data)) {
           continue;
         }
       }
@@ -796,17 +811,17 @@ Experiments.Experiments.prototype = {
 
       experiments.set(entry.id, entry);
     }
 
     // Make sure we keep experiments that are or were running.
     // We remove them after KEEP_HISTORY_N_DAYS.
     for (let [id, entry] of this._experiments) {
       if (experiments.has(id) || !entry.startDate || entry.shouldDiscard()) {
-        gLogger.trace("Experiments::updateExperiments() - discarding entry for " + id);
+        this._log.trace("updateExperiments() - discarding entry for " + id);
         continue;
       }
 
       experiments.set(id, entry);
     }
 
     this._experiments = experiments;
     this._dirty = true;
@@ -815,73 +830,97 @@ Experiments.Experiments.prototype = {
   _getActiveExperiment: function () {
     let enabled = [experiment for ([,experiment] of this._experiments) if (experiment._enabled)];
 
     if (enabled.length == 1) {
       return enabled[0];
     }
 
     if (enabled.length > 1) {
-      gLogger.error("Experiments::getActiveExperimentId() - should not have more than 1 active experiment");
+      this._log.error("getActiveExperimentId() - should not have more than 1 active experiment");
       throw new Error("have more than 1 active experiment");
     }
 
     return null;
   },
 
   /**
    * Disable an experiment by id.
    * @param experimentId The id of the experiment.
    * @param userDisabled (optional) Whether this is disabled as a result of a user action.
    * @return Promise<> Promise that will get resolved once the task is done or failed.
    */
   disableExperiment: function (userDisabled=true) {
-    gLogger.trace("Experiments::disableExperiment()");
+    this._log.trace("disableExperiment()");
 
     this._terminateReason = userDisabled ? TELEMETRY_LOG.TERMINATION.USERDISABLED : TELEMETRY_LOG.TERMINATION.FROM_API;
     return this._run();
   },
 
   /*
    * Task function to check applicability of experiments, disable the active
    * experiment if needed and activate the first applicable candidate.
    */
   _evaluateExperiments: function*() {
-    gLogger.trace("Experiments::_evaluateExperiments");
+    this._log.trace("_evaluateExperiments");
 
     this._checkForShutdown();
 
+    // The first thing we do is reconcile our state against what's in the
+    // Addon Manager. It's possible that the Addon Manager knows of experiment
+    // add-ons that we don't. This could happen if an experiment gets installed
+    // when we're not listening or if there is a bug in our synchronization
+    // code.
+    //
+    // We have a few options of what to do with unknown experiment add-ons
+    // coming from the Addon Manager. Ideally, we'd convert these to
+    // ExperimentEntry instances and stuff them inside this._experiments.
+    // However, since ExperimentEntry contain lots of metadata from the
+    // manifest and trying to make up data could be error prone, it's safer
+    // to not try. Furthermore, if an experiment really did come from us, we
+    // should have some record of it. In the end, we decide to discard all
+    // knowledge for these unknown experiment add-ons.
+    let installedExperiments = yield installedExperimentAddons();
+    let expectedAddonIds = new Set([e._addonId for ([,e] of this._experiments)]);
+    let unknownAddons = [a for (a of installedExperiments) if (!expectedAddonIds.has(a.id))];
+    if (unknownAddons.length) {
+      this._log.warn("_evaluateExperiments() - unknown add-ons in AddonManager: " +
+                     [a.id for (a of unknownAddons)].join(", "));
+
+      yield uninstallAddons(unknownAddons);
+    }
+
     let activeExperiment = this._getActiveExperiment();
     let activeChanged = false;
     let now = this._policy.now();
 
     if (activeExperiment) {
       this._pendingUninstall = activeExperiment._addonId;
       try {
         let wasStopped;
         if (this._terminateReason) {
           yield activeExperiment.stop(this._terminateReason);
           wasStopped = true;
         } else {
           wasStopped = yield activeExperiment.maybeStop();
         }
         if (wasStopped) {
           this._dirty = true;
-          gLogger.debug("Experiments::evaluateExperiments() - stopped experiment "
+          this._log.debug("evaluateExperiments() - stopped experiment "
                         + activeExperiment.id);
           activeExperiment = null;
           activeChanged = true;
         } else if (activeExperiment.needsUpdate) {
-          gLogger.debug("Experiments::evaluateExperiments() - updating experiment "
+          this._log.debug("evaluateExperiments() - updating experiment "
                         + activeExperiment.id);
           try {
             yield activeExperiment.stop();
             yield activeExperiment.start();
           } catch (e) {
-            gLogger.error(e);
+            this._log.error(e);
             // On failure try the next experiment.
             activeExperiment = null;
           }
           this._dirty = true;
           activeChanged = true;
         }
       } finally {
         this._pendingUninstall = null;
@@ -905,17 +944,17 @@ Experiments.Experiments.prototype = {
           // Report this from here to avoid over-reporting.
           let desc = TELEMETRY_LOG.ACTIVATION;
           let data = [TELEMETRY_LOG.ACTIVATION.REJECTED, id];
           data = data.concat(reason);
           TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY, data);
         }
 
         if (applicable) {
-          gLogger.debug("Experiments::evaluateExperiments() - activating experiment " + id);
+          this._log.debug("evaluateExperiments() - activating experiment " + id);
           try {
             yield experiment.start();
             activeChanged = true;
             activeExperiment = experiment;
             this._dirty = true;
             break;
           } catch (e) {
             // On failure try the next experiment.
@@ -963,28 +1002,31 @@ Experiments.Experiments.prototype = {
       }
     }
 
     if (time === null) {
       // No schedule time found.
       return;
     }
 
-    gLogger.trace("Experiments::scheduleExperimentEvaluation() - scheduling for "+time+", now: "+now);
+    this._log.trace("scheduleExperimentEvaluation() - scheduling for "+time+", now: "+now);
     this._policy.oneshotTimer(this.notify, time - now, this, "_timer");
   },
 };
 
 
 /*
  * Represents a single experiment.
  */
 
 Experiments.ExperimentEntry = function (policy) {
   this._policy = policy || new Experiments.Policy();
+  this._log = Log.repository.getLoggerWithMessagePrefix(
+    "Browser.Experiments.Experiments",
+    "ExperimentEntry #" + gExperimentEntryCounter++ + "::");
 
   // Is this experiment running?
   this._enabled = false;
   // When this experiment was started, if ever.
   this._startDate = null;
   // When this experiment was ended, if ever.
   this._endDate = null;
   // The condition data from the manifest.
@@ -1107,17 +1149,17 @@ Experiments.ExperimentEntry.prototype = 
   /*
    * Initialize entry from the cache.
    * @param data The entry data from the cache.
    * @return boolean Whether initialization succeeded.
    */
   initFromCacheData: function (data) {
     for (let key of this.SERIALIZE_KEYS) {
       if (!(key in data) && !this.DATE_KEYS.has(key)) {
-        gLogger.error("ExperimentEntry::initFromCacheData() - missing required key " + key);
+        this._log.error("initFromCacheData() - missing required key " + key);
         return false;
       }
     };
 
     if (!this._isManifestDataValid(data._manifestData)) {
       return false;
     }
 
@@ -1214,19 +1256,19 @@ Experiments.ExperimentEntry.prototype = 
     let channel = this._policy.updatechannel();
     let data = this._manifestData;
 
     let now = this._policy.now() / 1000; // The manifest times are in seconds.
     let minActive = MIN_EXPERIMENT_ACTIVE_SECONDS;
     let maxActive = data.maxActiveSeconds || 0;
     let startSec = (this.startDate || 0) / 1000;
 
-    gLogger.trace("ExperimentEntry::isApplicable() - now=" + now
-                  + ", randomValue=" + this._randomValue
-                  + ", data=" + JSON.stringify(this._manifestData));
+    this._log.trace("isApplicable() - now=" + now
+                    + ", randomValue=" + this._randomValue
+                    + ", data=" + JSON.stringify(this._manifestData));
 
     // Not applicable if it already ran.
 
     if (!this.enabled && this._endDate) {
       return Promise.reject(["was-active"]);
     }
 
     // Define and run the condition checks.
@@ -1268,67 +1310,67 @@ Experiments.ExperimentEntry.prototype = 
         condition: () => !data.minVersion || versionCmp.compare(app.version, data.minVersion) >= 0 },
       { name: "maxVersion",
         condition: () => !data.maxVersion || versionCmp.compare(app.version, data.maxVersion) <= 0 },
     ];
 
     for (let check of simpleChecks) {
       let result = check.condition();
       if (!result) {
-        gLogger.debug("ExperimentEntry::isApplicable() - id="
-                      + data.id + " - test '" + check.name + "' failed");
+        this._log.debug("isApplicable() - id="
+                        + data.id + " - test '" + check.name + "' failed");
         return Promise.reject([check.name]);
       }
     }
 
     if (data.jsfilter) {
       return this._runFilterFunction(data.jsfilter);
     }
 
     return Promise.resolve(true);
   },
 
   /*
    * Run the jsfilter function from the manifest in a sandbox and return the
    * result (forced to boolean).
    */
   _runFilterFunction: function (jsfilter) {
-    gLogger.trace("ExperimentEntry::runFilterFunction() - filter: " + jsfilter);
+    this._log.trace("runFilterFunction() - filter: " + jsfilter);
 
     return Task.spawn(function ExperimentEntry_runFilterFunction_task() {
       const nullprincipal = Cc["@mozilla.org/nullprincipal;1"].createInstance(Ci.nsIPrincipal);
       let options = {
         sandboxName: "telemetry experiments jsfilter sandbox",
         wantComponents: false,
       };
 
       let sandbox = Cu.Sandbox(nullprincipal);
       let context = {};
       context.healthReportPayload = yield this._policy.healthReportPayload();
       context.telemetryPayload    = yield this._policy.telemetryPayload();
 
       try {
         Cu.evalInSandbox(jsfilter, sandbox);
       } catch (e) {
-        gLogger.error("ExperimentEntry::runFilterFunction() - failed to eval jsfilter: " + e.message);
+        this._log.error("runFilterFunction() - failed to eval jsfilter: " + e.message);
         throw ["jsfilter-evalfailed"];
       }
 
       // You can't insert arbitrarily complex objects into a sandbox, so
       // we serialize everything through JSON.
       sandbox._hr = JSON.stringify(yield this._policy.healthReportPayload());
       Object.defineProperty(sandbox, "_t",
         { get: () => JSON.stringify(this._policy.telemetryPayload()) });
 
       let result = false;
       try {
         result = !!Cu.evalInSandbox("filter({healthReportPayload: JSON.parse(_hr), telemetryPayload: JSON.parse(_t)})", sandbox);
       }
       catch (e) {
-        gLogger.debug("ExperimentEntry::runFilterFunction() - filter function failed: "
+        this._log.debug("runFilterFunction() - filter function failed: "
                       + e.message + ", " + e.stack);
         throw ["jsfilter-threw", e.message];
       }
       finally {
         Cu.nukeSandbox(sandbox);
       }
 
       if (!result) {
@@ -1339,77 +1381,80 @@ Experiments.ExperimentEntry.prototype = 
     }.bind(this));
   },
 
   /*
    * Start running the experiment.
    * @return Promise<> Resolved when the operation is complete.
    */
   start: function () {
-    gLogger.trace("ExperimentEntry::start() for " + this.id);
+    this._log.trace("start() for " + this.id);
 
     return Task.spawn(function* ExperimentEntry_start_task() {
       let addons = yield installedExperimentAddons();
       if (addons.length > 0) {
-        gLogger.error("ExperimentEntry::start() - there are already "
-                      + addons.length + " experiment addons installed");
+        this._log.error("start() - there are already "
+                        + addons.length + " experiment addons installed");
         yield uninstallAddons(addons);
       }
 
       yield this._installAddon();
     }.bind(this));
   },
 
   // Async install of the addon for this experiment, part of the start task above.
   _installAddon: function* () {
     let deferred = Promise.defer();
 
     let install = yield addonInstallForURL(this._manifestData.xpiURL,
                                            this._manifestData.xpiHash);
     let failureHandler = (install, handler) => {
       let message = "AddonInstall " + handler + " for " + this.id + ", state=" +
                    (install.state || "?") + ", error=" + install.error;
-      gLogger.error("ExperimentEntry::_installAddon() - " + message);
+      this._log.error("_installAddon() - " + message);
       this._failedStart = true;
 
       TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY,
                       [TELEMETRY_LOG.ACTIVATION.INSTALL_FAILURE, this.id]);
 
       deferred.reject(new Error(message));
     };
 
     let listener = {
       onDownloadEnded: install => {
-        gLogger.trace("ExperimentEntry::_installAddon() - onDownloadEnded for " + this.id);
+        this._log.trace("_installAddon() - onDownloadEnded for " + this.id);
 
         if (install.existingAddon) {
-          gLogger.warn("ExperimentEntry::_installAddon() - onDownloadEnded, addon already installed");
+          this._log.warn("_installAddon() - onDownloadEnded, addon already installed");
         }
 
         if (install.addon.type !== "experiment") {
-          gLogger.error("ExperimentEntry::_installAddon() - onDownloadEnded, wrong addon type");
+          this._log.error("_installAddon() - onDownloadEnded, wrong addon type");
           install.cancel();
         }
       },
 
       onInstallStarted: install => {
-        gLogger.trace("ExperimentEntry::_installAddon() - onInstallStarted for " + this.id);
+        this._log.trace("_installAddon() - onInstallStarted for " + this.id);
 
         if (install.existingAddon) {
-          gLogger.warn("ExperimentEntry::_installAddon() - onInstallStarted, addon already installed");
+          this._log.warn("_installAddon() - onInstallStarted, addon already installed");
         }
 
         if (install.addon.type !== "experiment") {
-          gLogger.error("ExperimentEntry::_installAddon() - onInstallStarted, wrong addon type");
+          this._log.error("_installAddon() - onInstallStarted, wrong addon type");
           return false;
         }
+
+        // Experiment add-ons default to userDisabled = true.
+        install.addon.userDisabled = false;
       },
 
       onInstallEnded: install => {
-        gLogger.trace("ExperimentEntry::_installAddon() - install ended for " + this.id);
+        this._log.trace("_installAddon() - install ended for " + this.id);
         this._lastChangedDate = this._policy.now();
         this._startDate = this._policy.now();
         this._enabled = true;
 
         TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY,
                        [TELEMETRY_LOG.ACTIVATION.ACTIVATED, this.id]);
 
         let addon = install.addon;
@@ -1436,34 +1481,34 @@ Experiments.ExperimentEntry.prototype = 
   /*
    * Stop running the experiment if it is active.
    * @param terminationKind (optional) The termination kind, e.g. USERDISABLED or EXPIRED.
    * @param terminationReason (optional) The termination reason details for
    *                          termination kind RECHECK.
    * @return Promise<> Resolved when the operation is complete.
    */
   stop: function (terminationKind, terminationReason) {
-    gLogger.trace("ExperimentEntry::stop() - id=" + this.id + ", terminationKind=" + terminationKind);
+    this._log.trace("stop() - id=" + this.id + ", terminationKind=" + terminationKind);
     if (!this._enabled) {
-      gLogger.warning("ExperimentEntry::stop() - experiment not enabled: " + id);
+      this._log.warning("stop() - experiment not enabled: " + id);
       return Promise.reject();
     }
 
     this._enabled = false;
     let deferred = Promise.defer();
     let updateDates = () => {
       let now = this._policy.now();
       this._lastChangedDate = now;
       this._endDate = now;
     };
 
     AddonManager.getAddonByID(this._addonId, addon => {
       if (!addon) {
         let message = "could not get Addon for " + this.id;
-        gLogger.warn("ExperimentEntry::stop() - " + message);
+        this._log.warn("stop() - " + message);
         updateDates();
         deferred.resolve();
         return;
       }
 
       updateDates();
       this._logTermination(terminationKind, terminationReason);
       deferred.resolve(uninstallAddons([addon]));
@@ -1473,17 +1518,17 @@ Experiments.ExperimentEntry.prototype = 
   },
 
   _logTermination: function (terminationKind, terminationReason) {
     if (terminationKind === undefined) {
       return;
     }
 
     if (!(terminationKind in TELEMETRY_LOG.TERMINATION)) {
-      gLogger.warn("ExperimentEntry::stop() - unknown terminationKind " + terminationKind);
+      this._log.warn("stop() - unknown terminationKind " + terminationKind);
       return;
     }
 
     let data = [terminationKind, this.id];
     if (terminationReason) {
       data = data.concat(terminationReason);
     }
 
@@ -1491,17 +1536,17 @@ Experiments.ExperimentEntry.prototype = 
   },
 
   /*
    * Stop if experiment stop criteria are met.
    * @return Promise<boolean> Resolved when done stopping or checking,
    *                          the value indicates whether it was stopped.
    */
   maybeStop: function () {
-    gLogger.trace("ExperimentEntry::maybeStop()");
+    this._log.trace("maybeStop()");
 
     return Task.spawn(function ExperimentEntry_maybeStop_task() {
       let result = yield this._shouldStop();
       if (result.shouldStop) {
         let expireReasons = ["endTime", "maxActiveSeconds"];
         if (expireReasons.indexOf(result.reason[0]) != -1) {
           yield this.stop(TELEMETRY_LOG.TERMINATION.EXPIRED);
         } else {
@@ -1558,29 +1603,29 @@ Experiments.ExperimentEntry.prototype = 
 
     return 1000 * this._manifestData.startTime;
   },
 
   /*
    * Perform sanity checks on the experiment data.
    */
   _isManifestDataValid: function (data) {
-    gLogger.trace("ExperimentEntry::isManifestDataValid() - data: " + JSON.stringify(data));
+    this._log.trace("isManifestDataValid() - data: " + JSON.stringify(data));
 
     for (let key of this.MANIFEST_REQUIRED_FIELDS) {
       if (!(key in data)) {
-        gLogger.error("ExperimentEntry::isManifestDataValid() - missing required key: " + key);
+        this._log.error("isManifestDataValid() - missing required key: " + key);
         return false;
       }
     }
 
     for (let key in data) {
       if (!this.MANIFEST_OPTIONAL_FIELDS.has(key) &&
           !this.MANIFEST_REQUIRED_FIELDS.has(key)) {
-        gLogger.error("ExperimentEntry::isManifestDataValid() - unknown key: " + key);
+        this._log.error("isManifestDataValid() - unknown key: " + key);
         return false;
       }
     }
 
     return true;
   },
 };
 
--- a/browser/experiments/test/xpcshell/head.js
+++ b/browser/experiments/test/xpcshell/head.js
@@ -100,16 +100,28 @@ function defineNow(policy, time) {
 function futureDate(date, offset) {
   return new Date(date.getTime() + offset);
 }
 
 function dateToSeconds(date) {
   return date.getTime() / 1000;
 }
 
+let gGlobalScope = this;
+function loadAddonManager() {
+  let ns = {};
+  Cu.import("resource://gre/modules/Services.jsm", ns);
+  let head = "../../../../toolkit/mozapps/extensions/test/xpcshell/head_addons.js";
+  let file = do_get_file(head);
+  let uri = ns.Services.io.newFileURI(file);
+  ns.Services.scriptloader.loadSubScript(uri.spec, gGlobalScope);
+  createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+  startupManager();
+}
+
 // Install addon and return a Promise<boolean> that is
 // resolve with true on success, false otherwise.
 function installAddon(url, hash) {
   let deferred = Promise.defer();
   let success = () => deferred.resolve(true);
   let fail = () => deferred.resolve(false);
   let listener = {
     onDownloadCancelled: fail,
@@ -155,16 +167,24 @@ function uninstallAddon(id) {
 
     AddonManager.addAddonListener(listener);
     addon.uninstall();
   });
 
   return deferred.promise;
 }
 
+function getExperimentAddons() {
+  let deferred = Promise.defer();
+
+  AddonManager.getAddonsByTypes(["experiment"], deferred.resolve);
+
+  return deferred.promise;
+}
+
 function createAppInfo(optionsIn) {
   const XULAPPINFO_CONTRACTID = "@mozilla.org/xre/app-info;1";
   const XULAPPINFO_CID = Components.ID("{c763b610-9d49-455a-bbd2-ede71682a1ac}");
 
   let options = optionsIn || {};
   let id = options.id || "xpcshell@tests.mozilla.org";
   let name = options.name || "XPCShell";
   let version = options.version || "1.0";
--- a/browser/experiments/test/xpcshell/test_activate.js
+++ b/browser/experiments/test/xpcshell/test_activate.js
@@ -16,28 +16,16 @@ const SEC_IN_ONE_DAY  = 24 * 60 * 60;
 const MS_IN_ONE_DAY   = SEC_IN_ONE_DAY * 1000;
 
 let gProfileDir = null;
 let gHttpServer = null;
 let gHttpRoot   = null;
 let gReporter   = null;
 let gPolicy     = null;
 
-let gGlobalScope = this;
-function loadAddonManager() {
-  let ns = {};
-  Cu.import("resource://gre/modules/Services.jsm", ns);
-  let head = "../../../../toolkit/mozapps/extensions/test/xpcshell/head_addons.js";
-  let file = do_get_file(head);
-  let uri = ns.Services.io.newFileURI(file);
-  ns.Services.scriptloader.loadSubScript(uri.spec, gGlobalScope);
-  createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
-  startupManager();
-}
-
 function ManifestEntry(data) {
   this.id        = data.id        || EXPERIMENT1_ID;
   this.xpiURL    = data.xpiURL    || gHttpRoot + EXPERIMENT1_XPI_NAME;
   this.xpiHash   = data.xpiHash   || EXPERIMENT1_XPI_SHA1;
   this.appName   = data.appName   || ["XPCShell"];
   this.channel   = data.appName   || ["nightly"];
   this.startTime = data.startTime || new Date(2010, 0, 1, 12).getTime() / 1000;
   this.endTime   = data.endTime   || new Date(9001, 0, 1, 12).getTime() / 1000;
--- a/browser/experiments/test/xpcshell/test_api.js
+++ b/browser/experiments/test/xpcshell/test_api.js
@@ -24,28 +24,16 @@ let gHttpServer          = null;
 let gHttpRoot            = null;
 let gDataRoot            = null;
 let gReporter            = null;
 let gPolicy              = null;
 let gManifestObject      = null;
 let gManifestHandlerURI  = null;
 let gTimerScheduleOffset = -1;
 
-let gGlobalScope = this;
-function loadAddonManager() {
-  let ns = {};
-  Cu.import("resource://gre/modules/Services.jsm", ns);
-  let head = "../../../../toolkit/mozapps/extensions/test/xpcshell/head_addons.js";
-  let file = do_get_file(head);
-  let uri = ns.Services.io.newFileURI(file);
-  ns.Services.scriptloader.loadSubScript(uri.spec, gGlobalScope);
-  createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
-  startupManager();
-}
-
 function run_test() {
   run_next_test();
 }
 
 add_task(function* test_setup() {
   loadAddonManager();
   gProfileDir = do_get_profile();
 
@@ -1347,8 +1335,37 @@ add_task(function* test_unexpectedUninst
   Assert.equal(list[0].active, false, "Experiment 1 should not be active anymore.");
 
   // Cleanup.
 
   Services.obs.removeObserver(observer, OBSERVER_TOPIC);
   yield experiments.uninit();
   yield removeCacheFile();
 });
+
+// If the Addon Manager knows of an experiment that we don't, it should get
+// uninstalled.
+add_task(function* testUnknownExperimentsUninstalled() {
+  let experiments = new Experiments.Experiments(gPolicy);
+
+  let addons = yield getExperimentAddons();
+  Assert.equal(addons.length, 0, "Precondition: No experiment add-ons are present.");
+  yield installAddon(gDataRoot + EXPERIMENT1_XPI_NAME, EXPERIMENT1_XPI_SHA1);
+  addons = yield getExperimentAddons();
+  Assert.equal(addons.length, 1, "Experiment 1 installed via AddonManager");
+
+  // Simulate no known experiments.
+  gManifestObject = {
+    "version": 1,
+    experiments: [],
+  };
+
+  yield experiments.updateManifest();
+  let fromManifest = yield experiments.getExperiments();
+  Assert.equal(fromManifest.length, 0, "No experiments known in manifest.");
+
+  // And the unknown add-on should be gone.
+  addons = yield getExperimentAddons();
+  Assert.equal(addons.length, 0, "Experiment 1 was uninstalled.");
+
+  yield experiments.uninit();
+  yield removeCacheFile();
+});
--- a/browser/experiments/test/xpcshell/test_cache.js
+++ b/browser/experiments/test/xpcshell/test_cache.js
@@ -24,28 +24,16 @@ let gHttpServer          = null;
 let gHttpRoot            = null;
 let gDataRoot            = null;
 let gReporter            = null;
 let gPolicy              = null;
 let gManifestObject      = null;
 let gManifestHandlerURI  = null;
 let gTimerScheduleOffset = -1;
 
-let gGlobalScope = this;
-function loadAddonManager() {
-  let ns = {};
-  Cu.import("resource://gre/modules/Services.jsm", ns);
-  let head = "../../../../toolkit/mozapps/extensions/test/xpcshell/head_addons.js";
-  let file = do_get_file(head);
-  let uri = ns.Services.io.newFileURI(file);
-  ns.Services.scriptloader.loadSubScript(uri.spec, gGlobalScope);
-  createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
-  startupManager();
-}
-
 function run_test() {
   run_next_test();
 }
 
 add_task(function* test_setup() {
   loadAddonManager();
   gProfileDir = do_get_profile();
   yield removeCacheFile();
--- a/browser/experiments/test/xpcshell/test_fetch.js
+++ b/browser/experiments/test/xpcshell/test_fetch.js
@@ -15,17 +15,17 @@ const PREF_MANIFEST_URI        = "experi
 
 
 let gProfileDir = null;
 let gHttpServer = null;
 let gHttpRoot   = null;
 let gPolicy     = new Experiments.Policy();
 
 function run_test() {
-  createAppInfo();
+  loadAddonManager();
   gProfileDir = do_get_profile();
 
   gHttpServer = new HttpServer();
   gHttpServer.start(-1);
   let port = gHttpServer.identity.primaryPort;
   gHttpRoot = "http://localhost:" + port + "/";
   gHttpServer.registerDirectory("/", do_get_cwd());
   do_register_cleanup(() => gHttpServer.stop(() => {}));
--- a/browser/experiments/test/xpcshell/test_healthreport.js
+++ b/browser/experiments/test/xpcshell/test_healthreport.js
@@ -15,21 +15,25 @@ function getStorageAndProvider(name) {
     let provider = new ExperimentsProvider();
     yield provider.init(storage);
 
     return [storage, provider];
   });
 }
 
 function run_test() {
+  run_next_test();
+}
+
+add_test(function setup() {
   do_get_profile();
   initTestLogging();
 
   run_next_test();
-}
+});
 
 add_task(function test_constructor() {
   let provider = new ExperimentsProvider();
 });
 
 add_task(function* test_init() {
   let storage = yield Metrics.Storage("init");
   let provider = new ExperimentsProvider();
new file mode 100644
--- /dev/null
+++ b/browser/locales/en-US/chrome/browser/devtools/canvasdebugger.dtd
@@ -0,0 +1,45 @@
+<!-- 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/. -->
+
+<!-- LOCALIZATION NOTE : FILE This file contains the Debugger strings -->
+<!-- LOCALIZATION NOTE : FILE Do not translate commandkey -->
+
+<!-- LOCALIZATION NOTE : FILE The correct localization of this file might be to
+  - keep it in English, or another language commonly spoken among web developers.
+  - You want to make that choice consistent across the developer tools.
+  - A good criteria is the language in which you'd find the best
+  - documentation on web development on the web. -->
+
+<!-- LOCALIZATION NOTE (canvasDebuggerUI.reloadNotice1): This is the label shown
+  -  on the button that triggers a page refresh. -->
+<!ENTITY canvasDebuggerUI.reloadNotice1   "Reload">
+
+<!-- LOCALIZATION NOTE (canvasDebuggerUI.reloadNotice2): This is the label shown
+  -  along with the button that triggers a page refresh. -->
+<!ENTITY canvasDebuggerUI.reloadNotice2   "the page to be able to debug &lt;canvas&gt; contexts.">
+
+<!-- LOCALIZATION NOTE (canvasDebuggerUI.emptyNotice1/2): This is the label shown
+  -  in the call list view when empty. -->
+<!ENTITY canvasDebuggerUI.emptyNotice1    "Click on the">
+<!ENTITY canvasDebuggerUI.emptyNotice2    "button to record an animation frame's call stack.">
+
+<!-- LOCALIZATION NOTE (canvasDebuggerUI.reloadNotice1): This is the label shown
+  -  in the call list view while loading a snapshot. -->
+<!ENTITY canvasDebuggerUI.importNotice    "Loading…">
+
+<!-- LOCALIZATION NOTE (canvasDebuggerUI.recordSnapshot): This string is displayed
+  -  on a button that starts a new snapshot. -->
+<!ENTITY canvasDebuggerUI.recordSnapshot.tooltip "Record the next frame in the animation loop.">
+
+<!-- LOCALIZATION NOTE (canvasDebuggerUI.importSnapshot): This string is displayed
+  -  on a button that opens a dialog to import a saved snapshot data file. -->
+<!ENTITY canvasDebuggerUI.importSnapshot "Import…">
+
+<!-- LOCALIZATION NOTE (canvasDebuggerUI.clearSnapshots): This string is displayed
+  -  on a button that remvoes all the snapshots. -->
+<!ENTITY canvasDebuggerUI.clearSnapshots "Clear">
+
+<!-- LOCALIZATION NOTE (canvasDebuggerUI.searchboxPlaceholder): This string is displayed
+  -  as a placeholder of the search box that filters the calls list. -->
+<!ENTITY canvasDebuggerUI.searchboxPlaceholder "Filter calls">
new file mode 100644
--- /dev/null
+++ b/browser/locales/en-US/chrome/browser/devtools/canvasdebugger.properties
@@ -0,0 +1,74 @@
+# 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/.
+
+# LOCALIZATION NOTE These strings are used inside the Canvas Debugger
+# which is available from the Web Developer sub-menu -> 'Canvas'.
+# The correct localization of this file might be to keep it in
+# English, or another language commonly spoken among web developers.
+# You want to make that choice consistent across the developer tools.
+# A good criteria is the language in which you'd find the best
+# documentation on web development on the web.
+
+# LOCALIZATION NOTE (ToolboxCanvasDebugger.label):
+# This string is displayed in the title of the tab when the Shader Editor is
+# displayed inside the developer tools window and in the Developer Tools Menu.
+ToolboxCanvasDebugger.label=Canvas
+
+# LOCALIZATION NOTE (ToolboxCanvasDebugger.tooltip):
+# This string is displayed in the tooltip of the tab when the Shader Editor is
+# displayed inside the developer tools window.
+ToolboxCanvasDebugger.tooltip=Tools to inspect and debug <canvas> contexts
+
+# LOCALIZATION NOTE (noSnapshotsText): The text to display in the snapshots menu
+# when there are no recorded snapshots yet.
+noSnapshotsText=There are no snapshots yet.
+
+# LOCALIZATION NOTE (snapshotsList.itemLabel):
+# This string is displayed in the snapshots list of the Canvas Debugger,
+# identifying a set of function calls of a recorded animation frame.
+snapshotsList.itemLabel=Snapshot #%S
+
+# LOCALIZATION NOTE (snapshotsList.loadingLabel):
+# This string is displayed in the snapshots list of the Canvas Debugger,
+# for an item that has not finished loading.
+snapshotsList.loadingLabel=Loading…
+
+# LOCALIZATION NOTE (snapshotsList.saveLabel):
+# This string is displayed in the snapshots list of the Canvas Debugger,
+# for saving an item to disk.
+snapshotsList.saveLabel=Save
+
+# LOCALIZATION NOTE (snapshotsList.savingLabel):
+# This string is displayed in the snapshots list of the Canvas Debugger,
+# while saving an item to disk.
+snapshotsList.savingLabel=Saving…
+
+# LOCALIZATION NOTE (snapshotsList.loadedLabel):
+# This string is displayed in the snapshots list of the Canvas Debugger,
+# for an item which was loaded from disk
+snapshotsList.loadedLabel=Loaded from disk
+
+# LOCALIZATION NOTE (snapshotsList.saveDialogTitle):
+# This string is displayed as a title for saving a snapshot to disk.
+snapshotsList.saveDialogTitle=Save animation frame snapshot…
+
+# LOCALIZATION NOTE (snapshotsList.saveDialogJSONFilter):
+# This string is displayed as a filter for saving a snapshot to disk.
+snapshotsList.saveDialogJSONFilter=JSON Files
+
+# LOCALIZATION NOTE (snapshotsList.saveDialogAllFilter):
+# This string is displayed as a filter for saving a snapshot to disk.
+snapshotsList.saveDialogAllFilter=All Files
+
+# LOCALIZATION NOTE (snapshotsList.drawCallsLabel):
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# This string is displayed in the snapshots list of the Canvas Debugger,
+# as a generic description about how many draw calls were made.
+snapshotsList.drawCallsLabel=#1 draw;#1 draws
+
+# LOCALIZATION NOTE (snapshotsList.functionCallsLabel):
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# This string is displayed in the snapshots list of the Canvas Debugger,
+# as a generic description about how many function calls were made in total.
+snapshotsList.functionCallsLabel=#1 call;#1 calls
--- a/browser/locales/jar.mn
+++ b/browser/locales/jar.mn
@@ -27,16 +27,18 @@
     locale/browser/customizableui/customizableWidgets.properties (%chrome/browser/customizableui/customizableWidgets.properties)
     locale/browser/devtools/appcacheutils.properties  (%chrome/browser/devtools/appcacheutils.properties)
     locale/browser/devtools/debugger.dtd              (%chrome/browser/devtools/debugger.dtd)
     locale/browser/devtools/debugger.properties       (%chrome/browser/devtools/debugger.properties)
     locale/browser/devtools/netmonitor.dtd            (%chrome/browser/devtools/netmonitor.dtd)
     locale/browser/devtools/netmonitor.properties     (%chrome/browser/devtools/netmonitor.properties)
     locale/browser/devtools/shadereditor.dtd          (%chrome/browser/devtools/shadereditor.dtd)
     locale/browser/devtools/shadereditor.properties   (%chrome/browser/devtools/shadereditor.properties)
+    locale/browser/devtools/canvasdebugger.dtd        (%chrome/browser/devtools/canvasdebugger.dtd)
+    locale/browser/devtools/canvasdebugger.properties (%chrome/browser/devtools/canvasdebugger.properties)
     locale/browser/devtools/gcli.properties           (%chrome/browser/devtools/gcli.properties)
     locale/browser/devtools/gclicommands.properties   (%chrome/browser/devtools/gclicommands.properties)
     locale/browser/devtools/webconsole.properties     (%chrome/browser/devtools/webconsole.properties)
     locale/browser/devtools/inspector.properties      (%chrome/browser/devtools/inspector.properties)
     locale/browser/devtools/tilt.properties           (%chrome/browser/devtools/tilt.properties)
     locale/browser/devtools/scratchpad.properties     (%chrome/browser/devtools/scratchpad.properties)
     locale/browser/devtools/scratchpad.dtd            (%chrome/browser/devtools/scratchpad.dtd)
     locale/browser/devtools/styleeditor.properties    (%chrome/browser/devtools/styleeditor.properties)
new file mode 100644
--- /dev/null
+++ b/browser/themes/linux/devtools/canvasdebugger.css
@@ -0,0 +1,5 @@
+/* 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/. */
+
+%include ../../shared/devtools/canvasdebugger.inc.css
--- a/browser/themes/linux/jar.mn
+++ b/browser/themes/linux/jar.mn
@@ -204,16 +204,17 @@ browser.jar:
   skin/classic/browser/devtools/editor-breakpoint.png  (devtools/editor-breakpoint.png)
   skin/classic/browser/devtools/editor-debug-location.png (devtools/editor-debug-location.png)
   skin/classic/browser/devtools/breadcrumbs-divider@2x.png      (../shared/devtools/images/breadcrumbs-divider@2x.png)
   skin/classic/browser/devtools/breadcrumbs-scrollbutton.png    (../shared/devtools/images/breadcrumbs-scrollbutton.png)
   skin/classic/browser/devtools/breadcrumbs-scrollbutton@2x.png (../shared/devtools/images/breadcrumbs-scrollbutton@2x.png)
 * skin/classic/browser/devtools/splitview.css         (../shared/devtools/splitview.css)
   skin/classic/browser/devtools/styleeditor.css       (../shared/devtools/styleeditor.css)
 * skin/classic/browser/devtools/shadereditor.css      (devtools/shadereditor.css)
+* skin/classic/browser/devtools/canvasdebugger.css    (devtools/canvasdebugger.css)
 * skin/classic/browser/devtools/debugger.css          (devtools/debugger.css)
 * skin/classic/browser/devtools/profiler.css          (devtools/profiler.css)
 * skin/classic/browser/devtools/netmonitor.css        (devtools/netmonitor.css)
 * skin/classic/browser/devtools/scratchpad.css        (devtools/scratchpad.css)
   skin/classic/browser/devtools/magnifying-glass.png        (../shared/devtools/images/magnifying-glass.png)
   skin/classic/browser/devtools/magnifying-glass@2x.png     (../shared/devtools/images/magnifying-glass@2x.png)
   skin/classic/browser/devtools/magnifying-glass-light.png  (../shared/devtools/images/magnifying-glass-light.png)
   skin/classic/browser/devtools/magnifying-glass-light@2x.png (../shared/devtools/images/magnifying-glass-light@2x.png)
new file mode 100644
--- /dev/null
+++ b/browser/themes/osx/devtools/canvasdebugger.css
@@ -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/. */
+
+%include ../shared.inc
+%include ../../shared/devtools/canvasdebugger.inc.css
--- a/browser/themes/osx/jar.mn
+++ b/browser/themes/osx/jar.mn
@@ -325,16 +325,17 @@ browser.jar:
   skin/classic/browser/devtools/webconsole_networkpanel.css     (devtools/webconsole_networkpanel.css)
   skin/classic/browser/devtools/webconsole.png                  (devtools/webconsole.png)
   skin/classic/browser/devtools/breadcrumbs-divider@2x.png      (../shared/devtools/images/breadcrumbs-divider@2x.png)
   skin/classic/browser/devtools/breadcrumbs-scrollbutton.png    (../shared/devtools/images/breadcrumbs-scrollbutton.png)
   skin/classic/browser/devtools/breadcrumbs-scrollbutton@2x.png (../shared/devtools/images/breadcrumbs-scrollbutton@2x.png)
 * skin/classic/browser/devtools/splitview.css               (../shared/devtools/splitview.css)
   skin/classic/browser/devtools/styleeditor.css             (../shared/devtools/styleeditor.css)
 * skin/classic/browser/devtools/shadereditor.css            (devtools/shadereditor.css)
+* skin/classic/browser/devtools/canvasdebugger.css          (devtools/canvasdebugger.css)
 * skin/classic/browser/devtools/debugger.css                (devtools/debugger.css)
 * skin/classic/browser/devtools/profiler.css                (devtools/profiler.css)
 * skin/classic/browser/devtools/netmonitor.css              (devtools/netmonitor.css)
 * skin/classic/browser/devtools/scratchpad.css              (devtools/scratchpad.css)
   skin/classic/browser/devtools/magnifying-glass.png        (../shared/devtools/images/magnifying-glass.png)
   skin/classic/browser/devtools/magnifying-glass@2x.png     (../shared/devtools/images/magnifying-glass@2x.png)
   skin/classic/browser/devtools/magnifying-glass-light.png  (../shared/devtools/images/magnifying-glass-light.png)
   skin/classic/browser/devtools/magnifying-glass-light@2x.png (../shared/devtools/images/magnifying-glass-light@2x.png)
new file mode 100644
--- /dev/null
+++ b/browser/themes/shared/devtools/canvasdebugger.inc.css
@@ -0,0 +1,501 @@
+/* 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/. */
+
+%filter substitution
+%define darkCheckerboardBackground #000
+%define lightCheckerboardBackground #fff
+%define checkerboardCell rgba(128,128,128,0.2)
+%define checkerboardPattern linear-gradient(45deg, @checkerboardCell@ 25%, transparent 25%, transparent 75%, @checkerboardCell@ 75%, @checkerboardCell@), linear-gradient(45deg, @checkerboardCell@ 25%, transparent 25%, transparent 75%, @checkerboardCell@ 75%, @checkerboardCell@)
+%define gutterWidth 3em
+%define gutterPaddingStart 22px
+
+/* Reload and waiting notices */
+
+.notice-container {
+  margin-top: -50vh;
+  font-size: 120%;
+}
+
+.theme-dark .notice-container {
+  background: url(background-noise-toolbar.png), #343c45; /* Toolbars */
+  color: #f5f7fa; /* Light foreground text */
+}
+
+.theme-light .notice-container {
+  background: url(background-noise-toolbar.png), #f0f1f2; /* Toolbars */
+  color: #585959; /* Grey foreground text */
+}
+
+#reload-notice > button {
+  min-height: 2em;
+}
+
+#empty-notice > button {
+  min-width: 30px;
+  min-height: 28px;
+  margin: 0;
+  list-style-image: url(profiler-stopwatch.png);
+  -moz-image-region: rect(0px,16px,16px,0px);
+}
+
+#empty-notice > button .button-text {
+  display: none;
+}
+
+.theme-dark #import-notice {
+  font-size: 250%;
+  color: rgba(255,255,255,0.2);
+}
+
+.theme-light #import-notice {
+  font-size: 250%;
+  color: rgba(0,0,0,0.2);
+}
+
+/* Snapshots pane */
+
+#snapshots-pane > tabs {
+  -moz-border-end: 1px solid;
+}
+
+#snapshots-pane .devtools-toolbar {
+  -moz-border-end: 1px solid;
+}
+
+.theme-dark #snapshots-pane > tabs,
+.theme-dark #snapshots-pane .devtools-toolbar {
+  -moz-border-end-color: black; /* Match the splitter color. */
+}
+
+.theme-light #snapshots-pane > tabs,
+.theme-light #snapshots-pane .devtools-toolbar {
+  -moz-border-end-color: #aaa; /* Match the splitter color. */
+}
+
+#record-snapshot {
+  list-style-image: url("chrome://browser/skin/devtools/profiler-stopwatch.png");
+  -moz-image-region: rect(0px,16px,16px,0px);
+}
+
+#record-snapshot[checked] {
+  -moz-image-region: rect(0px,32px,16px,16px);
+}
+
+/* Snapshots items */
+
+.snapshot-item-thumbnail {
+  image-rendering: -moz-crisp-edges;
+  background-image: @checkerboardPattern@;
+  background-size: 12px 12px, 12px 12px;
+  background-position: 0px 0px, 6px 6px;
+  background-repeat: repeat, repeat;
+}
+
+.snapshot-item-thumbnail[flipped=true] {
+  transform: scaleY(-1);
+}
+
+.theme-dark .snapshot-item-thumbnail {
+  background-color: @darkCheckerboardBackground@;
+}
+
+.theme-light .snapshot-item-thumbnail {
+  background-color: @lightCheckerboardBackground@;
+}
+
+.snapshot-item-details {
+  -moz-padding-start: 6px;
+}
+
+.snapshot-item-calls {
+  padding-top: 4px;
+  font-size: 80%;
+}
+
+.snapshot-item-save {
+  padding-bottom: 2px;
+  font-size: 90%;
+}
+
+.theme-dark .snapshot-item-calls,
+.theme-dark .snapshot-item-save {
+  color: #b6babf; /* Foreground (Text) - Grey */
+}
+
+.theme-light .snapshot-item-calls,
+.theme-light .snapshot-item-save {
+  color: #585959; /* Foreground (Text) - Grey */
+}
+
+.snapshot-item-save {
+  text-decoration: underline;
+  cursor: pointer;
+}
+
+.snapshot-item-save[disabled=true] {
+  text-decoration: none;
+  pointer-events: none;
+}
+
+.snapshot-item-footer[saving]::before {
+  display: inline-block;
+  content: "";
+  background: url("chrome://global/skin/icons/loading_16.png") center no-repeat;
+  width: 16px;
+  height: 16px;
+  margin-top: -2px;
+  -moz-margin-end: 4px;
+}
+
+#snapshots-list .selected label {
+  /* Text inside a selected item should not be custom colored. */
+  color: inherit !important;
+}
+
+/* Debugging pane controls */
+
+#resume {
+  list-style-image: url(debugger-play.png);
+  -moz-image-region: rect(0px,32px,16px,16px);
+}
+
+#step-over {
+  list-style-image: url(debugger-step-over.png);
+}
+
+#step-in {
+  list-style-image: url(debugger-step-in.png);
+}
+
+#step-out {
+  list-style-image: url(debugger-step-out.png);
+}
+
+#debugging-controls > toolbarbutton {
+  transition: opacity 0.15s ease-in-out;
+}
+
+#debugging-controls > toolbarbutton[disabled=true] {
+  opacity: 0.5;
+}
+
+#calls-slider {
+  -moz-padding-end: 24px;
+}
+
+#calls-slider .scale-slider {
+  margin: 0;
+}
+
+#debugging-toolbar-sizer-button {
+  /* This button's only purpose in life is to make the
+     container .devtools-toolbar have the right height. */
+  visibility: hidden;
+  min-width: 1px;
+}
+
+/* Calls list pane */
+
+#calls-list .side-menu-widget-container {
+  background: transparent;
+}
+
+#calls-list .side-menu-widget-item {
+  padding: 0;
+}
+
+/* Calls list items */
+
+.theme-dark #calls-list .side-menu-widget-item {
+  border-color: #111;
+  border-bottom-color: transparent;
+}
+
+.theme-light #calls-list .side-menu-widget-item {
+  border-color: #eee;
+  border-bottom-color: transparent;
+}
+
+.theme-dark .call-item-view:hover {
+  background-color: rgba(255,255,255,.025);
+}
+
+.theme-light .call-item-view:hover {
+  background-color: rgba(0,0,0,.025);
+}
+
+.theme-dark .call-item-view[draw-call] {
+  background-color: rgba(112,191,83,0.15);
+}
+
+.theme-light .call-item-view[draw-call] {
+  background-color: rgba(44,187,15,0.1);
+}
+
+.theme-dark .call-item-view[interesting-call] {
+  background-color: rgba(223,128,255,0.15);
+}
+
+.theme-light .call-item-view[interesting-call] {
+  background-color: rgba(184,46,229,0.1);
+}
+
+.call-item-gutter {
+  width: calc(@gutterWidth@ + @gutterPaddingStart@);
+  -moz-padding-start: @gutterPaddingStart@;
+  -moz-padding-end: 4px;
+  padding-top: 2px;
+  padding-bottom: 2px;
+  -moz-border-end: 1px solid;
+  -moz-margin-end: 6px;
+}
+
+.selected .call-item-gutter {
+  background-image: url("editor-debug-location.png");
+  background-repeat: no-repeat;
+  background-position: 6px center;
+  background-size: 12px;
+}
+
+.theme-dark .call-item-gutter {
+  background-color: #181d20;
+  color: #5f7387;
+  border-color: #000;
+}
+
+.theme-light .call-item-gutter {
+  background-color: #f7f7f7;
+  color: #667380;
+  border-color: #aaa;
+}
+
+.call-item-index {
+  text-align: end;
+}
+
+.theme-dark .call-item-context {
+  color: #eb5368; /* Highlight Orange */
+}
+
+.theme-light .call-item-context {
+  color: #f13c00; /* Highlight Orange */
+}
+
+.theme-dark .call-item-name {
+  color: #46afe3; /* Highlight Blue */
+}
+
+.theme-light .call-item-name {
+  color: #0088cc; /* Highlight Blue */
+}
+
+.call-item-location {
+  -moz-padding-start: 2px;
+  -moz-padding-end: 6px;
+  text-align: end;
+  cursor: pointer;
+}
+
+.theme-dark .call-item-location:hover {
+  color: #0088cc; /* Highlight Blue */
+}
+
+.theme-light .call-item-location:hover {
+  color: #46afe3; /* Highlight Blue */
+}
+
+.call-item-view:hover .call-item-location,
+.call-item-view[expanded] .call-item-location {
+  text-decoration: underline;
+}
+
+.theme-dark .call-item-location {
+  border-color: #111;
+  color: #5e88b0; /* Highlight Blue-Grey */
+}
+
+.theme-light .call-item-location {
+  border-color: #eee;
+  color: #5f88b0; /* Highlight Blue-Grey */
+}
+
+.call-item-stack {
+  -moz-padding-start: calc(@gutterWidth@ + @gutterPaddingStart@);
+  padding-bottom: 10px;
+}
+
+.theme-dark .call-item-stack {
+  background: rgba(0,0,0,0.9);
+}
+
+.theme-light .call-item-stack {
+  background: rgba(255,255,255,0.9);
+}
+
+.call-item-stack-fn {
+  padding-top: 2px;
+  padding-bottom: 2px;
+}
+
+.call-item-stack-fn-location {
+  -moz-padding-start: 2px;
+  -moz-padding-end: 6px;
+  text-align: end;
+  cursor: pointer;
+  text-decoration: underline;
+}
+
+.theme-dark .call-item-stack-fn-name {
+  color: #a9bacb; /* Content (Text) - Light */
+}
+
+.theme-light .call-item-stack-fn-name {
+  color: #667380; /* Content (Text) - Dark Grey */
+}
+
+.theme-dark .call-item-stack-fn-location {
+  color: #5e88b0; /* Highlight Blue-Grey */
+}
+
+.theme-light .call-item-stack-fn-location {
+  color: #5e88b0; /* Highlight Blue-Grey */
+}
+
+.theme-dark .call-item-stack-fn-location:hover {
+  color: #0088cc; /* Highlight Blue */
+}
+
+.theme-light .call-item-stack-fn-location:hover {
+  color: #46afe3; /* Highlight Blue */
+}
+
+#calls-list .selected .call-item-contents > label:not(.call-item-gutter) {
+  /* Text inside a selected item should not be custom colored. */
+  color: inherit !important;
+}
+
+/* Rendering preview */
+
+#screenshot-container {
+  background-image: @checkerboardPattern@;
+  background-size: 30px 30px, 30px 30px;
+  background-position: 0px 0px, 15px 15px;
+  background-repeat: repeat, repeat;
+}
+
+.theme-dark #screenshot-container {
+  background-color: @darkCheckerboardBackground@;
+}
+
+.theme-light #screenshot-container {
+  background-color: @lightCheckerboardBackground@;
+}
+
+@media (min-width: 701px) {
+  #screenshot-container {
+    width: 30vw;
+    max-width: 50vw;
+    min-width: 100px;
+  }
+}
+
+@media (max-width: 700px) {
+  #screenshot-container {
+    height: 40vh;
+    max-height: 70vh;
+    min-height: 100px;
+  }
+}
+
+#screenshot-image {
+  background-image: -moz-element(#screenshot-rendering);
+  background-size: contain;
+  background-position: center, center;
+  background-repeat: no-repeat;
+}
+
+#screenshot-image[flipped=true] {
+  transform: scaleY(-1);
+}
+
+#screenshot-dimensions {
+  padding-top: 4px;
+  padding-bottom: 4px;
+  text-align: center;
+}
+
+.theme-dark #screenshot-dimensions {
+  background-color: rgba(0,0,0,0.4);
+}
+
+.theme-light #screenshot-dimensions {
+  background-color: rgba(255,255,255,0.8);
+}
+
+/* Snapshot filmstrip */
+
+#snapshot-filmstrip {
+  overflow: hidden;
+}
+
+.theme-dark #snapshot-filmstrip {
+  border-top: 1px solid #000;
+  background-image: url(background-noise-toolbar.png);
+  color: #f5f7fa; /* Light foreground text */
+}
+
+.theme-light #snapshot-filmstrip {
+  border-top: 1px solid #aaa;
+  background-image: url(background-noise-toolbar.png);
+  color: #585959; /* Grey foreground text */
+}
+
+.filmstrip-thumbnail {
+  image-rendering: -moz-crisp-edges;
+  background-image: @checkerboardPattern@;
+  background-size: 12px 12px, 12px 12px;
+  background-position: 0px -1px, 6px 5px;
+  background-repeat: repeat, repeat;
+  background-origin: content-box;
+  cursor: pointer;
+  padding-top: 1px;
+  padding-bottom: 1px;
+  transition: opacity 0.1s ease-in-out;
+}
+
+.filmstrip-thumbnail[flipped=true] {
+  transform: scaleY(-1);
+}
+
+.theme-dark .filmstrip-thumbnail {
+  background-color: @darkCheckerboardBackground@;
+}
+
+.theme-light .filmstrip-thumbnail {
+  background-color: @lightCheckerboardBackground@;
+}
+
+.theme-dark .filmstrip-thumbnail {
+  -moz-border-end: 1px solid #000;
+}
+
+.theme-light .filmstrip-thumbnail {
+  -moz-border-end: 1px solid #aaa;
+}
+
+.theme-dark #snapshot-filmstrip > .filmstrip-thumbnail:hover,
+.theme-dark #snapshot-filmstrip:not(:hover) > .filmstrip-thumbnail[highlighted] {
+  border: 1px solid #46afe3; /* Highlight Blue */
+  margin: 0 0 0 -1px;
+  padding: 0;
+  opacity: 0.66;
+}
+
+.theme-light #snapshot-filmstrip > .filmstrip-thumbnail:hover,
+.theme-light #snapshot-filmstrip:not(:hover) > .filmstrip-thumbnail[highlighted] {
+  border: 1px solid #0088cc; /* Highlight Blue */
+  margin: 0 0 0 -1px;
+  padding: 0;
+  opacity: 0.66;
+}
--- a/browser/themes/shared/devtools/toolbars.inc.css
+++ b/browser/themes/shared/devtools/toolbars.inc.css
@@ -769,16 +769,17 @@
 .theme-light .command-button-invertable:active > image,
 .theme-light .devtools-closebutton > image,
 .theme-light .devtools-toolbarbutton > image,
 .theme-light .devtools-option-toolbarbutton > image,
 .theme-light #breadcrumb-separator-normal,
 .theme-light .scrollbutton-up > .toolbarbutton-icon,
 .theme-light .scrollbutton-down > .toolbarbutton-icon,
 .theme-light #black-boxed-message-button .button-icon,
+.theme-light #canvas-debugging-empty-notice-button .button-icon,
 .theme-light #requests-menu-perf-notice-button .button-icon,
 .theme-light #requests-menu-network-summary-button .button-icon {
   filter: url(filters.svg#invert);
 }
 
 /* Since selected backgrounds are blue, we want to use the normal
  * (light) icons. */
 .theme-light .command-button-invertable[checked=true]:not(:active) > image,
--- a/browser/themes/shared/devtools/widgets.inc.css
+++ b/browser/themes/shared/devtools/widgets.inc.css
@@ -559,20 +559,22 @@
 
 /* SideMenuWidget misc */
 
 .side-menu-widget-empty-text {
   padding: 4px 8px;
 }
 
 .theme-dark .side-menu-widget-empty-text {
+  background: url(background-noise-toolbar.png), #343c45; /* Toolbars */
   color: #b6babf; /* Foreground (Text) - Grey */
 }
 
 .theme-light .side-menu-widget-empty-text {
+  background: #f7f7f7; /* Toolbars */
   color: #585959; /* Grey foreground text */
 }
 
 /* VariablesView */
 
 .variables-view-container {
   /* Hack: force hardware acceleration */
   transform: translateZ(1px);
new file mode 100644
--- /dev/null
+++ b/browser/themes/windows/devtools/canvasdebugger.css
@@ -0,0 +1,5 @@
+/* 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/. */
+
+%include ../../shared/devtools/canvasdebugger.inc.css
--- a/browser/themes/windows/jar.mn
+++ b/browser/themes/windows/jar.mn
@@ -237,16 +237,17 @@ browser.jar:
         skin/classic/browser/devtools/webconsole_networkpanel.css   (devtools/webconsole_networkpanel.css)
         skin/classic/browser/devtools/webconsole.png                (devtools/webconsole.png)
         skin/classic/browser/devtools/breadcrumbs-divider@2x.png    (../shared/devtools/images/breadcrumbs-divider@2x.png)
         skin/classic/browser/devtools/breadcrumbs-scrollbutton.png  (../shared/devtools/images/breadcrumbs-scrollbutton.png)
         skin/classic/browser/devtools/breadcrumbs-scrollbutton@2x.png (../shared/devtools/images/breadcrumbs-scrollbutton@2x.png)
 *       skin/classic/browser/devtools/splitview.css                 (../shared/devtools/splitview.css)
         skin/classic/browser/devtools/styleeditor.css               (../shared/devtools/styleeditor.css)
 *       skin/classic/browser/devtools/shadereditor.css              (devtools/shadereditor.css)
+*       skin/classic/browser/devtools/canvasdebugger.css            (devtools/canvasdebugger.css)
 *       skin/classic/browser/devtools/debugger.css                  (devtools/debugger.css)
 *       skin/classic/browser/devtools/profiler.css                  (devtools/profiler.css)
 *       skin/classic/browser/devtools/netmonitor.css                (devtools/netmonitor.css)
 *       skin/classic/browser/devtools/scratchpad.css                (devtools/scratchpad.css)
         skin/classic/browser/devtools/magnifying-glass.png          (../shared/devtools/images/magnifying-glass.png)
         skin/classic/browser/devtools/magnifying-glass@2x.png       (../shared/devtools/images/magnifying-glass@2x.png)
         skin/classic/browser/devtools/magnifying-glass-light.png    (../shared/devtools/images/magnifying-glass-light.png)
         skin/classic/browser/devtools/magnifying-glass-light@2x.png  (../shared/devtools/images/magnifying-glass-light@2x.png)
@@ -584,16 +585,17 @@ browser.jar:
         skin/classic/aero/browser/devtools/webconsole_networkpanel.css     (devtools/webconsole_networkpanel.css)
         skin/classic/aero/browser/devtools/webconsole.png                  (devtools/webconsole.png)
         skin/classic/aero/browser/devtools/breadcrumbs-divider@2x.png      (../shared/devtools/images/breadcrumbs-divider@2x.png)
         skin/classic/aero/browser/devtools/breadcrumbs-scrollbutton.png    (../shared/devtools/images/breadcrumbs-scrollbutton.png)
         skin/classic/aero/browser/devtools/breadcrumbs-scrollbutton@2x.png (../shared/devtools/images/breadcrumbs-scrollbutton@2x.png)
 *       skin/classic/aero/browser/devtools/splitview.css             (../shared/devtools/splitview.css)
         skin/classic/aero/browser/devtools/styleeditor.css           (../shared/devtools/styleeditor.css)
 *       skin/classic/aero/browser/devtools/shadereditor.css          (devtools/shadereditor.css)
+*       skin/classic/aero/browser/devtools/canvasdebugger.css        (devtools/canvasdebugger.css)
 *       skin/classic/aero/browser/devtools/debugger.css              (devtools/debugger.css)
 *       skin/classic/aero/browser/devtools/profiler.css              (devtools/profiler.css)
 *       skin/classic/aero/browser/devtools/netmonitor.css            (devtools/netmonitor.css)
 *       skin/classic/aero/browser/devtools/scratchpad.css            (devtools/scratchpad.css)
         skin/classic/aero/browser/devtools/magnifying-glass.png      (../shared/devtools/images/magnifying-glass.png)
         skin/classic/aero/browser/devtools/magnifying-glass@2x.png   (../shared/devtools/images/magnifying-glass@2x.png)
         skin/classic/aero/browser/devtools/magnifying-glass-light.png (../shared/devtools/images/magnifying-glass-light.png)
         skin/classic/aero/browser/devtools/magnifying-glass-light@2x.png  (../shared/devtools/images/magnifying-glass-light@2x.png)
--- a/toolkit/devtools/DevToolsUtils.js
+++ b/toolkit/devtools/DevToolsUtils.js
@@ -3,16 +3,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 /* General utilities used throughout devtools. */
 const { Ci, Cu } = require("chrome");
 
 let { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
+let { setTimeout, clearTimeout } = Cu.import("resource://gre/modules/Timer.jsm", {});
 
 /**
  * Turn the error |aError| into a string, without fail.
  */
 exports.safeErrorString = function safeErrorString(aError) {
   try {
     let errorString = aError.toString();
     if (typeof errorString == "string") {
@@ -22,16 +23,18 @@ exports.safeErrorString = function safeE
         if (aError.stack) {
           let stack = aError.stack.toString();
           if (typeof stack == "string") {
             errorString += "\nStack: " + stack;
           }
         }
       } catch (ee) { }
 
+      // Append additional line and column number information to the output,
+      // since it might not be part of the stringified error.
       if (typeof aError.lineNumber == "number" && typeof aError.columnNumber == "number") {
         errorString += "Line: " + aError.lineNumber + ", column: " + aError.columnNumber;
       }
 
       return errorString;
     }
   } catch (ee) { }
 
@@ -108,69 +111,100 @@ exports.zip = function zip(a, b) {
   for (let i = 0, aLength = a.length, bLength = b.length;
        i < aLength || i < bLength;
        i++) {
     pairs.push([a[i], b[i]]);
   }
   return pairs;
 };
 
-const executeSoon = aFn => {
+/**
+ * Waits for the next tick in the event loop to execute a callback.
+ */
+exports.executeSoon = function executeSoon(aFn) {
   Services.tm.mainThread.dispatch({
     run: exports.makeInfallible(aFn)
   }, Ci.nsIThread.DISPATCH_NORMAL);
 };
 
 /**
+ * Waits for the next tick in the event loop.
+ *
+ * @return Promise
+ *         A promise that is resolved after the next tick in the event loop.
+ */
+exports.waitForTick = function waitForTick() {
+  let deferred = promise.defer();
+  exports.executeSoon(deferred.resolve);
+  return deferred.promise;
+};
+
+/**
+ * Waits for the specified amount of time to pass.
+ *
+ * @param number aDelay
+ *        The amount of time to wait, in milliseconds.
+ * @return Promise
+ *         A promise that is resolved after the specified amount of time passes.
+ */
+exports.waitForTime = function waitForTime(aDelay) {
+  let deferred = promise.defer();
+  setTimeout(deferred.resolve, aDelay);
+  return deferred.promise;
+};
+
+/**
  * Like Array.prototype.forEach, but doesn't cause jankiness when iterating over
  * very large arrays by yielding to the browser and continuing execution on the
  * next tick.
  *
  * @param Array aArray
  *        The array being iterated over.
  * @param Function aFn
- *        The function called on each item in the array.
+ *        The function called on each item in the array. If a promise is
+ *        returned by this function, iterating over the array will be paused
+ *        until the respective promise is resolved.
  * @returns Promise
  *          A promise that is resolved once the whole array has been iterated
- *          over.
+ *          over, and all promises returned by the aFn callback are resolved.
  */
 exports.yieldingEach = function yieldingEach(aArray, aFn) {
   const deferred = promise.defer();
 
   let i = 0;
   let len = aArray.length;
+  let outstanding = [deferred.promise];
 
   (function loop() {
     const start = Date.now();
 
     while (i < len) {
       // Don't block the main thread for longer than 16 ms at a time. To
       // maintain 60fps, you have to render every frame in at least 16ms; we
       // aren't including time spent in non-JS here, but this is Good
       // Enough(tm).
       if (Date.now() - start > 16) {
-        executeSoon(loop);
+        exports.executeSoon(loop);
         return;
       }
 
       try {
-        aFn(aArray[i++]);
+        outstanding.push(aFn(aArray[i], i++));
       } catch (e) {
         deferred.reject(e);
         return;
       }
     }
 
     deferred.resolve();
   }());
 
-  return deferred.promise;
+  return promise.all(outstanding);
 }
 
-
 /**
  * Like XPCOMUtils.defineLazyGetter, but with a |this| sensitive getter that
  * allows the lazy getter to be defined on a prototype and work correctly with
  * instances.
  *
  * @param Object aObject
  *        The prototype object to define the lazy getter on.
  * @param String aKey
@@ -261,9 +295,8 @@ exports.isSafeJSObject = function isSafe
 
   let principal = Services.scriptSecurityManager.getObjectPrincipal(aObj);
   if (Services.scriptSecurityManager.isSystemPrincipal(principal)) {
     return true; // allow chrome objects
   }
 
   return Cu.isXrayWrapper(aObj);
 };
-
--- a/toolkit/devtools/Loader.jsm
+++ b/toolkit/devtools/Loader.jsm
@@ -66,16 +66,17 @@ BuiltinProvider.prototype = {
         "devtools/app-actor-front": "resource://gre/modules/devtools/app-actor-front.js",
         "devtools/styleinspector/css-logic": "resource://gre/modules/devtools/styleinspector/css-logic",
         "devtools/css-color": "resource://gre/modules/devtools/css-color",
         "devtools/output-parser": "resource://gre/modules/devtools/output-parser",
         "devtools/touch-events": "resource://gre/modules/devtools/touch-events",
         "devtools/client": "resource://gre/modules/devtools/client",
         "devtools/pretty-fast": "resource://gre/modules/devtools/pretty-fast.js",
         "devtools/async-utils": "resource://gre/modules/devtools/async-utils",
+        "devtools/content-observer": "resource://gre/modules/devtools/content-observer",
         "gcli": "resource://gre/modules/devtools/gcli",
         "acorn": "resource://gre/modules/devtools/acorn",
         "acorn/util/walk": "resource://gre/modules/devtools/acorn/walk.js",
 
         // Allow access to xpcshell test items from the loader.
         "xpcshell-test": "resource://test"
       },
       globals: loaderGlobals,
@@ -115,16 +116,17 @@ SrcdirProvider.prototype = {
     let appActorURI = this.fileURI(OS.Path.join(toolkitDir, "apps", "app-actor-front.js"));
     let cssLogicURI = this.fileURI(OS.Path.join(toolkitDir, "styleinspector", "css-logic"));
     let cssColorURI = this.fileURI(OS.Path.join(toolkitDir, "css-color"));
     let outputParserURI = this.fileURI(OS.Path.join(toolkitDir, "output-parser"));
     let touchEventsURI = this.fileURI(OS.Path.join(toolkitDir, "touch-events"));
     let clientURI = this.fileURI(OS.Path.join(toolkitDir, "client"));
     let prettyFastURI = this.fileURI(OS.Path.join(toolkitDir), "pretty-fast.js");
     let asyncUtilsURI = this.fileURI(OS.Path.join(toolkitDir), "async-utils.js");
+    let contentObserverURI = this.fileURI(OS.Path.join(toolkitDir), "content-observer.js");
     let gcliURI = this.fileURI(OS.Path.join(toolkitDir, "gcli", "source", "lib", "gcli"));
     let acornURI = this.fileURI(OS.Path.join(toolkitDir, "acorn"));
     let acornWalkURI = OS.Path.join(acornURI, "walk.js");
     this.loader = new loader.Loader({
       modules: {
         "Services": Object.create(Services),
         "toolkit/loader": loader,
         "source-map": SourceMap,
@@ -139,16 +141,17 @@ SrcdirProvider.prototype = {
         "devtools/app-actor-front": appActorURI,
         "devtools/styleinspector/css-logic": cssLogicURI,
         "devtools/css-color": cssColorURI,
         "devtools/output-parser": outputParserURI,
         "devtools/touch-events": touchEventsURI,
         "devtools/client": clientURI,
         "devtools/pretty-fast": prettyFastURI,
         "devtools/async-utils": asyncUtilsURI,
+        "devtools/content-observer": contentObserverURI,
         "gcli": gcliURI,
         "acorn": acornURI,
         "acorn/util/walk": acornWalkURI
       },
       globals: loaderGlobals,
       invisibleToDebugger: this.invisibleToDebugger
     });
 
new file mode 100644
--- /dev/null
+++ b/toolkit/devtools/content-observer.js
@@ -0,0 +1,72 @@
+/* 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/. */
+"use strict";
+
+const {Cc, Ci, Cu, Cr} = require("chrome");
+const {Services} = Cu.import("resource://gre/modules/Services.jsm", {});
+
+const events = require("sdk/event/core");
+const promise = require("sdk/core/promise");
+
+/**
+ * Handles adding an observer for the creation of content document globals,
+ * event sent immediately after a web content document window has been set up,
+ * but before any script code has been executed.
+ */
+function ContentObserver(tabActor) {
+  this._contentWindow = tabActor.window;
+  this._onContentGlobalCreated = this._onContentGlobalCreated.bind(this);
+  this._onInnerWindowDestroyed = this._onInnerWindowDestroyed.bind(this);
+  this.startListening();
+}
+
+module.exports.ContentObserver = ContentObserver;
+
+ContentObserver.prototype = {
+  /**
+   * Starts listening for the required observer messages.
+   */
+  startListening: function() {
+    Services.obs.addObserver(
+      this._onContentGlobalCreated, "content-document-global-created", false);
+    Services.obs.addObserver(
+      this._onInnerWindowDestroyed, "inner-window-destroyed", false);
+  },
+
+  /**
+   * Stops listening for the required observer messages.
+   */
+  stopListening: function() {
+    Services.obs.removeObserver(
+      this._onContentGlobalCreated, "content-document-global-created", false);
+    Services.obs.removeObserver(
+      this._onInnerWindowDestroyed, "inner-window-destroyed", false);
+  },
+
+  /**
+   * Fired immediately after a web content document window has been set up.
+   */
+  _onContentGlobalCreated: function(subject, topic, data) {
+    if (subject == this._contentWindow) {
+      events.emit(this, "global-created", subject);
+    }
+  },
+
+  /**
+   * Fired when an inner window is removed from the backward/forward cache.
+   */
+  _onInnerWindowDestroyed: function(subject, topic, data) {
+    let id = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
+    events.emit(this, "global-destroyed", id);
+  }
+};
+
+// Utility functions.
+
+ContentObserver.GetInnerWindowID = function(window) {
+  return window
+    .QueryInterface(Ci.nsIInterfaceRequestor)
+    .getInterface(Ci.nsIDOMWindowUtils)
+    .currentInnerWindowID;
+};
new file mode 100644
--- /dev/null
+++ b/toolkit/devtools/server/actors/call-watcher.js
@@ -0,0 +1,559 @@
+/* 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/. */
+"use strict";
+
+const {Cc, Ci, Cu, Cr} = require("chrome");
+const events = require("sdk/event/core");
+const promise = require("sdk/core/promise");
+const protocol = require("devtools/server/protocol");
+const {ContentObserver} = require("devtools/content-observer");
+
+const {on, once, off, emit} = events;
+const {method, Arg, Option, RetVal} = protocol;
+
+exports.register = function(handle) {
+  handle.addTabActor(CallWatcherActor, "callWatcherActor");
+};
+
+exports.unregister = function(handle) {
+  handle.removeTabActor(CallWatcherActor);
+};
+
+/**
+ * Type describing a single function call in a stack trace.
+ */
+protocol.types.addDictType("call-stack-item", {
+  name: "string",
+  file: "string",
+  line: "number"
+});
+
+/**
+ * Type describing an overview of a function call.
+ */
+protocol.types.addDictType("call-details", {
+  type: "number",
+  name: "string",
+  stack: "array:call-stack-item"
+});
+
+/**
+ * This actor contains information about a function call, like the function
+ * type, name, stack, arguments, returned value etc.
+ */
+let FunctionCallActor = protocol.ActorClass({
+  typeName: "function-call",
+
+  /**
+   * Creates the function call actor.
+   *
+   * @param DebuggerServerConnection conn
+   *        The server connection.
+   * @param DOMWindow window
+   *        The content window.
+   * @param string global
+   *        The name of the global object owning this function, like
+   *        "CanvasRenderingContext2D" or "WebGLRenderingContext".
+   * @param object caller
+   *        The object owning the function when it was called.
+   *        For example, in `foo.bar()`, the caller is `foo`.
+   * @param number type
+   *        Either METHOD_FUNCTION, METHOD_GETTER or METHOD_SETTER.
+   * @param string name
+   *        The called function's name.
+   * @param array stack
+   *        The called function's stack, as a list of { name, file, line } objects.
+   * @param array args
+   *        The called function's arguments.
+   * @param any result
+   *        The value returned by the function call.
+   */
+  initialize: function(conn, [window, global, caller, type, name, stack, args, result]) {
+    protocol.Actor.prototype.initialize.call(this, conn);
+
+    this.details = {
+      window: window,
+      caller: caller,
+      type: type,
+      name: name,
+      stack: stack,
+      args: args,
+      return: result
+    };
+
+    this.meta = {
+      global: -1,
+      previews: { caller: "", args: "" }
+    };
+
+    if (global == "WebGLRenderingContext") {
+      this.meta.global = CallWatcherFront.CANVAS_WEBGL_CONTEXT;
+    } else if (global == "CanvasRenderingContext2D") {
+      this.meta.global = CallWatcherFront.CANVAS_2D_CONTEXT;
+    } else if (global == "window") {
+      this.meta.global = CallWatcherFront.UNKNOWN_SCOPE;
+    } else {
+      this.meta.global = CallWatcherFront.GLOBAL_SCOPE;
+    }
+
+    this.meta.previews.caller = this._generateCallerPreview();
+    this.meta.previews.args = this._generateArgsPreview();
+  },
+
+  /**
+   * Customize the marshalling of this actor to provide some generic information
+   * directly on the Front instance.
+   */
+  form: function() {
+    return {
+      actor: this.actorID,
+      type: this.details.type,
+      name: this.details.name,
+      file: this.details.stack[0].file,
+      line: this.details.stack[0].line,
+      callerPreview: this.meta.previews.caller,
+      argsPreview: this.meta.previews.args
+    };
+  },
+
+  /**
+   * Gets more information about this function call, which is not necessarily
+   * available on the Front instance.
+   */
+  getDetails: method(function() {
+    let { type, name, stack } = this.details;
+
+    // Since not all calls on the stack have corresponding owner files (e.g.
+    // callbacks of a requestAnimationFrame etc.), there's no benefit in
+    // returning them, as the user can't jump to the Debugger from them.
+    for (let i = stack.length - 1;;) {
+      if (stack[i].file) {
+        break;
+      }
+      stack.pop();
+      i--;
+    }
+
+    // XXX: Use grips for objects and serialize them properly, in order
+    // to add the function's caller, arguments and return value. Bug 978957.
+    return {
+      type: type,
+      name: name,
+      stack: stack
+    };
+  }, {
+    response: { info: RetVal("call-details") }
+  }),
+
+  /**
+   * Serializes the caller's name so that it can be easily be transferred
+   * as a string, but still be useful when displayed in a potential UI.
+   *
+   * @return string
+   *         The caller's name as a string.
+   */
+  _generateCallerPreview: function() {
+    let global = this.meta.global;
+    if (global == CallWatcherFront.CANVAS_WEBGL_CONTEXT) {
+      return "gl";
+    }
+    if (global == CallWatcherFront.CANVAS_2D_CONTEXT) {
+      return "ctx";
+    }
+    return "";
+  },
+
+  /**
+   * Serializes the arguments so that they can be easily be transferred
+   * as a string, but still be useful when displayed in a potential UI.
+   *
+   * @return string
+   *         The arguments as a string.
+   */
+  _generateArgsPreview: function() {
+    let { caller, args } = this.details;
+    let { global } = this.meta;
+
+    // XXX: All of this sucks. Make this smarter, so that the frontend
+    // can inspect each argument, be it object or primitive. Bug 978960.
+    let serializeArgs = () => args.map(arg => {
+      if (typeof arg == "undefined") {
+        return "undefined";
+      }
+      if (typeof arg == "function") {
+        return "Function";
+      }
+      if (typeof arg == "object") {
+        return "Object";
+      }
+      if (global == CallWatcherFront.CANVAS_WEBGL_CONTEXT) {
+        // XXX: This doesn't handle combined bitmasks. Bug 978964.
+        return getEnumsLookupTable("webgl", caller)[arg] || arg;
+      }
+      if (global == CallWatcherFront.CANVAS_2D_CONTEXT) {
+        return getEnumsLookupTable("2d", caller)[arg] || arg;
+      }
+      return arg;
+    });
+
+    return serializeArgs().join(", ");
+  }
+});
+
+/**
+ * The corresponding Front object for the FunctionCallActor.
+ */
+let FunctionCallFront = protocol.FrontClass(FunctionCallActor, {
+  initialize: function(client, form) {
+    protocol.Front.prototype.initialize.call(this, client, form);
+  },
+
+  /**
+   * Adds some generic information directly to this instance,
+   * to avoid extra roundtrips.
+   */
+  form: function(form) {
+    this.actorID = form.actor;
+    this.type = form.type;
+    this.name = form.name;
+    this.file = form.file;
+    this.line = form.line;
+    this.callerPreview = form.callerPreview;
+    this.argsPreview = form.argsPreview;
+  }
+});
+
+/**
+ * This actor observes function calls on certain objects or globals.
+ */
+let CallWatcherActor = exports.CallWatcherActor = protocol.ActorClass({
+  typeName: "call-watcher",
+  initialize: function(conn, tabActor) {
+    protocol.Actor.prototype.initialize.call(this, conn);
+    this.tabActor = tabActor;
+    this._onGlobalCreated = this._onGlobalCreated.bind(this);
+    this._onGlobalDestroyed = this._onGlobalDestroyed.bind(this);
+    this._onContentFunctionCall = this._onContentFunctionCall.bind(this);
+  },
+  destroy: function(conn) {
+    protocol.Actor.prototype.destroy.call(this, conn);
+    this.finalize();
+  },
+
+  /**
+   * Starts waiting for the current tab actor's document global to be
+   * created, in order to instrument the specified objects and become
+   * aware of everything the content does with them.
+   */
+  setup: method(function({ tracedGlobals, tracedFunctions, startRecording, performReload }) {
+    if (this._initialized) {
+      return;
+    }
+    this._initialized = true;
+
+    this._functionCalls = [];
+    this._tracedGlobals = tracedGlobals || [];
+    this._tracedFunctions = tracedFunctions || [];
+    this._contentObserver = new ContentObserver(this.tabActor);
+
+    on(this._contentObserver, "global-created", this._onGlobalCreated);
+    on(this._contentObserver, "global-destroyed", this._onGlobalDestroyed);
+
+    if (startRecording) {
+      this.resumeRecording();
+    }
+    if (performReload) {
+      this.tabActor.window.location.reload();
+    }
+  }, {
+    request: {
+      tracedGlobals: Option(0, "nullable:array:string"),
+      tracedFunctions: Option(0, "nullable:array:string"),
+      startRecording: Option(0, "boolean"),
+      performReload: Option(0, "boolean")
+    },
+    oneway: true
+  }),
+
+  /**
+   * Stops listening for document global changes and puts this actor
+   * to hibernation. This method is called automatically just before the
+   * actor is destroyed.
+   */
+  finalize: method(function() {
+    if (!this._initialized) {
+      return;
+    }
+    this._initialized = false;
+
+    this._contentObserver.stopListening();
+    off(this._contentObserver, "global-created", this._onGlobalCreated);
+    off(this._contentObserver, "global-destroyed", this._onGlobalDestroyed);
+
+    this._tracedGlobals = null;
+    this._tracedFunctions = null;
+    this._contentObserver = null;
+  }, {
+    oneway: true
+  }),
+
+  /**
+   * Returns whether the instrumented function calls are currently recorded.
+   */
+  isRecording: method(function() {
+    return this._recording;
+  }, {
+    response: RetVal("boolean")
+  }),
+
+  /**
+   * Starts recording function calls.
+   */
+  resumeRecording: method(function() {
+    this._recording = true;
+  }),
+
+  /**
+   * Stops recording function calls.
+   */
+  pauseRecording: method(function() {
+    this._recording = false;
+    return this._functionCalls;
+  }, {
+    response: { calls: RetVal("array:function-call") }
+  }),
+
+  /**
+   * Erases all the recorded function calls.
+   * Calling `resumeRecording` or `pauseRecording` does not erase history.
+   */
+  eraseRecording: method(function() {
+    this._functionCalls = [];
+  }),
+
+  /**
+   * Lightweight listener invoked whenever an instrumented function is called
+   * while recording. We're doing this to avoid the event emitter overhead,
+   * since this is expected to be a very hot function.
+   */
+  onCall: function() {},
+
+  /**
+   * Invoked whenever the current tab actor's document global is created.
+   */
+  _onGlobalCreated: function(window) {
+    let self = this;
+
+    this._tracedWindowId = ContentObserver.GetInnerWindowID(window);
+    let unwrappedWindow = XPCNativeWrapper.unwrap(window);
+    let callback = this._onContentFunctionCall;
+
+    for (let global of this._tracedGlobals) {
+      let prototype = unwrappedWindow[global].prototype;
+      let properties = Object.keys(prototype);
+      properties.forEach(name => overrideSymbol(global, prototype, name, callback));
+    }
+
+    for (let name of this._tracedFunctions) {
+      overrideSymbol("window", unwrappedWindow, name, callback);
+    }
+
+    /**
+     * Instruments a method, getter or setter on the specified target object to
+     * invoke a callback whenever it is called.
+     */
+    function overrideSymbol(global, target, name, callback) {
+      let propertyDescriptor = Object.getOwnPropertyDescriptor(target, name);
+
+      if (propertyDescriptor.get || propertyDescriptor.set) {
+        overrideAccessor(global, target, name, propertyDescriptor, callback);
+        return;
+      }
+      if (propertyDescriptor.writable && typeof propertyDescriptor.value == "function") {
+        overrideFunction(global, target, name, propertyDescriptor, callback);
+        return;
+      }
+    }
+
+    /**
+     * Instruments a function on the specified target object.
+     */
+    function overrideFunction(global, target, name, descriptor, callback) {
+      let originalFunc = target[name];
+
+      Object.defineProperty(target, name, {
+        value: function(...args) {
+          let result = originalFunc.apply(this, args);
+
+          if (self._recording) {
+            let stack = getStack(name);
+            let type = CallWatcherFront.METHOD_FUNCTION;
+            callback(unwrappedWindow, global, this, type, name, stack, args, result);
+          }
+          return result;
+        },
+        configurable: descriptor.configurable,
+        enumerable: descriptor.enumerable,
+        writable: true
+      });
+    }
+
+    /**
+     * Instruments a getter or setter on the specified target object.
+     */
+    function overrideAccessor(global, target, name, descriptor, callback) {
+      let originalGetter = target.__lookupGetter__(name);
+      let originalSetter = target.__lookupSetter__(name);
+
+      Object.defineProperty(target, name, {
+        get: function(...args) {
+          if (!originalGetter) return undefined;
+          let result = originalGetter.apply(this, args);
+
+          if (self._recording) {
+            let stack = getStack(name);
+            let type = CallWatcherFront.GETTER_FUNCTION;
+            callback(unwrappedWindow, global, this, type, name, stack, args, result);
+          }
+          return result;
+        },
+        set: function(...args) {
+          if (!originalSetter) return;
+          originalSetter.apply(this, args);
+
+          if (self._recording) {
+            let stack = getStack(name);
+            let type = CallWatcherFront.SETTER_FUNCTION;
+            callback(unwrappedWindow, global, this, type, name, stack, args, undefined);
+          }
+        },
+        configurable: descriptor.configurable,
+        enumerable: descriptor.enumerable
+      });
+    }
+
+    /**
+     * Stores the relevant information about calls on the stack when
+     * a function is called.
+     */
+    function getStack(caller) {
+      try {
+        // Using Components.stack wouldn't be a better idea, since it's
+        // much slower because it attempts to retrieve the C++ stack as well.
+        throw new Error();
+      } catch (e) {
+        var stack = e.stack;
+      }
+
+      // Of course, using a simple regex like /(.*?)@(.*):(\d*):\d*/ would be
+      // much prettier, but this is a very hot function, so let's sqeeze
+      // every drop of performance out of it.
+      let calls = [];
+      let callIndex = 0;
+      let currNewLinePivot = stack.indexOf("\n") + 1;
+      let nextNewLinePivot = stack.indexOf("\n", currNewLinePivot);
+
+      while (nextNewLinePivot > 0) {
+        let nameDelimiterIndex = stack.indexOf("@", currNewLinePivot);
+        let columnDelimiterIndex = stack.lastIndexOf(":", nextNewLinePivot - 1);
+        let lineDelimiterIndex = stack.lastIndexOf(":", columnDelimiterIndex - 1);
+
+        if (!calls[callIndex]) {
+          calls[callIndex] = { name: "", file: "", line: 0 };
+        }
+        if (!calls[callIndex + 1]) {
+          calls[callIndex + 1] = { name: "", file: "", line: 0 };
+        }
+
+        if (callIndex > 0) {
+          let file = stack.substring(nameDelimiterIndex + 1, lineDelimiterIndex);
+          let line = stack.substring(lineDelimiterIndex + 1, columnDelimiterIndex);
+          let name = stack.substring(currNewLinePivot, nameDelimiterIndex);
+          calls[callIndex].name = name;
+          calls[callIndex - 1].file = file;
+          calls[callIndex - 1].line = line;
+        } else {
+          // Since the topmost stack frame is actually our overwritten function,
+          // it will not have the expected name.
+          calls[0].name = caller;
+        }
+
+        currNewLinePivot = nextNewLinePivot + 1;
+        nextNewLinePivot = stack.indexOf("\n", currNewLinePivot);
+        callIndex++;
+      }
+
+      return calls;
+    }
+  },
+
+  /**
+   * Invoked whenever the current tab actor's inner window is destroyed.
+   */
+  _onGlobalDestroyed: function(id) {
+    if (this._tracedWindowId == id) {
+      this.pauseRecording();
+      this.eraseRecording();
+    }
+  },
+
+  /**
+   * Invoked whenever an instrumented function is called.
+   */
+  _onContentFunctionCall: function(...details) {
+    let functionCall = new FunctionCallActor(this.conn, details);
+    this._functionCalls.push(functionCall);
+    this.onCall(functionCall);
+  }
+});
+
+/**
+ * The corresponding Front object for the CallWatcherActor.
+ */
+let CallWatcherFront = exports.CallWatcherFront = protocol.FrontClass(CallWatcherActor, {
+  initialize: function(client, { callWatcherActor }) {
+    protocol.Front.prototype.initialize.call(this, client, { actor: callWatcherActor });
+    client.addActorPool(this);
+    this.manage(this);
+  }
+});
+
+/**
+ * Constants.
+ */
+CallWatcherFront.METHOD_FUNCTION = 0;
+CallWatcherFront.GETTER_FUNCTION = 1;
+CallWatcherFront.SETTER_FUNCTION = 2;
+
+CallWatcherFront.GLOBAL_SCOPE = 0;
+CallWatcherFront.UNKNOWN_SCOPE = 1;
+CallWatcherFront.CANVAS_WEBGL_CONTEXT = 2;
+CallWatcherFront.CANVAS_2D_CONTEXT = 3;
+
+/**
+ * A lookup table for cross-referencing flags or properties with their name
+ * assuming they look LIKE_THIS most of the time.
+ *
+ * For example, when gl.clear(gl.COLOR_BUFFER_BIT) is called, the actual passed
+ * argument's value is 16384, which we want identified as "COLOR_BUFFER_BIT".
+ */
+var gEnumRegex = /^[A-Z_]+$/;
+var gEnumsLookupTable = {};
+
+function getEnumsLookupTable(type, object) {
+  let cachedEnum = gEnumsLookupTable[type];
+  if (cachedEnum) {
+    return cachedEnum;
+  }
+
+  let table = gEnumsLookupTable[type] = {};
+
+  for (let key in object) {
+    if (key.match(gEnumRegex)) {
+      table[object[key]] = key;
+    }
+  }
+
+  return table;
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/devtools/server/actors/canvas.js
@@ -0,0 +1,759 @@
+/* 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/. */
+"use strict";
+
+const {Cc, Ci, Cu, Cr} = require("chrome");
+const events = require("sdk/event/core");
+const promise = require("sdk/core/promise");
+const protocol = require("devtools/server/protocol");
+const {CallWatcherActor, CallWatcherFront} = require("devtools/server/actors/call-watcher");
+const DevToolsUtils = require("devtools/toolkit/DevToolsUtils.js");
+
+const {on, once, off, emit} = events;
+const {method, custom, Arg, Option, RetVal} = protocol;
+
+const CANVAS_CONTEXTS = [
+  "CanvasRenderingContext2D",
+  "WebGLRenderingContext"
+];
+
+const ANIMATION_GENERATORS = [
+  "requestAnimationFrame",
+  "mozRequestAnimationFrame"
+];
+
+const DRAW_CALLS = [
+  // 2D canvas
+  "fill",
+  "stroke",
+  "clearRect",
+  "fillRect",
+  "strokeRect",
+  "fillText",
+  "strokeText",
+  "drawImage",
+
+  // WebGL
+  "clear",
+  "drawArrays",
+  "drawElements",
+  "finish",
+  "flush"
+];
+
+const INTERESTING_CALLS = [
+  // 2D canvas
+  "save",
+  "restore",
+
+  // WebGL
+  "useProgram"
+];
+
+exports.register = function(handle) {
+  handle.addTabActor(CanvasActor, "canvasActor");
+};
+
+exports.unregister = function(handle) {
+  handle.removeTabActor(CanvasActor);
+};
+
+/**
+ * Type representing an Uint32Array buffer, serialized fast(er).
+ *
+ * XXX: It would be nice if on local connections (only), we could just *give*
+ * the buffer directly to the front, instead of going through all this
+ * serialization redundancy.
+ */
+protocol.types.addType("uint32-array", {
+  write: (v) => "[" + Array.join(v, ",") + "]",
+  read: (v) => new Uint32Array(JSON.parse(v))
+});
+
+/**
+ * Type describing a thumbnail or screenshot in a recorded animation frame.
+ */
+protocol.types.addDictType("snapshot-image", {
+  index: "number",
+  width: "number",
+  height: "number",
+  flipped: "boolean",
+  pixels: "uint32-array"
+});
+
+/**
+ * Type describing an overview of a recorded animation frame.
+ */
+protocol.types.addDictType("snapshot-overview", {
+  calls: "array:function-call",
+  thumbnails: "array:snapshot-image",
+  screenshot: "snapshot-image"
+});
+
+/**
+ * This actor represents a recorded animation frame snapshot, along with
+ * all the corresponding canvas' context methods invoked in that frame,
+ * thumbnails for each draw call and a screenshot of the end result.
+ */
+let FrameSnapshotActor = protocol.ActorClass({
+  typeName: "frame-snapshot",
+
+  /**
+   * Creates the frame snapshot call actor.
+   *
+   * @param DebuggerServerConnection conn
+   *        The server connection.
+   * @param HTMLCanvasElement canvas
+   *        A reference to the content canvas.
+   * @param array calls
+   *        An array of "function-call" actor instances.
+   * @param object screenshot
+   *        A single "snapshot-image" type instance.
+   */
+  initialize: function(conn, { canvas, calls, screenshot }) {
+    protocol.Actor.prototype.initialize.call(this, conn);
+    this._contentCanvas = canvas;
+    this._functionCalls = calls;
+    this._lastDrawCallScreenshot = screenshot;
+  },
+
+  /**
+   * Gets as much data about this snapshot without computing anything costly.
+   */
+  getOverview: method(function() {
+    return {
+      calls: this._functionCalls,
+      thumbnails: this._functionCalls.map(e => e._thumbnail).filter(e => !!e),
+      screenshot: this._lastDrawCallScreenshot
+    };
+  }, {
+    response: { overview: RetVal("snapshot-overview") }
+  }),
+
+  /**
+   * Gets a screenshot of the canvas's contents after the specified
+   * function was called.
+   */
+  generateScreenshotFor: method(function(functionCall) {
+    let caller = functionCall.details.caller;
+    let global = functionCall.meta.global;
+
+    let canvas = this._contentCanvas;
+    let calls = this._functionCalls;
+    let index = calls.indexOf(functionCall);
+
+    // To get a screenshot, replay all the steps necessary to render the frame,
+    // by invoking the context calls up to and including the specified one.
+    // This will be done in a custom framebuffer in case of a WebGL context.
+    let { replayContext, lastDrawCallIndex } = ContextUtils.replayAnimationFrame({
+      contextType: global,
+      canvas: canvas,
+      calls: calls,
+      first: 0,
+      last: index
+    });
+
+    // To keep things fast, generate an image that's relatively small.
+    let dimensions = Math.min(CanvasFront.SCREENSHOT_HEIGHT_MAX, canvas.height);
+    let screenshot;
+
+    // Depending on the canvas' context, generating a screenshot is done
+    // in different ways. In case of the WebGL context, we also need to reset
+    // the framebuffer binding to the default value.
+    if (global == CallWatcherFront.CANVAS_WEBGL_CONTEXT) {
+      screenshot = ContextUtils.getPixelsForWebGL(replayContext);
+      replayContext.bindFramebuffer(replayContext.FRAMEBUFFER, null);
+      screenshot.flipped = true;
+    }
+    // In case of 2D contexts, no additional special treatment is necessary.
+    else if (global == CallWatcherFront.CANVAS_2D_CONTEXT) {
+      screenshot = ContextUtils.getPixelsFor2D(replayContext);
+      screenshot.flipped = false;
+    }
+
+    screenshot.index = lastDrawCallIndex;
+    return screenshot;
+  }, {
+    request: { call: Arg(0, "function-call") },
+    response: { screenshot: RetVal("snapshot-image") }
+  })
+});
+
+/**
+ * The corresponding Front object for the FrameSnapshotActor.
+ */
+let FrameSnapshotFront = protocol.FrontClass(FrameSnapshotActor, {
+  initialize: function(client, form) {
+    protocol.Front.prototype.initialize.call(this, client, form);
+    this._lastDrawCallScreenshot = null;
+    this._cachedScreenshots = new WeakMap();
+  },
+
+  /**
+   * This implementation caches the last draw call screenshot to optimize
+   * frontend requests to `generateScreenshotFor`.
+   */
+  getOverview: custom(function() {
+    return this._getOverview().then(data => {
+      this._lastDrawCallScreenshot = data.screenshot;
+      return data;
+    });
+  }, {
+    impl: "_getOverview"
+  }),
+
+  /**
+   * This implementation saves a roundtrip to the backend if the screenshot
+   * was already generated and retrieved once.
+   */
+  generateScreenshotFor: custom(function(functionCall) {
+    if (CanvasFront.ANIMATION_GENERATORS.has(functionCall.name)) {
+      return promise.resolve(this._lastDrawCallScreenshot);
+    }
+    let cachedScreenshot = this._cachedScreenshots.get(functionCall);
+    if (cachedScreenshot) {
+      return cachedScreenshot;
+    }
+    let screenshot = this._generateScreenshotFor(functionCall);
+    this._cachedScreenshots.set(functionCall, screenshot);
+    return screenshot;
+  }, {
+    impl: "_generateScreenshotFor"
+  })
+});
+
+/**
+ * This Canvas Actor handles simple instrumentation of all the methods
+ * of a 2D or WebGL context, to provide information regarding all the calls
+ * made when drawing frame inside an animation loop.
+ */
+let CanvasActor = exports.CanvasActor = protocol.ActorClass({
+  typeName: "canvas",
+  initialize: function(conn, tabActor) {
+    protocol.Actor.prototype.initialize.call(this, conn);
+    this.tabActor = tabActor;
+    this._onContentFunctionCall = this._onContentFunctionCall.bind(this);
+  },
+  destroy: function(conn) {
+    protocol.Actor.prototype.destroy.call(this, conn);
+    this.finalize();
+  },
+
+  /**
+   * Starts listening for function calls.
+   */
+  setup: method(function({ reload }) {
+    if (this._initialized) {
+      return;
+    }
+    this._initialized = true;
+
+    this._callWatcher = new CallWatcherActor(this.conn, this.tabActor);
+    this._callWatcher.onCall = this._onContentFunctionCall;
+    this._callWatcher.setup({
+      tracedGlobals: CANVAS_CONTEXTS,
+      tracedFunctions: ANIMATION_GENERATORS,
+      performReload: reload
+    });
+  }, {
+    request: { reload: Option(0, "boolean") },
+    oneway: true
+  }),
+
+  /**
+   * Stops listening for function calls.
+   */
+  finalize: method(function() {
+    if (!this._initialized) {
+      return;
+    }
+    this._initialized = false;
+
+    this._callWatcher.finalize();
+    this._callWatcher = null;
+  }, {
+    oneway: true
+  }),
+
+  /**
+   * Returns whether this actor has been set up.
+   */
+  isInitialized: method(function() {
+    return !!this._initialized;
+  }, {
+    response: { initialized: RetVal("boolean") }
+  }),
+
+  /**
+   * Records a snapshot of all the calls made during the next animation frame.
+   * The animation should be implemented via the de-facto requestAnimationFrame
+   * utility, not inside a `setInterval` or recursive `setTimeout`.
+   *
+   * XXX: Currently only supporting requestAnimationFrame. When this isn't used,
+   * it'd be a good idea to display a huge red flashing banner telling people to
+   * STOP USING `setInterval` OR `setTimeout` FOR ANIMATION. Bug 978948.
+   */
+  recordAnimationFrame: method(function() {
+    if (this._callWatcher.isRecording()) {
+      return this._currentAnimationFrameSnapshot.promise;
+    }
+
+    this._callWatcher.eraseRecording();
+    this._callWatcher.resumeRecording();
+
+    let deferred = this._currentAnimationFrameSnapshot = promise.defer();
+    return deferred.promise;
+  }, {
+    response: { snapshot: RetVal("frame-snapshot") }
+  }),
+
+  /**
+   * Invoked whenever an instrumented function is called, be it on a
+   * 2d or WebGL context, or an animation generator like requestAnimationFrame.
+   */
+  _onContentFunctionCall: function(functionCall) {
+    let { window, name, args } = functionCall.details;
+
+    // The function call arguments are required to replay animation frames,
+    // in order to generate screenshots. However, simply storing references to
+    // every kind of object is a bad idea, since their properties may change.
+    // Consider transformation matrices for example, which are typically
+    // Float32Arrays whose values can easily change across context calls.
+    // They need to be cloned.
+    inplaceShallowCloneArrays(args, window);
+
+    if (CanvasFront.ANIMATION_GENERATORS.has(name)) {
+      this._handleAnimationFrame(functionCall);
+      return;
+    }
+    if (CanvasFront.DRAW_CALLS.has(name) && this._animationStarted) {
+      this._handleDrawCall(functionCall);
+      return;
+    }
+  },
+
+  /**
+   * Handle animations generated using requestAnimationFrame.
+   */
+  _handleAnimationFrame: function(functionCall) {
+    if (!this._animationStarted) {
+      this._handleAnimationFrameBegin();
+    } else {
+      this._handleAnimationFrameEnd(functionCall);
+    }
+  },
+
+  /**
+   * Called whenever an animation frame rendering begins.
+   */
+  _handleAnimationFrameBegin: function() {
+    this._callWatcher.eraseRecording();
+    this._animationStarted = true;
+  },
+
+  /**
+   * Called whenever an animation frame rendering ends.
+   */
+  _handleAnimationFrameEnd: function() {
+    // Get a hold of all the function calls made during this animation frame.
+    // Since only one snapshot can be recorded at a time, erase all the
+    // previously recorded calls.
+    let functionCalls = this._callWatcher.pauseRecording();
+    this._callWatcher.eraseRecording();
+
+    // Since the animation frame finished, get a hold of the (already retrieved)
+    // canvas pixels to conveniently create a screenshot of the final rendering.
+    let index = this._lastDrawCallIndex;
+    let width = this._lastContentCanvasWidth;
+    let height = this._lastContentCanvasHeight;
+    let flipped = this._lastThumbnailFlipped;
+    let pixels = ContextUtils.getPixelStorage()["32bit"];
+    let lastDrawCallScreenshot = {
+      index: index,
+      width: width,
+      height: height,
+      flipped: flipped,
+      pixels: pixels.subarray(0, width * height)
+    };
+
+    // Wrap the function calls and screenshot in a FrameSnapshotActor instance,
+    // which will resolve the promise returned by `recordAnimationFrame`.
+    let frameSnapshot = new FrameSnapshotActor(this.conn, {
+      canvas: this._lastDrawCallCanvas,
+      calls: functionCalls,
+      screenshot: lastDrawCallScreenshot
+    });
+
+    this._currentAnimationFrameSnapshot.resolve(frameSnapshot);
+    this._currentAnimationFrameSnapshot = null;
+    this._animationStarted = false;
+  },
+
+  /**
+   * Invoked whenever a draw call is detected in the animation frame which is
+   * currently being recorded.
+   */
+  _handleDrawCall: function(functionCall) {
+    let functionCalls = this._callWatcher.pauseRecording();
+    let caller = functionCall.details.caller;
+    let global = functionCall.meta.global;
+
+    let contentCanvas = this._lastDrawCallCanvas = caller.canvas;
+    let index = this._lastDrawCallIndex = functionCalls.indexOf(functionCall);
+    let w = this._lastContentCanvasWidth = contentCanvas.width;
+    let h = this._lastContentCanvasHeight = contentCanvas.height;
+
+    // To keep things fast, generate images of small and fixed dimensions.
+    let dimensions = CanvasFront.THUMBNAIL_HEIGHT;
+    let thumbnail;
+
+    // Create a thumbnail on every draw call on the canvas context, to augment
+    // the respective function call actor with this additional data.
+    if (global == CallWatcherFront.CANVAS_WEBGL_CONTEXT) {
+      // Check if drawing to a custom framebuffer (when rendering to texture).
+      // Don't create a thumbnail in this particular case.
+      let framebufferBinding = caller.getParameter(caller.FRAMEBUFFER_BINDING);
+      if (framebufferBinding == null) {
+        thumbnail = ContextUtils.getPixelsForWebGL(caller, 0, 0, w, h, dimensions);
+        thumbnail.flipped = this._lastThumbnailFlipped = true;
+        thumbnail.index = index;
+      }
+    } else if (global == CallWatcherFront.CANVAS_2D_CONTEXT) {
+      thumbnail = ContextUtils.getPixelsFor2D(caller, 0, 0, w, h, dimensions);
+      thumbnail.flipped = this._lastThumbnailFlipped = false;
+      thumbnail.index = index;
+    }
+
+    functionCall._thumbnail = thumbnail;
+    this._callWatcher.resumeRecording();
+  }
+});
+
+/**
+ * A collection of methods for manipulating canvas contexts.
+ */
+let ContextUtils = {
+  /**
+   * WebGL contexts are sensitive to how they're queried. Use this function
+   * to make sure the right context is always retrieved, if available.
+   *
+   * @param HTMLCanvasElement canvas
+   *        The canvas element for which to get a WebGL context.
+   * @param WebGLRenderingContext gl
+   *        The queried WebGL context, or null if unavailable.
+   */
+  getWebGLContext: function(canvas) {
+    return canvas.getContext("webgl") ||
+           canvas.getContext("experimental-webgl");
+  },
+
+  /**
+   * Gets a hold of the rendered pixels in the most efficient way possible for
+   * a canvas with a WebGL context.
+   *
+   * @param WebGLRenderingContext gl
+   *        The WebGL context to get a screenshot from.
+   * @param number srcX [optional]
+   *        The first left pixel that is read from the framebuffer.
+   * @param number srcY [optional]
+   *        The first top pixel that is read from the framebuffer.
+   * @param number srcWidth [optional]
+   *        The number of pixels to read on the X axis.
+   * @param number srcHeight [optional]
+   *        The number of pixels to read on the Y axis.
+   * @param number dstHeight [optional]
+   *        The desired generated screenshot height.
+   * @return object
+   *         An objet containing the screenshot's width, height and pixel data.
+   */
+  getPixelsForWebGL: function(gl,
+    srcX = 0, srcY = 0,
+    srcWidth = gl.canvas.width,
+    srcHeight = gl.canvas.height,
+    dstHeight = srcHeight)
+  {
+    let contentPixels = ContextUtils.getPixelStorage(srcWidth, srcHeight);
+    let { "8bit": charView, "32bit": intView } = contentPixels;
+    gl.readPixels(srcX, srcY, srcWidth, srcHeight, gl.RGBA, gl.UNSIGNED_BYTE, charView);
+    return this.resizePixels(intView, srcWidth, srcHeight, dstHeight);
+  },
+
+  /**
+   * Gets a hold of the rendered pixels in the most efficient way possible for
+   * a canvas with a 2D context.
+   *
+   * @param CanvasRenderingContext2D ctx
+   *        The 2D context to get a screenshot from.
+   * @param number srcX [optional]
+   *        The first left pixel that is read from the canvas.
+   * @param number srcY [optional]
+   *        The first top pixel that is read from the canvas.
+   * @param number srcWidth [optional]
+   *        The number of pixels to read on the X axis.
+   * @param number srcHeight [optional]
+   *        The number of pixels to read on the Y axis.
+   * @param number dstHeight [optional]
+   *        The desired generated screenshot height.
+   * @return object
+   *         An objet containing the screenshot's width, height and pixel data.
+   */
+  getPixelsFor2D: function(ctx,
+    srcX = 0, srcY = 0,
+    srcWidth = ctx.canvas.width,
+    srcHeight = ctx.canvas.height,
+    dstHeight = srcHeight)
+  {
+    let { data } = ctx.getImageData(srcX, srcY, srcWidth, srcHeight);
+    let { "32bit": intView } = ContextUtils.usePixelStorage(data.buffer);
+    return this.resizePixels(intView, srcWidth, srcHeight, dstHeight);
+  },
+
+  /**
+   * Resizes the provided pixels to fit inside a rectangle with the specified
+   * height and the same aspect ratio as the source.
+   *
+   * @param Uint32Array srcPixels
+   *        The source pixel data, assuming 32bit/pixel and 4 color components.
+   * @param number srcWidth
+   *        The source pixel data width.
+   * @param number srcHeight
+   *        The source pixel data height.
+   * @param number dstHeight [optional]
+   *        The desired resized pixel data height.
+   * @return object
+   *         An objet containing the resized pixels width, height and data.
+   */
+  resizePixels: function(srcPixels, srcWidth, srcHeight, dstHeight) {
+    let screenshotRatio = dstHeight / srcHeight;
+    let dstWidth = Math.floor(srcWidth * screenshotRatio);
+
+    // Use a plain array instead of a Uint32Array to make serializing faster.
+    let dstPixels = new Array(dstWidth * dstHeight);
+
+    // If the resized image ends up being completely transparent, returning
+    // an empty array will skip some redundant serialization cycles.
+    let isTransparent = true;
+
+    for (let dstX = 0; dstX < dstWidth; dstX++) {
+      for (let dstY = 0; dstY < dstHeight; dstY++) {
+        let srcX = Math.floor(dstX / screenshotRatio);
+        let srcY = Math.floor(dstY / screenshotRatio);
+        let cPos = srcX + srcWidth * srcY;
+        let dPos = dstX + dstWidth * dstY;
+        let color = dstPixels[dPos] = srcPixels[cPos];
+        if (color) {
+          isTransparent = false;
+        }
+      }
+    }
+
+    return {
+      width: dstWidth,
+      height: dstHeight,
+      pixels: isTransparent ? [] : dstPixels
+    };
+  },
+
+  /**
+   * Invokes a series of canvas context calls, to "replay" an animation frame
+   * and generate a screenshot.
+   *
+   * In case of a WebGL context, an offscreen framebuffer is created for
+   * the respective canvas, and the rendering will be performed into it.
+   * This is necessary because some state (like shaders, textures etc.) can't
+   * be shared between two different WebGL contexts.
+   * Hopefully, once SharedResources are a thing this won't be necessary:
+   * http://www.khronos.org/webgl/wiki/SharedResouces
+   *
+   * In case of a 2D context, a new canvas is created, since there's no
+   * intrinsic state that can't be easily duplicated.
+   *
+   * @param number contexType
+   *        The type of context to use. See the CallWatcherFront scope types.
+   * @param HTMLCanvasElement canvas
+   *        The canvas element which is the source of all context calls.
+   * @param array calls
+   *        An array of function call actors.
+   * @param number first
+   *        The first function call to start from.
+   * @param number last
+   *        The last (inclusive) function call to end at.
+   * @return object
+   *         The context on which the specified calls were invoked and the
+   *         last registered draw call's index.
+   */
+  replayAnimationFrame: function({ contextType, canvas, calls, first, last }) {
+    let w = canvas.width;
+    let h = canvas.height;
+
+    let replayCanvas;
+    let replayContext;
+    let customFramebuffer;
+    let lastDrawCallIndex = -1;
+
+    // In case of WebGL contexts, rendering will be done offscreen, in a
+    // custom framebuffer, but on the provided canvas context.
+    if (contextType == CallWatcherFront.CANVAS_WEBGL_CONTEXT) {
+      replayCanvas = canvas;
+      replayContext = this.getWebGLContext(replayCanvas);
+      customFramebuffer = this.createBoundFramebuffer(replayContext, w, h);
+    }
+    // In case of 2D contexts, draw everything on a separate canvas context.
+    else if (contextType == CallWatcherFront.CANVAS_2D_CONTEXT) {
+      let contentDocument = canvas.ownerDocument;
+      replayCanvas = contentDocument.createElement("canvas");
+      replayCanvas.width = w;
+      replayCanvas.height = h;
+      replayContext = replayCanvas.getContext("2d");
+      replayContext.clearRect(0, 0, w, h);
+    }
+
+    // Replay all the context calls up to and including the specified one.
+    for (let i = first; i <= last; i++) {
+      let { type, name, args } = calls[i].details;
+
+      // Prevent WebGL context calls that try to reset the framebuffer binding
+      // to the default value, since we want to perform the rendering offscreen.
+      if (name == "bindFramebuffer" && args[1] == null) {
+        replayContext.bindFramebuffer(replayContext.FRAMEBUFFER, customFramebuffer);
+      } else {
+        if (type == CallWatcherFront.METHOD_FUNCTION) {
+          replayContext[name].apply(replayContext, args);
+        } else if (type == CallWatcherFront.SETTER_FUNCTION) {
+          replayContext[name] = args;
+        } else {
+          // Ignore getter calls.
+        }
+        if (CanvasFront.DRAW_CALLS.has(name)) {
+          lastDrawCallIndex = i;
+        }
+      }
+    }
+
+    return {
+      replayContext: replayContext,
+      lastDrawCallIndex: lastDrawCallIndex
+    };
+  },
+
+  /**
+   * Gets an object containing a buffer large enough to hold width * height
+   * pixels, assuming 32bit/pixel and 4 color components.
+   *
+   * This method avoids allocating memory and tries to reuse a common buffer
+   * as much as possible.
+   *
+   * @param number w
+   *        The desired pixel array storage width.
+   * @param number h
+   *        The desired pixel array storage height.
+   * @return object
+   *         The requested pixel array buffer.
+   */
+  getPixelStorage: function(w = 0, h = 0) {
+    let storage = this._currentPixelStorage;
+    if (storage && storage["32bit"].length >= w * h) {
+      return storage;
+    }
+    return this.usePixelStorage(new ArrayBuffer(w * h * 4));
+  },
+
+  /**
+   * Creates and saves the array buffer views used by `getPixelStorage`.
+   *
+   * @param ArrayBuffer buffer
+   *        The raw buffer used as storage for various array buffer views.
+   */
+  usePixelStorage: function(buffer) {
+    let array8bit = new Uint8Array(buffer);
+    let array32bit = new Uint32Array(buffer);
+    return this._currentPixelStorage = {
+      "8bit": array8bit,
+      "32bit": array32bit
+    };
+  },
+
+  /**
+   * Creates a framebuffer of the specified dimensions for a WebGL context,
+   * assuming a RGBA color buffer, a depth buffer and no stencil buffer.
+   *
+   * @param WebGLRenderingContext gl
+   *        The WebGL context to create and bind a framebuffer for.
+   * @param number width
+   *        The desired width of the renderbuffers.
+   * @param number height
+   *        The desired height of the renderbuffers.
+   * @return WebGLFramebuffer
+   *         The generated framebuffer object.
+   */
+  createBoundFramebuffer: function(gl, width, height) {
+    let framebuffer = gl.createFramebuffer();
+    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
+
+    // Use a texture as the color rendebuffer attachment, since consumenrs of
+    // this function will most likely want to read the rendered pixels back.
+    let colorBuffer = gl.createTexture();
+    gl.bindTexture(gl.TEXTURE_2D, colorBuffer);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+    gl.generateMipmap(gl.TEXTURE_2D);
+    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
+
+    let depthBuffer = gl.createRenderbuffer();
+    gl.bindRenderbuffer(gl.RENDERBUFFER, depthBuffer);
+    gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, width, height);
+
+    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, colorBuffer, 0);
+    gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthBuffer);
+
+    gl.bindTexture(gl.TEXTURE_2D, null);
+    gl.bindRenderbuffer(gl.RENDERBUFFER, null);
+
+    return framebuffer;
+  }
+};
+
+/**
+ * The corresponding Front object for the CanvasActor.
+ */
+let CanvasFront = exports.CanvasFront = protocol.FrontClass(CanvasActor, {
+  initialize: function(client, { canvasActor }) {
+    protocol.Front.prototype.initialize.call(this, client, { actor: canvasActor });
+    client.addActorPool(this);
+    this.manage(this);
+  }
+});
+
+/**
+ * Constants.
+ */
+CanvasFront.CANVAS_CONTEXTS = new Set(CANVAS_CONTEXTS);
+CanvasFront.ANIMATION_GENERATORS = new Set(ANIMATION_GENERATORS);
+CanvasFront.DRAW_CALLS = new Set(DRAW_CALLS);
+CanvasFront.INTERESTING_CALLS = new Set(INTERESTING_CALLS);
+CanvasFront.THUMBNAIL_HEIGHT = 50; // px
+CanvasFront.SCREENSHOT_HEIGHT_MAX = 256; // px
+CanvasFront.INVALID_SNAPSHOT_IMAGE = {
+  index: -1,
+  width: 0,
+  height: 0,
+  pixels: []
+};
+
+/**
+ * Goes through all the arguments and creates a one-level shallow copy
+ * of all arrays and array buffers.
+ */
+function inplaceShallowCloneArrays(functionArguments, contentWindow) {
+  let { Object, Array, ArrayBuffer } = contentWindow;
+
+  functionArguments.forEach((arg, index, store) => {
+    if (arg instanceof Array) {
+      store[index] = arg.slice();
+    }
+    if (arg instanceof Object && arg.buffer instanceof ArrayBuffer) {
+      store[index] = new arg.constructor(arg);
+    }
+  });
+}
--- a/toolkit/devtools/server/actors/webgl.js
+++ b/toolkit/devtools/server/actors/webgl.js
@@ -1,17 +1,17 @@
 /* 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/. */
 "use strict";
 
 const {Cc, Ci, Cu, Cr} = require("chrome");
-const Services = require("Services");
 const events = require("sdk/event/core");
 const protocol = require("devtools/server/protocol");
+const { ContentObserver } = require("devtools/content-observer");
 
 const { on, once, off, emit } = events;
 const { method, Arg, Option, RetVal } = protocol;
 
 const WEBGL_CONTEXT_NAMES = ["webgl", "experimental-webgl", "moz-webgl"];
 
 // These traits are bit masks. Make sure they're powers of 2.
 const PROGRAM_DEFAULT_TRAITS = 0;
@@ -288,17 +288,17 @@ let WebGLActor = exports.WebGLActor = pr
    oneway: true
   }),
 
   /**
    * Gets an array of cached program actors for the current tab actor's window.
    * This is useful for dealing with bfcache, when no new programs are linked.
    */
   getPrograms: method(function() {
-    let id = getInnerWindowID(this.tabActor.window);
+    let id = ContentObserver.GetInnerWindowID(this.tabActor.window);
     return this._programActorsCache.filter(e => e.ownerWindow == id);
   }, {
     response: { programs: RetVal("array:gl-program") }
   }),
 
   /**
    * Events emitted by this actor. The "program-linked" event is fired
    * every time a WebGL program was linked with its respective two shaders.
@@ -342,83 +342,31 @@ let WebGLFront = exports.WebGLFront = pr
   initialize: function(client, { webglActor }) {
     protocol.Front.prototype.initialize.call(this, client, { actor: webglActor });
     client.addActorPool(this);
     this.manage(this);
   }
 });
 
 /**
- * Handles adding an observer for the creation of content document globals,
- * event sent immediately after a web content document window has been set up,
- * but before any script code has been executed. This will allow us to
- * instrument the HTMLCanvasElement with the appropriate inspection methods.
- */
-function ContentObserver(tabActor) {
-  this._contentWindow = tabActor.window;
-  this._onContentGlobalCreated = this._onContentGlobalCreated.bind(this);
-  this._onInnerWindowDestroyed = this._onInnerWindowDestroyed.bind(this);
-  this.startListening();
-}
-
-ContentObserver.prototype = {
-  /**
-   * Starts listening for the required observer messages.
-   */
-  startListening: function() {
-    Services.obs.addObserver(
-      this._onContentGlobalCreated, "content-document-global-created", false);
-    Services.obs.addObserver(
-      this._onInnerWindowDestroyed, "inner-window-destroyed", false);
-  },
-
-  /**
-   * Stops listening for the required observer messages.
-   */
-  stopListening: function() {
-    Services.obs.removeObserver(
-      this._onContentGlobalCreated, "content-document-global-created", false);
-    Services.obs.removeObserver(
-      this._onInnerWindowDestroyed, "inner-window-destroyed", false);
-  },
-
-  /**
-   * Fired immediately after a web content document window has been set up.
-   */
-  _onContentGlobalCreated: function(subject, topic, data) {
-    if (subject == this._contentWindow) {
-      emit(this, "global-created", subject);
-    }
-  },
-
-  /**
-   * Fired when an inner window is removed from the backward/forward cache.
-   */
-  _onInnerWindowDestroyed: function(subject, topic, data) {
-    let id = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
-    emit(this, "global-destroyed", id);
-  }
-};
-
-/**
  * Instruments a HTMLCanvasElement with the appropriate inspection methods.
  */
 let WebGLInstrumenter = {
   /**
    * Overrides the getContext method in the HTMLCanvasElement prototype.
    *
    * @param nsIDOMWindow window
    *        The window to perform the instrumentation in.
    * @param WebGLObserver observer
    *        The observer watching function calls in the context.
    */
   handle: function(window, observer) {
     let self = this;
 
-    let id = getInnerWindowID(window);
+    let id = ContentObserver.GetInnerWindowID(window);
     let canvasElem = XPCNativeWrapper.unwrap(window.HTMLCanvasElement);
     let canvasPrototype = canvasElem.prototype;
     let originalGetContext = canvasPrototype.getContext;
 
     /**
      * Returns a drawing context on the canvas, or null if the context ID is
      * not supported. This override creates an observer for the targeted context
      * type and instruments specific functions in the targeted context instance.
@@ -1349,23 +1297,16 @@ WebGLProxy.prototype = {
     this._observer.suppressHandlers = prevState;
 
     return result;
   }
 };
 
 // Utility functions.
 
-function getInnerWindowID(window) {
-  return window
-    .QueryInterface(Ci.nsIInterfaceRequestor)
-    .getInterface(Ci.nsIDOMWindowUtils)
-    .currentInnerWindowID;
-}
-
 function removeFromMap(map, predicate) {
   for (let [key, value] of map) {
     if (predicate(value)) {
       map.delete(key);
     }
   }
 };
 
--- a/toolkit/devtools/server/main.js
+++ b/toolkit/devtools/server/main.js
@@ -388,26 +388,29 @@ var DebuggerServer = {
 
   /**
    * Install tab actors.
    */
   addTabActors: function() {
     this.addActors("resource://gre/modules/devtools/server/actors/script.js");
     this.addActors("resource://gre/modules/devtools/server/actors/webconsole.js");
     this.registerModule("devtools/server/actors/inspector");
+    this.registerModule("devtools/server/actors/call-watcher");
+    this.registerModule("devtools/server/actors/canvas");
     this.registerModule("devtools/server/actors/webgl");
     this.registerModule("devtools/server/actors/stylesheets");
     this.registerModule("devtools/server/actors/styleeditor");
     this.registerModule("devtools/server/actors/storage");
     this.registerModule("devtools/server/actors/gcli");
     this.registerModule("devtools/server/actors/tracer");
     this.registerModule("devtools/server/actors/memory");
     this.registerModule("devtools/server/actors/eventlooplag");
-    if ("nsIProfiler" in Ci)
+    if ("nsIProfiler" in Ci) {
       this.addActors("resource://gre/modules/devtools/server/actors/profiler.js");
+    }
   },
 
   /**
    * Listens on the given port or socket file for remote debugger connections.
    *
    * @param aPortOrPath int, string
    *        If given an integer, the port to listen on.
    *        Otherwise, the path to the unix socket domain file to listen on.
--- a/toolkit/modules/Log.jsm
+++ b/toolkit/modules/Log.jsm
@@ -64,16 +64,17 @@ this.Log = {
   },
 
   LogMessage: LogMessage,
   Logger: Logger,
   LoggerRepository: LoggerRepository,
 
   Formatter: Formatter,
   BasicFormatter: BasicFormatter,
+  MessageOnlyFormatter: MessageOnlyFormatter,
   StructuredFormatter: StructuredFormatter,
 
   Appender: Appender,
   DumpAppender: DumpAppender,
   ConsoleAppender: ConsoleAppender,
   StorageStreamAppender: StorageStreamAppender,
 
   FileAppender: FileAppender,
@@ -354,23 +355,68 @@ LoggerRepository.prototype = {
 
     // trigger updates for any possible descendants of this logger
     for (let logger in this._loggers) {
       if (logger != name && logger.indexOf(name) == 0)
         this._updateParents(logger);
     }
   },
 
-  getLogger: function LogRep_getLogger(name) {
+  /**
+   * Obtain a named Logger.
+   *
+   * The returned Logger instance for a particular name is shared among
+   * all callers. In other words, if two consumers call getLogger("foo"),
+   * they will both have a reference to the same object.
+   *
+   * @return Logger
+   */
+  getLogger: function (name) {
     if (name in this._loggers)
       return this._loggers[name];
     this._loggers[name] = new Logger(name, this);
     this._updateParents(name);
     return this._loggers[name];
-  }
+  },
+
+  /**
+   * Obtain a Logger that logs all string messages with a prefix.
+   *
+   * A common pattern is to have separate Logger instances for each instance
+   * of an object. But, you still want to distinguish between each instance.
+   * Since Log.repository.getLogger() returns shared Logger objects,
+   * monkeypatching one Logger modifies them all.
+   *
+   * This function returns a new object with a prototype chain that chains
+   * up to the original Logger instance. The new prototype has log functions
+   * that prefix content to each message.
+   *
+   * @param name
+   *        (string) The Logger to retrieve.
+   * @param prefix
+   *        (string) The string to prefix each logged message with.
+   */
+  getLoggerWithMessagePrefix: function (name, prefix) {
+    let log = this.getLogger(name);
+
+    let proxy = {__proto__: log};
+
+    for (let level in Log.Level) {
+      if (level == "Desc") {
+        continue;
+      }
+
+      let lc = level.toLowerCase();
+      proxy[lc] = function (msg, ...args) {
+        return log[lc].apply(log, [prefix + msg, ...args]);
+      };
+    }
+
+    return proxy;
+  },
 };
 
 /*
  * Formatters
  * These massage a LogMessage into whatever output is desired.
  * BasicFormatter and StructuredFormatter are implemented here.
  */
 
@@ -391,16 +437,29 @@ BasicFormatter.prototype = {
   format: function BF_format(message) {
     return message.time + "\t" +
       message.loggerName + "\t" +
       message.levelDesc + "\t" +
       message.message + "\n";
   }
 };
 
+/**
+ * A formatter that only formats the string message component.
+ */
+function MessageOnlyFormatter() {
+}
+MessageOnlyFormatter.prototype = Object.freeze({
+  __proto__: Formatter.prototype,
+
+  format: function (message) {
+    return message.message + "\n";
+  },
+});
+
 // Structured formatter that outputs JSON based on message data.
 // This formatter will format unstructured messages by supplying
 // default values.
 function StructuredFormatter() { }
 StructuredFormatter.prototype = {
   __proto__: Formatter.prototype,
 
   format: function (logMessage) {
--- a/toolkit/modules/Sqlite.jsm
+++ b/toolkit/modules/Sqlite.jsm
@@ -168,38 +168,18 @@ function openConnection(options) {
  *        (string) The basename of this database name. Used for logging.
  * @param number
  *        (Number) The connection number to this database.
  * @param options
  *        (object) Options to control behavior of connection. See
  *        `openConnection`.
  */
 function OpenedConnection(connection, basename, number, options) {
-  let log = Log.repository.getLogger("Sqlite.Connection." + basename);
-
-  // getLogger() returns a shared object. We can't modify the functions on this
-  // object since they would have effect on all instances and last write would
-  // win. So, we create a "proxy" object with our custom functions. Everything
-  // else is proxied back to the shared logger instance via prototype
-  // inheritance.
-  let logProxy = {__proto__: log};
-
-  // Automatically prefix all log messages with the identifier.
-  for (let level in Log.Level) {
-    if (level == "Desc") {
-      continue;
-    }
-
-    let lc = level.toLowerCase();
-    logProxy[lc] = function (msg) {
-      return log[lc].call(log, "Conn #" + number + ": " + msg);
-    };
-  }
-
-  this._log = logProxy;
+  this._log = Log.repository.getLoggerWithMessagePrefix("Sqlite.Connection." + basename,
+                                                        "Conn #" + number + ": ");
 
   this._log.info("Opened");
 
   this._connection = connection;
   this._connectionIdentifier = basename + " Conn #" + number;
   this._open = true;
 
   this._cachedStatements = new Map();
--- a/toolkit/modules/tests/xpcshell/test_Log.js
+++ b/toolkit/modules/tests/xpcshell/test_Log.js
@@ -67,16 +67,36 @@ add_test(function test_Logger_parent() {
   Log.repository.rootLogger.info("this shouldn't show up in gpAppender");
 
   do_check_eq(gpAppender.messages.length, 1);
   do_check_true(gpAppender.messages[0].indexOf("child info test") > 0);
 
   run_next_test();
 });
 
+add_test(function test_LoggerWithMessagePrefix() {
+  let log = Log.repository.getLogger("test.logger.prefix");
+  let appender = new MockAppender(new Log.MessageOnlyFormatter());
+  log.addAppender(appender);
+
+  let prefixed = Log.repository.getLoggerWithMessagePrefix(
+    "test.logger.prefix", "prefix: ");
+
+  log.warn("no prefix");
+  prefixed.warn("with prefix");
+
+  Assert.equal(appender.messages.length, 2, "2 messages were logged.");
+  Assert.deepEqual(appender.messages, [
+    "no prefix\n",
+    "prefix: with prefix\n",
+  ], "Prefix logger works.");
+
+  run_next_test();
+});
+
 // A utility method for checking object equivalence.
 // Fields with a reqular expression value in expected will be tested
 // against the corresponding value in actual. Otherwise objects
 // are expected to have the same keys and equal values.
 function checkObjects(expected, actual) {
   do_check_true(expected instanceof Object);
   do_check_true(actual instanceof Object);
   for (let key in expected) {
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -2312,17 +2312,35 @@ this.AddonManagerPrivate = {
 
   // Start a timer, record a simple measure of the time interval when
   // timer.done() is called
   simpleTimer: function(aName) {
     let startTime = Date.now();
     return {
       done: () => this.recordSimpleMeasure(aName, Date.now() - startTime)
     };
-  }
+  },
+
+  /**
+   * Helper to call update listeners when no update is available.
+   *
+   * This can be used as an implementation for Addon.findUpdates() when
+   * no update mechanism is available.
+   */
+  callNoUpdateListeners: function (addon, listener, reason, appVersion, platformVersion) {
+    if ("onNoCompatibilityUpdateAvailable" in listener) {
+      safeCall(listener.onNoCompatibilityUpdateAvailable.bind(listener), addon);
+    }
+    if ("onNoUpdateAvailable" in listener) {
+      safeCall(listener.onNoUpdateAvailable.bind(listener), addon);
+    }
+    if ("onUpdateFinished" in listener) {
+      safeCall(listener.onUpdateFinished.bind(listener), addon);
+    }
+  },
 };
 
 /**
  * This is the public API that UI and developers should be calling. All methods
  * just forward to AddonManagerInternal.
  */
 this.AddonManager = {
   // Constants for the AddonInstall.state property
--- a/toolkit/mozapps/extensions/LightweightThemeManager.jsm
+++ b/toolkit/mozapps/extensions/LightweightThemeManager.jsm
@@ -503,22 +503,17 @@ function AddonWrapper(aTheme) {
     LightweightThemeManager.forgetUsedTheme(aTheme.id);
   };
 
   this.cancelUninstall = function AddonWrapper_cancelUninstall() {
     throw new Error("Theme is not marked to be uninstalled");
   };
 
   this.findUpdates = function AddonWrapper_findUpdates(listener, reason, appVersion, platformVersion) {
-    if ("onNoCompatibilityUpdateAvailable" in listener)
-      listener.onNoCompatibilityUpdateAvailable(this);
-    if ("onNoUpdateAvailable" in listener)
-      listener.onNoUpdateAvailable(this);
-    if ("onUpdateFinished" in listener)
-      listener.onUpdateFinished(this);
+    AddonManagerPrivate.callNoUpdateListeners(this, listener, reason, appVersion, platformVersion);
   };
 }
 
 AddonWrapper.prototype = {
   // Lightweight themes are never disabled by the application
   get appDisabled() {
     return false;
   },
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -903,23 +903,39 @@ function loadManifestFromRDF(aUri, aStre
 
   // A theme's userDisabled value is true if the theme is not the selected skin
   // or if there is an active lightweight theme. We ignore whether softblocking
   // is in effect since it would change the active theme.
   if (addon.type == "theme") {
     addon.userDisabled = !!LightweightThemeManager.currentTheme ||
                          addon.internalName != XPIProvider.selectedSkin;
   }
+  // Experiments are disabled by default. It is up to the Experiments Manager
+  // to enable them (it drives installation).
+  else if (addon.type == "experiment") {
+    addon.userDisabled = true;
+  }
   else {
     addon.userDisabled = false;
     addon.softDisabled = addon.blocklistState == Ci.nsIBlocklistService.STATE_SOFTBLOCKED;
   }
 
   addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT;
 
+  // Experiments are managed and updated through an external "experiments
+  // manager." So disable some built-in mechanisms.
+  if (addon.type == "experiment") {
+    addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DISABLE;
+    addon.updateURL = null;
+    addon.updateKey = null;
+
+    addon.targetApplications = [];
+    addon.targetPlatforms = [];
+  }
+
   // Load the storage service before NSS (nsIRandomGenerator),
   // to avoid a SQLite initialization error (bug 717904).
   let storage = Services.storage;
 
   // Generate random GUID used for Sync.
   // This was lifted from util.js:makeGUID() from services-sync.
   let rng = Cc["@mozilla.org/security/random-generator;1"].
             createInstance(Ci.nsIRandomGenerator);
@@ -2066,18 +2082,29 @@ var XPIProvider = {
     Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS,
                                !XPIDatabase.writeAddonsList());
   },
 
   /**
    * Persists changes to XPIProvider.bootstrappedAddons to its store (a pref).
    */
   persistBootstrappedAddons: function XPI_persistBootstrappedAddons() {
+    // Experiments are disabled upon app load, so don't persist references.
+    let filtered = {};
+    for (let id in this.bootstrappedAddons) {
+      let entry = this.bootstrappedAddons[id];
+      if (entry.type == "experiment") {
+        continue;
+      }
+
+      filtered[id] = entry;
+    }
+
     Services.prefs.setCharPref(PREF_BOOTSTRAP_ADDONS,
-                               JSON.stringify(this.bootstrappedAddons));
+                               JSON.stringify(filtered));
   },
 
   /**
    * Adds a list of currently active add-ons to the next crash report.
    */
   addAddonsToCrashReporter: function XPI_addAddonsToCrashReporter() {
     if (!("nsICrashReporter" in Ci) ||
         !(Services.appinfo instanceof Ci.nsICrashReporter))
@@ -4222,22 +4249,26 @@ var XPIProvider = {
 
     let wasDisabled = isAddonDisabled(aAddon);
     let isDisabled = aUserDisabled || aSoftDisabled || appDisabled;
 
     // If appDisabled changes but the result of isAddonDisabled() doesn't,
     // no onDisabling/onEnabling is sent - so send a onPropertyChanged.
     let appDisabledChanged = aAddon.appDisabled != appDisabled;
 
-    // Update the properties in the database
-    XPIDatabase.setAddonProperties(aAddon, {
-      userDisabled: aUserDisabled,
-      appDisabled: appDisabled,
-      softDisabled: aSoftDisabled
-    });
+    // Update the properties in the database.
+    // We never persist this for experiments because the disabled flags
+    // are controlled by the Experiments Manager.
+    if (aAddon.type != "experiment") {
+      XPIDatabase.setAddonProperties(aAddon, {
+        userDisabled: aUserDisabled,
+        appDisabled: appDisabled,
+        softDisabled: aSoftDisabled
+      });
+    }
 
     if (appDisabledChanged) {
       AddonManagerPrivate.callAddonListeners("onPropertyChanged",
                                             aAddon,
                                             ["appDisabled"]);
     }
 
     // If the add-on is not visible or the add-on is not changing state then
@@ -6009,16 +6040,27 @@ AddonInternal.prototype = {
         }
       }
     }
 
     return matchedOS && !needsABI;
   },
 
   isCompatibleWith: function AddonInternal_isCompatibleWith(aAppVersion, aPlatformVersion) {
+    // Experiments are installed through an external mechanism that
+    // limits target audience to compatible clients. We trust it knows what
+    // it's doing and skip compatibility checks.
+    //
+    // This decision does forfeit defense in depth. If the experiments system
+    // is ever wrong about targeting an add-on to a specific application
+    // or platform, the client will likely see errors.
+    if (this.type == "experiment") {
+      return true;
+    }
+
     let app = this.matchingTargetApplication;
     if (!app)
       return false;
 
     if (!aAppVersion)
       aAppVersion = Services.appinfo.version;
     if (!aPlatformVersion)
       aPlatformVersion = Services.appinfo.platformVersion;
@@ -6393,16 +6435,21 @@ function AddonWrapper(aAddon) {
 
     return null;
   });
 
   this.__defineGetter__("applyBackgroundUpdates", function AddonWrapper_applyBackgroundUpdatesGetter() {
     return aAddon.applyBackgroundUpdates;
   });
   this.__defineSetter__("applyBackgroundUpdates", function AddonWrapper_applyBackgroundUpdatesSetter(val) {
+    if (this.type == "experiment") {
+      logger.warn("Setting applyBackgroundUpdates on an experiment is not supported.");
+      return;
+    }
+
     if (val != AddonManager.AUTOUPDATE_DEFAULT &&
         val != AddonManager.AUTOUPDATE_DISABLE &&
         val != AddonManager.AUTOUPDATE_ENABLE) {
       val = val ? AddonManager.AUTOUPDATE_DEFAULT :
                   AddonManager.AUTOUPDATE_DISABLE;
     }
 
     if (val == aAddon.applyBackgroundUpdates)
@@ -6493,32 +6540,43 @@ function AddonWrapper(aAddon) {
 
   this.__defineGetter__("permissions", function AddonWrapper_permisionsGetter() {
     let permissions = 0;
 
     // Add-ons that aren't installed cannot be modified in any way
     if (!(aAddon.inDatabase))
       return permissions;
 
+    // Experiments can only be uninstalled. An uninstall reflects the user
+    // intent of "disable this experiment." This is partially managed by the
+    // experiments manager.
+    if (aAddon.type == "experiment") {
+      return AddonManager.PERM_CAN_UNINSTALL;
+    }
+
     if (!aAddon.appDisabled) {
-      if (this.userDisabled)
+      if (this.userDisabled) {
         permissions |= AddonManager.PERM_CAN_ENABLE;
-      else if (aAddon.type != "theme")
+      }
+      else if (aAddon.type != "theme") {
         permissions |= AddonManager.PERM_CAN_DISABLE;
+      }
     }
 
     // Add-ons that are in locked install locations, or are pending uninstall
     // cannot be upgraded or uninstalled
     if (!aAddon._installLocation.locked && !aAddon.pendingUninstall) {
       // Add-ons that are installed by a file link cannot be upgraded
-      if (!aAddon._installLocation.isLinkedAddon(aAddon.id))
+      if (!aAddon._installLocation.isLinkedAddon(aAddon.id)) {
         permissions |= AddonManager.PERM_CAN_UPGRADE;
+      }
 
       permissions |= AddonManager.PERM_CAN_UNINSTALL;
     }
+
     return permissions;
   });
 
   this.__defineGetter__("isActive", function AddonWrapper_isActiveGetter() {
     if (Services.appinfo.inSafeMode)
       return false;
     return aAddon.active;
   });
@@ -6590,16 +6648,24 @@ function AddonWrapper(aAddon) {
     if (!(aAddon.inDatabase))
       throw new Error("Cannot cancel uninstall for an add-on that isn't installed");
     if (!aAddon.pendingUninstall)
       throw new Error("Add-on is not marked to be uninstalled");
     XPIProvider.cancelUninstallAddon(aAddon);
   };
 
   this.findUpdates = function AddonWrapper_findUpdates(aListener, aReason, aAppVersion, aPlatformVersion) {
+    // Short-circuit updates for experiments because updates are handled
+    // through the Experiments Manager.
+    if (this.type == "experiment") {
+      AddonManagerPrivate.callNoUpdateListeners(this, aListener, aReason,
+                                                aAppVersion, aPlatformVersion);
+      return;
+    }
+
     new UpdateChecker(aAddon, aListener, aReason, aAppVersion, aPlatformVersion);
   };
 
   // Returns true if there was an update in progress, false if there was no update to cancel
   this.cancelUpdate = function AddonWrapper_cancelUpdate() {
     if (aAddon._updateCheck) {
       aAddon._updateCheck.cancel();
       return true;
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/addons/test_experiment1/install.rdf
@@ -0,0 +1,17 @@
+<?xml version="1.0"?>
+
+<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:em="http://www.mozilla.org/2004/em-rdf#">
+
+  <Description about="urn:mozilla:install-manifest">
+    <em:id>experiment1@tests.mozilla.org</em:id>
+    <em:version>1.0</em:version>
+    <em:type>128</em:type>
+    <em:bootstrap>true</em:bootstrap>
+
+    <!-- Front End MetaData -->
+    <em:name>Test Experiment 1</em:name>
+    <em:description>Test Description</em:description>
+
+  </Description>
+</RDF>
--- a/toolkit/mozapps/extensions/test/browser/browser_experiments.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_experiments.js
@@ -45,18 +45,17 @@ add_test(function testExperimentInfoNotV
 });
 
 // If we have an active experiment, we should see the experiments tab
 // and that tab should have some messages.
 add_test(function testActiveExperiment() {
   install_addon("addons/browser_experiment1.xpi", (addon) => {
     gInstalledAddons.push(addon);
 
-    // This may change if we remove compatibility checking from experiments.
-    // Putting this check here so a test fails if preconditions change.
+    Assert.ok(addon.userDisabled, "Add-on is disabled upon initial install.");
     Assert.equal(addon.isActive, false, "Add-on is not active.");
 
     Assert.ok(gCategoryUtilities.isTypeVisible("experiment"), "Experiment tab visible.");
 
     gCategoryUtilities.openType("experiment", (win) => {
       let el = gManagerWindow.document.getElementsByClassName("experiment-info-container")[0];
       is_element_visible(el, "Experiment info is visible on experiment tab.");
 
@@ -128,8 +127,27 @@ add_test(function testOpenPreferences() 
 
       run_next_test();
     }, "advanced-pane-loaded", false);
 
     info("Loading preferences pane.");
     EventUtils.synthesizeMouseAtCenter(btn, {}, gManagerWindow);
   });
 });
+
+add_test(function testButtonPresence() {
+  gCategoryUtilities.openType("experiment", (win) => {
+    let item = get_addon_element(gManagerWindow, "test-experiment1@experiments.mozilla.org");
+    Assert.ok(item, "Got add-on element.");
+
+    let el = item.ownerDocument.getAnonymousElementByAttribute(item, "anonid", "remove-btn");
+    // Corresponds to the uninstall permission.
+    is_element_visible(el, "Remove button is visible.");
+    // Corresponds to lack of disable permission.
+    el = item.ownerDocument.getAnonymousElementByAttribute(item, "anonid", "disable-btn");
+    is_element_hidden(el, "Disable button not visible.");
+    // Corresponds to lack of enable permission.
+    el = item.ownerDocument.getAnonymousElementByAttribute(item, "anonid", "enable-btn");
+    is_element_hidden(el, "Enable button not visible.");
+
+    run_next_test();
+  });
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_experiment.js
@@ -0,0 +1,89 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+let scope = Components.utils.import("resource://gre/modules/addons/XPIProvider.jsm");
+const XPIProvider = scope.XPIProvider;
+
+function run_test() {
+  createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+  startupManager();
+
+  run_next_test();
+}
+
+add_test(function test_experiment() {
+  AddonManager.getInstallForFile(do_get_addon("test_experiment1"), (install) => {
+    completeAllInstalls([install], () => {
+      AddonManager.getAddonByID("experiment1@tests.mozilla.org", (addon) => {
+        Assert.ok(addon, "Addon is found.");
+
+        Assert.ok(addon.userDisabled, "Experiments are userDisabled by default.");
+        Assert.equal(addon.isActive, false, "Add-on is not active.");
+        Assert.equal(addon.updateURL, null, "No updateURL for experiments.");
+        Assert.equal(addon.applyBackgroundUpdates, AddonManager.AUTOUPDATE_DISABLE,
+                     "Background updates are disabled.");
+        Assert.equal(addon.permissions, AddonManager.PERM_CAN_UNINSTALL,
+                     "Permissions are minimal.");
+
+        // Setting applyBackgroundUpdates should not work.
+        addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_ENABLE;
+        Assert.equal(addon.applyBackgroundUpdates, AddonManager.AUTOUPDATE_DISABLE,
+                     "Setting applyBackgroundUpdates shouldn't do anything.");
+
+        let noCompatibleCalled = false;
+        let noUpdateCalled = false;
+        let finishedCalled = false;
+
+        let listener = {
+          onNoCompatibilityUpdateAvailable: () => { noCompatibleCalled = true; },
+          onNoUpdateAvailable: () => { noUpdateCalled = true; },
+          onUpdateFinished: () => { finishedCalled = true; },
+        };
+
+        addon.findUpdates(listener, "testing", null, null);
+        Assert.ok(noCompatibleCalled, "Listener called.");
+        Assert.ok(noUpdateCalled, "Listener called.");
+        Assert.ok(finishedCalled, "Listener called.");
+
+        run_next_test();
+      });
+    });
+  });
+});
+
+// Changes to userDisabled should not be persisted to the database.
+add_test(function test_userDisabledNotPersisted() {
+  AddonManager.getAddonByID("experiment1@tests.mozilla.org", (addon) => {
+    Assert.ok(addon, "Addon is found.");
+
+    let listener = {
+      onEnabled: (addon2) => {
+        Assert.equal(addon2.id, addon.id, "Changed add-on matches expected.");
+        Assert.ok(addon2.isActive, "Add-on is no longer disabled.");
+
+        Assert.ok("experiment1@tests.mozilla.org" in XPIProvider.bootstrappedAddons,
+                  "Experiment add-on listed in XPIProvider bootstrapped list.");
+
+        AddonManager.getAddonByID("experiment1@tests.mozilla.org", (addon) => {
+          Assert.ok(addon, "Add-on retrieved.");
+          Assert.ok(addon.userDisabled, "Add-on is disabled according to database.");
+
+          restartManager();
+          let persisted = JSON.parse(Services.prefs.getCharPref("extensions.bootstrappedAddons"));
+          Assert.ok(!("experiment1@tests.mozilla.org" in persisted),
+                    "Experiment add-on not persisted to bootstrappedAddons.");
+
+          AddonManager.getAddonByID("experiment1@tests.mozilla.org", (addon) => {
+            Assert.ok(addon, "Add-on retrieved.");
+            Assert.ok(addon.userDisabled, "Add-on is disabled after restart.");
+
+            run_next_test();
+          });
+        });
+      },
+    };
+
+    AddonManager.addAddonListener(listener);
+    addon.userDisabled = false;
+  });
+});
--- a/toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
@@ -12,17 +12,18 @@ const IGNORE = ["escapeAddonURI", "shoul
                 "mapURIToAddonID"];
 
 const IGNORE_PRIVATE = ["AddonAuthor", "AddonCompatibilityOverride",
                         "AddonScreenshot", "AddonType", "startup", "shutdown",
                         "registerProvider", "unregisterProvider",
                         "addStartupChange", "removeStartupChange",
                         "recordTimestamp", "recordSimpleMeasure",
                         "recordException", "getSimpleMeasures", "simpleTimer",
-                        "setTelemetryDetails", "getTelemetryDetails"];
+                        "setTelemetryDetails", "getTelemetryDetails",
+                        "callNoUpdateListeners"];
 
 function test_functions() {
   for (let prop in AddonManager) {
     if (typeof AddonManager[prop] != "function")
       continue;
     if (IGNORE.indexOf(prop) != -1)
       continue;
 
--- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell-shared.ini
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell-shared.ini
@@ -158,16 +158,17 @@ fail-if = os == "android"
 [test_distribution.js]
 [test_dss.js]
 # Bug 676992: test consistently fails on Android
 fail-if = os == "android"
 [test_duplicateplugins.js]
 # Bug 676992: test consistently hangs on Android
 skip-if = os == "android"
 [test_error.js]
+[test_experiment.js]
 [test_filepointer.js]
 # Bug 676992: test consistently hangs on Android
 skip-if = os == "android"
 [test_fuel.js]
 [test_general.js]
 [test_getresource.js]
 [test_gfxBlacklist_Device.js]
 [test_gfxBlacklist_DriverNew.js]