Bug 917226 - Build a canvas inspection tool, r=rcampbell, jryans
authorVictor Porof <vporof@mozilla.com>
Sat, 29 Mar 2014 13:01:37 -0400
changeset 176140 d5882d4e88887c07f152682d554f449f822b8cf1
parent 176139 5aed75c602ce1f1f55b9b2190ed88bb95146d325
child 176141 6040c2bfc7c3c25c99c0750801c392ed403734e2
push id26511
push userphilringnalda@gmail.com
push dateSun, 30 Mar 2014 23:31:59 +0000
treeherdermozilla-central@382f676d0ed9 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrcampbell, jryans
bugs917226
milestone31.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 917226 - Build a canvas inspection tool, r=rcampbell, jryans
browser/app/profile/firefox.js
browser/devtools/canvasdebugger/canvasdebugger.js
browser/devtools/canvasdebugger/canvasdebugger.xul
browser/devtools/canvasdebugger/moz.build
browser/devtools/canvasdebugger/panel.js
browser/devtools/canvasdebugger/test/browser.ini
browser/devtools/canvasdebugger/test/browser_canvas-actor-test-01.js
browser/devtools/canvasdebugger/test/browser_canvas-actor-test-02.js
browser/devtools/canvasdebugger/test/browser_canvas-actor-test-03.js
browser/devtools/canvasdebugger/test/browser_canvas-actor-test-04.js
browser/devtools/canvasdebugger/test/browser_canvas-actor-test-05.js
browser/devtools/canvasdebugger/test/browser_canvas-actor-test-06.js
browser/devtools/canvasdebugger/test/browser_canvas-actor-test-07.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-call-highlight.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-call-list.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-call-search.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-call-stack-01.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-call-stack-02.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-call-stack-03.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-clear.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-img-screenshots.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-img-thumbnails-01.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-img-thumbnails-02.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-open.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-record-01.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-record-02.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-record-03.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-reload-01.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-reload-02.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-slider-01.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-slider-02.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-snapshot-select.js
browser/devtools/canvasdebugger/test/browser_canvas-frontend-stepping.js
browser/devtools/canvasdebugger/test/doc_simple-canvas-deep-stack.html
browser/devtools/canvasdebugger/test/doc_simple-canvas-transparent.html
browser/devtools/canvasdebugger/test/doc_simple-canvas.html
browser/devtools/canvasdebugger/test/head.js
browser/devtools/canvasdebugger/test/moz.build
browser/devtools/debugger/debugger-view.js
browser/devtools/debugger/debugger.xul
browser/devtools/debugger/panel.js
browser/devtools/debugger/test/head.js
browser/devtools/jar.mn
browser/devtools/main.js
browser/devtools/moz.build
browser/devtools/netmonitor/netmonitor-controller.js
browser/devtools/netmonitor/panel.js
browser/devtools/shadereditor/moz.build
browser/devtools/shadereditor/panel.js
browser/devtools/shadereditor/shadereditor.js
browser/devtools/shared/widgets/ViewHelpers.jsm
browser/locales/en-US/chrome/browser/devtools/canvasdebugger.dtd
browser/locales/en-US/chrome/browser/devtools/canvasdebugger.properties
browser/locales/jar.mn
browser/themes/linux/devtools/canvasdebugger.css
browser/themes/linux/jar.mn
browser/themes/osx/devtools/canvasdebugger.css
browser/themes/osx/jar.mn
browser/themes/shared/devtools/canvasdebugger.inc.css
browser/themes/shared/devtools/toolbars.inc.css
browser/themes/shared/devtools/widgets.inc.css
browser/themes/windows/devtools/canvasdebugger.css
browser/themes/windows/jar.mn
toolkit/devtools/DevToolsUtils.js
toolkit/devtools/Loader.jsm
toolkit/devtools/content-observer.js
toolkit/devtools/server/actors/call-watcher.js
toolkit/devtools/server/actors/canvas.js
toolkit/devtools/server/actors/webgl.js
toolkit/devtools/server/main.js
--- 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);
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.
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.