Bug 917226 - Build a canvas inspection tool, r=rcampbell, jryans
authorVictor Porof <vporof@mozilla.com>
Sat, 29 Mar 2014 13:01:37 -0400
changeset 176010 d5882d4e88887c07f152682d554f449f822b8cf1
parent 176009 5aed75c602ce1f1f55b9b2190ed88bb95146d325
child 176104 6040c2bfc7c3c25c99c0750801c392ed403734e2
push id6018
push uservporof@mozilla.com
push dateSat, 29 Mar 2014 17:01:45 +0000
treeherderfx-team@d5882d4e8888 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrcampbell, jryans
bugs917226
milestone31.0a1
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.