/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* Any copyright is dedicated to the Public Domain.
 * http://creativecommons.org/publicdomain/zero/1.0/ */

"use strict";

// shared-head.js handles imports, constants, and utility functions
Services.scriptloader.loadSubScript("chrome://mochitests/content/browser/devtools/client/framework/test/shared-head.js", this);

// Disable logging for faster test runs. Set this pref to true if you want to
// debug a test in your try runs. Both the debugger server and frontend will
// be affected by this pref.
var gEnableLogging = Services.prefs.getBoolPref("devtools.debugger.log");
Services.prefs.setBoolPref("devtools.debugger.log", false);

var { BrowserToolboxProcess } = Cu.import("resource://devtools/client/framework/ToolboxProcess.jsm", {});
var { DebuggerServer } = require("devtools/server/main");
var { DebuggerClient, ObjectClient } = require("devtools/shared/client/main");
var { AddonManager } = Cu.import("resource://gre/modules/AddonManager.jsm", {});
var EventEmitter = require("devtools/shared/event-emitter");
var { Toolbox } = require("devtools/client/framework/toolbox");

const chromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIChromeRegistry);

// Override promise with deprecated-sync-thenables
promise = Cu.import("resource://devtools/shared/deprecated-sync-thenables.js", {}).Promise;

const EXAMPLE_URL = "http://example.com/browser/devtools/client/debugger/test/mochitest/";
const FRAME_SCRIPT_URL = getRootDirectory(gTestPath) + "code_frame-script.js";
const CHROME_URL = "chrome://mochitests/content/browser/devtools/client/debugger/test/mochitest/";
const CHROME_URI = Services.io.newURI(CHROME_URL, null, null);

registerCleanupFunction(function* () {
  info("finish() was called, cleaning up...");
  Services.prefs.setBoolPref("devtools.debugger.log", gEnableLogging);

  while (gBrowser && gBrowser.tabs && gBrowser.tabs.length > 1) {
    info("Destroying toolbox.");
    let target = TargetFactory.forTab(gBrowser.selectedTab);
    yield gDevTools.closeToolbox(target);

    info("Removing tab.");
    gBrowser.removeCurrentTab();
  }

  // Properly shut down the server to avoid memory leaks.
  DebuggerServer.destroy();

  // Debugger tests use a lot of memory, so force a GC to help fragmentation.
  info("Forcing GC after debugger test.");
  Cu.forceGC();
});

// Import the GCLI test helper
var testDir = gTestPath.substr(0, gTestPath.lastIndexOf("/"));
testDir = testDir.replace(/\/\//g, "/");
testDir = testDir.replace("chrome:/mochitest", "chrome://mochitest");
var helpersjs = testDir + "/../../../commandline/test/helpers.js";
Services.scriptloader.loadSubScript(helpersjs, this);

function addWindow(aUrl) {
  info("Adding window: " + aUrl);
  return promise.resolve(getChromeWindow(window.open(aUrl)));
}

function getChromeWindow(aWindow) {
  return aWindow
    .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation)
    .QueryInterface(Ci.nsIDocShellTreeItem).rootTreeItem
    .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow);
}

// Override addTab/removeTab as defined by shared-head, since these have
// an extra window parameter and add a frame script
this.addTab = 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;

  info("Loading frame script with url " + FRAME_SCRIPT_URL + ".");
  linkedBrowser.messageManager.loadFrameScript(FRAME_SCRIPT_URL, false);

  linkedBrowser.addEventListener("load", function onLoad() {
    linkedBrowser.removeEventListener("load", onLoad, true);
    info("Tab added and finished loading: " + aUrl);
    deferred.resolve(tab);
  }, true);

  return deferred.promise;
};

this.removeTab = 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 getAddonURIFromPath(aPath) {
  let chromeURI = Services.io.newURI(aPath, null, CHROME_URI);
  return chromeRegistry.convertChromeURL(chromeURI).QueryInterface(Ci.nsIFileURL);
}

function getTemporaryAddonURLFromPath(aPath) {
  return getAddonURIFromPath(aPath).spec;
}

function addTemporaryAddon(aPath) {
  let addonFile = getAddonURIFromPath(aPath).file;
  info("Installing addon: " + addonFile.path);

  return AddonManager.installTemporaryAddon(addonFile);
}

function removeAddon(aAddon) {
  info("Removing addon.");

  let deferred = promise.defer();

  let listener = {
    onUninstalled: function (aUninstalledAddon) {
      if (aUninstalledAddon != aAddon) {
        return;
      }
      AddonManager.removeAddonListener(listener);
      deferred.resolve();
    }
  };
  AddonManager.addAddonListener(listener);
  aAddon.uninstall();

  return deferred.promise;
}

function getTabActorForUrl(aClient, aUrl) {
  let deferred = promise.defer();

  aClient.listTabs(aResponse => {
    let tabActor = aResponse.tabs.filter(aGrip => aGrip.url == aUrl).pop();
    deferred.resolve(tabActor);
  });

  return deferred.promise;
}

function getAddonActorForId(aClient, aAddonId) {
  info("Get addon actor for ID: " + aAddonId);
  let deferred = promise.defer();

  aClient.listAddons(aResponse => {
    let addonActor = aResponse.addons.filter(aGrip => aGrip.id == aAddonId).pop();
    info("got addon actor for ID: " + aAddonId);
    deferred.resolve(addonActor);
  });

  return deferred.promise;
}

function attachTabActorForUrl(aClient, aUrl) {
  let deferred = promise.defer();

  getTabActorForUrl(aClient, aUrl).then(aGrip => {
    aClient.attachTab(aGrip.actor, aResponse => {
      deferred.resolve([aGrip, aResponse]);
    });
  });

  return deferred.promise;
}

function attachThreadActorForUrl(aClient, aUrl) {
  let deferred = promise.defer();

  attachTabActorForUrl(aClient, aUrl).then(([aGrip, aResponse]) => {
    aClient.attachThread(aResponse.threadActor, (aResponse, aThreadClient) => {
      aThreadClient.resume(aResponse => {
        deferred.resolve(aThreadClient);
      });
    });
  });

  return deferred.promise;
}

function once(aTarget, aEventName, aUseCapture = false) {
  info("Waiting for event: '" + aEventName + "' on " + aTarget + ".");

  let deferred = promise.defer();

  for (let [add, remove] of [
    ["addEventListener", "removeEventListener"],
    ["addListener", "removeListener"],
    ["on", "off"]
  ]) {
    if ((add in aTarget) && (remove in aTarget)) {
      aTarget[add](aEventName, function onEvent(...aArgs) {
        aTarget[remove](aEventName, onEvent, aUseCapture);
        deferred.resolve.apply(deferred, aArgs);
      }, aUseCapture);
      break;
    }
  }

  return deferred.promise;
}

function waitForTick() {
  let deferred = promise.defer();
  executeSoon(deferred.resolve);
  return deferred.promise;
}

function waitForTime(aDelay) {
  let deferred = promise.defer();
  setTimeout(deferred.resolve, aDelay);
  return deferred.promise;
}

function waitForSourceLoaded(aPanel, aUrl) {
  let { Sources } = aPanel.panelWin.DebuggerView;
  let isLoaded = Sources.items.some(item =>
    item.attachment.source.url === aUrl);
  if (isLoaded) {
    info("The correct source has been loaded.");
    return promise.resolve(null);
  } else {
    return waitForDebuggerEvents(aPanel, aPanel.panelWin.EVENTS.NEW_SOURCE).then(() => {
      // Wait for it to be loaded in the UI and appear into Sources.items.
      return waitForTick();
    }).then(() => {
      return waitForSourceLoaded(aPanel, aUrl);
    });
  }

}

function waitForSourceShown(aPanel, aUrl) {
  return waitForDebuggerEvents(aPanel, aPanel.panelWin.EVENTS.SOURCE_SHOWN).then(aSource => {
    let sourceUrl = aSource.url || aSource.introductionUrl;
    info("Source shown: " + sourceUrl);

    if (!sourceUrl.includes(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);
}

function ensureSourceIs(aPanel, aUrlOrSource, aWaitFlag = false) {
  let sources = aPanel.panelWin.DebuggerView.Sources;

  if (sources.selectedValue === aUrlOrSource ||
      (sources.selectedItem &&
       sources.selectedItem.attachment.source.url.includes(aUrlOrSource))) {
    ok(true, "Expected source is shown: " + aUrlOrSource);
    return promise.resolve(null);
  }
  if (aWaitFlag) {
    return waitForSourceShown(aPanel, aUrlOrSource);
  }
  ok(false, "Expected source was not already shown: " + aUrlOrSource);
  return promise.reject(null);
}

function waitForCaretUpdated(aPanel, aLine, aCol = 1) {
  return waitForEditorEvents(aPanel, "cursorActivity").then(() => {
    let cursor = aPanel.panelWin.DebuggerView.editor.getCursor();
    info("Caret updated: " + (cursor.line + 1) + ", " + (cursor.ch + 1));

    if (!isCaretPos(aPanel, aLine, aCol)) {
      return waitForCaretUpdated(aPanel, aLine, aCol);
    } else {
      ok(true, "The correct caret position has been set.");
    }
  });
}

function ensureCaretAt(aPanel, aLine, aCol = 1, aWaitFlag = false) {
  if (isCaretPos(aPanel, aLine, aCol)) {
    ok(true, "Expected caret position is set: " + aLine + "," + aCol);
    return promise.resolve(null);
  }
  if (aWaitFlag) {
    return waitForCaretUpdated(aPanel, aLine, aCol);
  }
  ok(false, "Expected caret position was not already set: " + aLine + "," + aCol);
  return promise.reject(null);
}

function isCaretPos(aPanel, aLine, aCol = 1) {
  let editor = aPanel.panelWin.DebuggerView.editor;
  let cursor = editor.getCursor();

  // Source editor starts counting line and column numbers from 0.
  info("Current editor caret position: " + (cursor.line + 1) + ", " + (cursor.ch + 1));
  return cursor.line == (aLine - 1) && cursor.ch == (aCol - 1);
}

function isDebugPos(aPanel, aLine) {
  let editor = aPanel.panelWin.DebuggerView.editor;
  let location = editor.getDebugLocation();

  // Source editor starts counting line and column numbers from 0.
  info("Current editor debug position: " + (location + 1));
  return location != null && editor.hasLineClass(aLine - 1, "debug-line");
}

function isEditorSel(aPanel, [start, end]) {
  let editor = aPanel.panelWin.DebuggerView.editor;
  let range = {
    start: editor.getOffset(editor.getCursor("start")),
    end:   editor.getOffset(editor.getCursor())
  };

  // Source editor starts counting line and column numbers from 0.
  info("Current editor selection: " + (range.start + 1) + ", " + (range.end + 1));
  return range.start == (start - 1) && range.end == (end - 1);
}

function waitForSourceAndCaret(aPanel, aUrl, aLine, aCol) {
  return promise.all([
    waitForSourceShown(aPanel, aUrl),
    waitForCaretUpdated(aPanel, aLine, aCol)
  ]);
}

function waitForCaretAndScopes(aPanel, aLine, aCol) {
  return promise.all([
    waitForCaretUpdated(aPanel, aLine, aCol),
    waitForDebuggerEvents(aPanel, aPanel.panelWin.EVENTS.FETCHED_SCOPES)
  ]);
}

function waitForSourceAndCaretAndScopes(aPanel, aUrl, aLine, aCol) {
  return promise.all([
    waitForSourceAndCaret(aPanel, aUrl, aLine, aCol),
    waitForDebuggerEvents(aPanel, aPanel.panelWin.EVENTS.FETCHED_SCOPES)
  ]);
}

function waitForDebuggerEvents(aPanel, aEventName, aEventRepeat = 1) {
  info("Waiting for debugger event: '" + aEventName + "' to fire: " + aEventRepeat + " time(s).");

  let deferred = promise.defer();
  let panelWin = aPanel.panelWin;
  let count = 0;

  panelWin.on(aEventName, function onEvent(aEventName, ...aArgs) {
    info("Debugger event '" + aEventName + "' fired: " + (++count) + " time(s).");

    if (count == aEventRepeat) {
      ok(true, "Enough '" + aEventName + "' panel events have been fired.");
      panelWin.off(aEventName, onEvent);
      deferred.resolve.apply(deferred, aArgs);
    }
  });

  return deferred.promise;
}

function waitForEditorEvents(aPanel, aEventName, aEventRepeat = 1) {
  info("Waiting for editor event: '" + aEventName + "' to fire: " + aEventRepeat + " time(s).");

  let deferred = promise.defer();
  let editor = aPanel.panelWin.DebuggerView.editor;
  let count = 0;

  editor.on(aEventName, function onEvent(...aArgs) {
    info("Editor event '" + aEventName + "' fired: " + (++count) + " time(s).");

    if (count == aEventRepeat) {
      ok(true, "Enough '" + aEventName + "' editor events have been fired.");
      editor.off(aEventName, onEvent);
      deferred.resolve.apply(deferred, aArgs);
    }
  });

  return deferred.promise;
}

function waitForThreadEvents(aPanel, aEventName, aEventRepeat = 1) {
  info("Waiting for thread event: '" + aEventName + "' to fire: " + aEventRepeat + " time(s).");

  let deferred = promise.defer();
  let thread = aPanel.panelWin.gThreadClient;
  let count = 0;

  thread.addListener(aEventName, function onEvent(aEventName, ...aArgs) {
    info("Thread event '" + aEventName + "' fired: " + (++count) + " time(s).");

    if (count == aEventRepeat) {
      ok(true, "Enough '" + aEventName + "' thread events have been fired.");
      thread.removeListener(aEventName, onEvent);
      deferred.resolve.apply(deferred, aArgs);
    }
  });

  return deferred.promise;
}

function waitForClientEvents(aPanel, aEventName, aEventRepeat = 1) {
  info("Waiting for client event: '" + aEventName + "' to fire: " + aEventRepeat + " time(s).");

  let deferred = promise.defer();
  let client = aPanel.panelWin.gClient;
  let count = 0;

  client.addListener(aEventName, function onEvent(aEventName, ...aArgs) {
    info("Thread event '" + aEventName + "' fired: " + (++count) + " time(s).");

    if (count == aEventRepeat) {
      ok(true, "Enough '" + aEventName + "' thread events have been fired.");
      client.removeListener(aEventName, onEvent);
      deferred.resolve.apply(deferred, aArgs);
    }
  });

  return deferred.promise;
}

function waitForClipboardPromise(setup, expected) {
  return new Promise((resolve, reject) => {
    SimpleTest.waitForClipboard(expected, setup, resolve, reject);
  });
}

function ensureThreadClientState(aPanel, aState) {
  let thread = aPanel.panelWin.gThreadClient;
  let state = thread.state;

  info("Thread is: '" + state + "'.");

  if (state == aState) {
    return promise.resolve(null);
  } else {
    return waitForThreadEvents(aPanel, aState);
  }
}

function reload(aPanel, aUrl) {
  let activeTab = aPanel.panelWin.DebuggerController._target.activeTab;
  aUrl ? activeTab.navigateTo(aUrl) : activeTab.reload();
}

function navigateActiveTabTo(aPanel, aUrl, aWaitForEventName, aEventRepeat) {
  let finished = waitForDebuggerEvents(aPanel, aWaitForEventName, aEventRepeat);
  reload(aPanel, aUrl);
  return finished;
}

function navigateActiveTabInHistory(aPanel, aDirection, aWaitForEventName, aEventRepeat) {
  let finished = waitForDebuggerEvents(aPanel, aWaitForEventName, aEventRepeat);
  content.history[aDirection]();
  return finished;
}

function reloadActiveTab(aPanel, aWaitForEventName, aEventRepeat) {
  return navigateActiveTabTo(aPanel, null, aWaitForEventName, aEventRepeat);
}

function clearText(aElement) {
  info("Clearing text...");
  aElement.focus();
  aElement.value = "";
}

function setText(aElement, aText) {
  clearText(aElement);
  info("Setting text: " + aText);
  aElement.value = aText;
}

function typeText(aElement, aText) {
  info("Typing text: " + aText);
  aElement.focus();
  EventUtils.sendString(aText, aElement.ownerDocument.defaultView);
}

function backspaceText(aElement, aTimes) {
  info("Pressing backspace " + aTimes + " times.");
  for (let i = 0; i < aTimes; i++) {
    aElement.focus();
    EventUtils.sendKey("BACK_SPACE", aElement.ownerDocument.defaultView);
  }
}

function getTab(aTarget, aWindow) {
  if (aTarget instanceof XULElement) {
    return promise.resolve(aTarget);
  } else {
    return addTab(aTarget, aWindow);
  }
}

function getSources(aClient) {
  info("Getting sources.");

  let deferred = promise.defer();

  aClient.getSources((packet) => {
    deferred.resolve(packet.sources);
  });

  return deferred.promise;
}

/**
 * Optionaly open a new tab and then open the debugger panel.
 * The returned promise resolves only one the panel is fully set.

 * @param {String|xul:tab} urlOrTab
 *   If a string, consider it as the url of the tab to open before opening the
 *   debugger panel.
 *   Otherwise, if a <xul:tab>, do nothing, but open the debugger panel against
 *   the given tab.
 * @param {Object} options
 *   Set of optional arguments:
 *   - {String} source
 *     If given, assert the default loaded source once the debugger is loaded.
 *     This string can be partial to only match a part of the source name.
 *     If null, do not expect any source and skip SOURCE_SHOWN wait.
 *   - {Number} line
 *     If given, wait for the caret to be set on a precise line
 *
 * @return {Promise}
 *   Resolves once debugger panel is fully set according to the given options.
 */
let initDebugger = Task.async(function*(urlOrTab, options) {
  let { window, source, line } = options || {};
  info("Initializing a debugger panel.");

  let tab, url;
  if (urlOrTab instanceof XULElement) {
    // `urlOrTab` Is a Tab.
    tab = urlOrTab;
  } else {
    // `urlOrTab` is an url. Open an empty tab first in order to load the page
    // only once the panel is ready. That to be able to safely catch the
    // SOURCE_SHOWN event.
    tab = yield addTab("about:blank", window);
    url = urlOrTab;
  }
  info("Debugee tab added successfully: " + urlOrTab);

  let debuggee = tab.linkedBrowser.contentWindow.wrappedJSObject;
  let target = TargetFactory.forTab(tab);

  let toolbox = yield gDevTools.showToolbox(target, "jsdebugger");
  info("Debugger panel shown successfully.");

  let debuggerPanel = toolbox.getCurrentPanel();
  let panelWin = debuggerPanel.panelWin;
  let { Sources } = panelWin.DebuggerView;

  prepareDebugger(debuggerPanel);

  if (url && url != "about:blank") {
    let onCaretUpdated;
    if (line) {
      onCaretUpdated = waitForCaretUpdated(debuggerPanel, line);
    }
    if (source === null) {
      // When there is no source in the document, we shouldn't wait for
      // SOURCE_SHOWN event
      yield reload(debuggerPanel, url);
    } else {
      yield navigateActiveTabTo(debuggerPanel,
                                url,
                                panelWin.EVENTS.SOURCE_SHOWN);
    }
    if (source) {
      let isSelected = Sources.selectedItem.attachment.source.url === source;
      if (!isSelected) {
        // Ensure that the source is loaded first before trying to select it
        yield waitForSourceLoaded(debuggerPanel, source);
        // Select the js file.
        let onSource = waitForSourceAndCaret(debuggerPanel, source, line ? line : 1);
        Sources.selectedValue = getSourceActor(Sources, source);
        yield onSource;
      }
    }
    yield onCaretUpdated;
  }

  return [tab, debuggee, debuggerPanel, window];
});

// Creates an add-on debugger for a given add-on. The returned AddonDebugger
// object must be destroyed before finishing the test
function initAddonDebugger(aAddonId) {
  let addonDebugger = new AddonDebugger();
  return addonDebugger.init(aAddonId).then(() => addonDebugger);
}

function AddonDebugger() {
  this._onMessage = this._onMessage.bind(this);
  this._onConsoleAPICall = this._onConsoleAPICall.bind(this);
  EventEmitter.decorate(this);
}

AddonDebugger.prototype = {
  init: Task.async(function* (aAddonId) {
    info("Initializing an addon debugger panel.");

    if (!DebuggerServer.initialized) {
      DebuggerServer.init();
      DebuggerServer.addBrowserActors();
    }
    DebuggerServer.allowChromeProcess = true;

    this.frame = document.createElement("iframe");
    this.frame.setAttribute("height", 400);
    document.documentElement.appendChild(this.frame);
    window.addEventListener("message", this._onMessage);

    let transport = DebuggerServer.connectPipe();
    this.client = new DebuggerClient(transport);

    yield this.client.connect();

    let addonActor = yield getAddonActorForId(this.client, aAddonId);

    let targetOptions = {
      form: addonActor,
      client: this.client,
      chrome: true,
      isTabActor: false
    };

    let toolboxOptions = {
      customIframe: this.frame
    };

    this.target = TargetFactory.forTab(targetOptions);
    let toolbox = yield gDevTools.showToolbox(this.target, "jsdebugger", Toolbox.HostType.CUSTOM, toolboxOptions);

    info("Addon debugger panel shown successfully.");

    this.debuggerPanel = toolbox.getCurrentPanel();
    yield waitForSourceShown(this.debuggerPanel, "");

    prepareDebugger(this.debuggerPanel);
    yield this._attachConsole();
  }),

  destroy: Task.async(function* () {
    let deferred = promise.defer();
    this.client.close(deferred.resolve);
    yield deferred.promise;
    yield this.debuggerPanel._toolbox.destroy();
    this.frame.remove();
    window.removeEventListener("message", this._onMessage);
  }),

  _attachConsole: function () {
    let deferred = promise.defer();
    this.client.attachConsole(this.target.form.consoleActor, ["ConsoleAPI"], (aResponse, aWebConsoleClient) => {
      if (aResponse.error) {
        deferred.reject(aResponse);
      }
      else {
        this.webConsole = aWebConsoleClient;
        this.client.addListener("consoleAPICall", this._onConsoleAPICall);
        deferred.resolve();
      }
    });
    return deferred.promise;
  },

  _onConsoleAPICall: function (aType, aPacket) {
    if (aPacket.from != this.webConsole.actor)
      return;
    this.emit("console", aPacket.message);
  },

  /**
   * Returns a list of the groups and sources in the UI. The returned array
   * contains objects for each group with properties name and sources. The
   * sources property contains an array with objects for each source for that
   * group with properties label and url.
   */
  getSourceGroups: Task.async(function* () {
    let debuggerWin = this.debuggerPanel.panelWin;
    let sources = yield getSources(debuggerWin.gThreadClient);
    ok(sources.length, "retrieved sources");

    // groups will be the return value, groupmap and the maps we put in it will
    // be used as quick lookups to add the url information in below
    let groups = [];
    let groupmap = new Map();

    let uigroups = this.debuggerPanel.panelWin.document.querySelectorAll(".side-menu-widget-group");
    for (let g of uigroups) {
      let name = g.querySelector(".side-menu-widget-group-title .name").value;
      let group = {
        name: name,
        sources: []
      };
      groups.push(group);
      let labelmap = new Map();
      groupmap.set(name, labelmap);

      for (let l of g.querySelectorAll(".dbg-source-item")) {
        let source = {
          label: l.value,
          url: null
        };

        labelmap.set(l.value, source);
        group.sources.push(source);
      }
    }

    for (let source of sources) {
      let { label, group } = debuggerWin.DebuggerView.Sources.getItemByValue(source.actor).attachment;

      if (!groupmap.has(group)) {
        ok(false, "Saw a source group not in the UI: " + group);
        continue;
      }

      if (!groupmap.get(group).has(label)) {
        ok(false, "Saw a source label not in the UI: " + label);
        continue;
      }

      groupmap.get(group).get(label).url = source.url.split(" -> ").pop();
    }

    return groups;
  }),

  _onMessage: function (event) {
    let json = JSON.parse(event.data);
    switch (json.name) {
      case "toolbox-title":
        this.title = json.data.value;
        break;
    }
  }
};

function initChromeDebugger(aOnClose) {
  info("Initializing a chrome debugger process.");

  let deferred = promise.defer();

  // Wait for the toolbox process to start...
  BrowserToolboxProcess.init(aOnClose, (aEvent, aProcess) => {
    info("Browser toolbox process started successfully.");

    prepareDebugger(aProcess);
    deferred.resolve(aProcess);
  });

  return deferred.promise;
}

function prepareDebugger(aDebugger) {
  if ("target" in aDebugger) {
    let view = aDebugger.panelWin.DebuggerView;
    view.Variables.lazyEmpty = false;
    view.Variables.lazySearch = false;
    view.Filtering.FilteredSources._autoSelectFirstItem = true;
    view.Filtering.FilteredFunctions._autoSelectFirstItem = true;
  } else {
    // Nothing to do here yet.
  }
}

function teardown(aPanel, aFlags = {}) {
  info("Destroying the specified debugger.");

  let toolbox = aPanel._toolbox;
  let tab = aPanel.target.tab;
  let debuggerRootActorDisconnected = once(window, "Debugger:Shutdown");
  let debuggerPanelDestroyed = once(aPanel, "destroyed");
  let devtoolsToolboxDestroyed = toolbox.destroy();

  return promise.all([
    debuggerRootActorDisconnected,
    debuggerPanelDestroyed,
    devtoolsToolboxDestroyed
  ]).then(() => aFlags.noTabRemoval ? null : removeTab(tab));
}

function closeDebuggerAndFinish(aPanel, aFlags = {}) {
  let thread = aPanel.panelWin.gThreadClient;
  if (thread.state == "paused" && !aFlags.whilePaused) {
    ok(false, "You should use 'resumeDebuggerThenCloseAndFinish' instead, " +
              "unless you're absolutely sure about what you're doing.");
  }
  return teardown(aPanel, aFlags).then(finish);
}

function resumeDebuggerThenCloseAndFinish(aPanel, aFlags = {}) {
  let deferred = promise.defer();
  let thread = aPanel.panelWin.gThreadClient;
  thread.resume(() => closeDebuggerAndFinish(aPanel, aFlags).then(deferred.resolve));
  return deferred.promise;
}

// Blackboxing helpers

function getBlackBoxButton(aPanel) {
  return aPanel.panelWin.document.getElementById("black-box");
}

/**
 * Returns the node that has the black-boxed class applied to it.
 */
function getSelectedSourceElement(aPanel) {
  return aPanel.panelWin.DebuggerView.Sources.selectedItem.prebuiltNode;
}

function toggleBlackBoxing(aPanel, aSourceActor = null) {
  function clickBlackBoxButton() {
    getBlackBoxButton(aPanel).click();
  }

  const blackBoxChanged = waitForDispatch(
    aPanel,
    aPanel.panelWin.constants.BLACKBOX
  ).then(() => {
    return aSourceActor ?
      getSource(aPanel, aSourceActor) :
      getSelectedSource(aPanel);
  });

  if (aSourceActor) {
    aPanel.panelWin.DebuggerView.Sources.selectedValue = aSourceActor;
    ensureSourceIs(aPanel, aSourceActor, true).then(clickBlackBoxButton);
  } else {
    clickBlackBoxButton();
  }

  return blackBoxChanged;
}

function selectSourceAndGetBlackBoxButton(aPanel, aUrl) {
  function returnBlackboxButton() {
    return getBlackBoxButton(aPanel);
  }

  let sources = aPanel.panelWin.DebuggerView.Sources;
  sources.selectedValue = getSourceActor(sources, aUrl);
  return ensureSourceIs(aPanel, aUrl, true).then(returnBlackboxButton);
}

// Variables view inspection popup helpers

function openVarPopup(aPanel, aCoords, aWaitForFetchedProperties) {
  let events = aPanel.panelWin.EVENTS;
  let editor = aPanel.panelWin.DebuggerView.editor;
  let bubble = aPanel.panelWin.DebuggerView.VariableBubble;
  let tooltip = bubble._tooltip.panel;

  let popupShown = once(tooltip, "popupshown");
  let fetchedProperties = aWaitForFetchedProperties
    ? waitForDebuggerEvents(aPanel, events.FETCHED_BUBBLE_PROPERTIES)
    : promise.resolve(null);
  let updatedFrame = waitForDebuggerEvents(aPanel, events.FETCHED_SCOPES);

  let { left, top } = editor.getCoordsFromPosition(aCoords);
  bubble._findIdentifier(left, top);
  return promise.all([popupShown, fetchedProperties, updatedFrame]).then(waitForTick);
}

// Simulates the mouse hovering a variable in the debugger
// Takes in account the position of the cursor in the text, if the text is
// selected and if a button is currently pushed (aButtonPushed > 0).
// The function returns a promise which returns true if the popup opened or
// false if it didn't
function intendOpenVarPopup(aPanel, aPosition, aButtonPushed) {
  let bubble = aPanel.panelWin.DebuggerView.VariableBubble;
  let editor = aPanel.panelWin.DebuggerView.editor;
  let tooltip = bubble._tooltip;

  let { left, top } = editor.getCoordsFromPosition(aPosition);

  const eventDescriptor = {
    clientX: left,
    clientY: top,
    buttons: aButtonPushed
  };

  bubble._onMouseMove(eventDescriptor);

  const deferred = promise.defer();
  window.setTimeout(
    function () {
      if (tooltip.isEmpty()) {
        deferred.resolve(false);
      } else {
        deferred.resolve(true);
      }
    },
    bubble.TOOLTIP_SHOW_DELAY + 1000
  );

  return deferred.promise;
}

function hideVarPopup(aPanel) {
  let bubble = aPanel.panelWin.DebuggerView.VariableBubble;
  let tooltip = bubble._tooltip.panel;

  let popupHiding = once(tooltip, "popuphiding");
  bubble.hideContents();
  return popupHiding.then(waitForTick);
}

function hideVarPopupByScrollingEditor(aPanel) {
  let editor = aPanel.panelWin.DebuggerView.editor;
  let bubble = aPanel.panelWin.DebuggerView.VariableBubble;
  let tooltip = bubble._tooltip.panel;

  let popupHiding = once(tooltip, "popuphiding");
  editor.setFirstVisibleLine(0);
  return popupHiding.then(waitForTick);
}

function reopenVarPopup(...aArgs) {
  return hideVarPopup.apply(this, aArgs).then(() => openVarPopup.apply(this, aArgs));
}

function attachAddonActorForId(aClient, aAddonId) {
  let deferred = promise.defer();

  getAddonActorForId(aClient, aAddonId).then(aGrip => {
    aClient.attachAddon(aGrip.actor, aResponse => {
      deferred.resolve([aGrip, aResponse]);
    });
  });

  return deferred.promise;
}

function doResume(aPanel) {
  const threadClient = aPanel.panelWin.gThreadClient;
  return threadClient.resume();
}

function doInterrupt(aPanel) {
  const threadClient = aPanel.panelWin.gThreadClient;
  return threadClient.interrupt();
}

function pushPrefs(...aPrefs) {
  let deferred = promise.defer();
  SpecialPowers.pushPrefEnv({"set": aPrefs}, deferred.resolve);
  return deferred.promise;
}

function popPrefs() {
  let deferred = promise.defer();
  SpecialPowers.popPrefEnv(deferred.resolve);
  return deferred.promise;
}

// Source helpers

function getSelectedSource(panel) {
  const win = panel.panelWin;
  return win.queries.getSelectedSource(win.DebuggerController.getState());
}

function getSource(panel, actor) {
  const win = panel.panelWin;
  return win.queries.getSource(win.DebuggerController.getState(), actor);
}

function getSelectedSourceURL(aSources) {
  return (aSources.selectedItem &&
          aSources.selectedItem.attachment.source.url);
}

function getSourceURL(aSources, aActor) {
  let item = aSources.getItemByValue(aActor);
  return item && item.attachment.source.url;
}

function getSourceActor(aSources, aURL) {
  let item = aSources.getItemForAttachment(a => a.source && a.source.url === aURL);
  return item && item.value;
}

function getSourceForm(aSources, aURL) {
  let item = aSources.getItemByValue(getSourceActor(aSources, aURL));
  return item.attachment.source;
}

var nextId = 0;

function jsonrpc(tab, method, params) {
  return new Promise(function (resolve, reject) {
    let currentId = nextId++;
    let messageManager = tab.linkedBrowser.messageManager;
    messageManager.sendAsyncMessage("jsonrpc", {
      method: method,
      params: params,
      id: currentId
    });
    messageManager.addMessageListener("jsonrpc", function listener(res) {
      const { data: { result, error, id } } = res;
      if (id !== currentId) {
        return;
      }

      messageManager.removeMessageListener("jsonrpc", listener);
      if (error != null) {
        reject(error);
      }

      resolve(result);
    });
  });
}

function callInTab(tab, name) {
  info("Calling function with name '" + name + "' in tab.");

  return jsonrpc(tab, "call", [name, Array.prototype.slice.call(arguments, 2)]);
}

function evalInTab(tab, string) {
  info("Evalling string in tab.");

  return jsonrpc(tab, "_eval", [string]);
}

function createWorkerInTab(tab, url) {
  info("Creating worker with url '" + url + "' in tab.");

  return jsonrpc(tab, "createWorker", [url]);
}

function terminateWorkerInTab(tab, url) {
  info("Terminating worker with url '" + url + "' in tab.");

  return jsonrpc(tab, "terminateWorker", [url]);
}

function postMessageToWorkerInTab(tab, url, message) {
  info("Posting message to worker with url '" + url + "' in tab.");

  return jsonrpc(tab, "postMessageToWorker", [url, message]);
}

function generateMouseClickInTab(tab, path) {
  info("Generating mouse click in tab.");

  return jsonrpc(tab, "generateMouseClick", [path]);
}

function connect(client) {
  info("Connecting client.");
  return client.connect();
}

function close(client) {
  info("Waiting for client to close.\n");
  return new Promise(function (resolve) {
    client.close(() => {
      resolve();
    });
  });
}

function listTabs(client) {
  info("Listing tabs.");
  return client.listTabs();
}

function findTab(tabs, url) {
  info("Finding tab with url '" + url + "'.");
  for (let tab of tabs) {
    if (tab.url === url) {
      return tab;
    }
  }
  return null;
}

function attachTab(client, tab) {
  info("Attaching to tab with url '" + tab.url + "'.");
  return new Promise(function (resolve) {
    client.attachTab(tab.actor, function (response, tabClient) {
      resolve([response, tabClient]);
    });
  });
}

function listWorkers(tabClient) {
  info("Listing workers.");
  return new Promise(function (resolve) {
    tabClient.listWorkers(function (response) {
      resolve(response);
    });
  });
}

function findWorker(workers, url) {
  info("Finding worker with url '" + url + "'.");
  for (let worker of workers) {
    if (worker.url === url) {
      return worker;
    }
  }
  return null;
}

function attachWorker(tabClient, worker) {
  info("Attaching to worker with url '" + worker.url + "'.");
  return new Promise(function (resolve, reject) {
    tabClient.attachWorker(worker.actor, function (response, workerClient) {
      resolve([response, workerClient]);
    });
  });
}

function waitForWorkerListChanged(tabClient) {
  info("Waiting for worker list to change.");
  return new Promise(function (resolve) {
    tabClient.addListener("workerListChanged", function listener() {
      tabClient.removeListener("workerListChanged", listener);
      resolve();
    });
  });
}

function attachThread(workerClient, options) {
  info("Attaching to thread.");
  return new Promise(function (resolve, reject) {
    workerClient.attachThread(options, function (response, threadClient) {
      resolve([response, threadClient]);
    });
  });
}

function waitForWorkerClose(workerClient) {
  info("Waiting for worker to close.");
  return new Promise(function (resolve) {
    workerClient.addOneTimeListener("close", function () {
      info("Worker did close.");
      resolve();
    });
  });
}

function resume(threadClient) {
  info("Resuming thread.");
  return threadClient.resume();
}

function findSource(sources, url) {
  info("Finding source with url '" + url + "'.\n");
  for (let source of sources) {
    if (source.url === url) {
      return source;
    }
  }
  return null;
}

function waitForEvent(client, type, predicate) {
  return new Promise(function (resolve) {
    function listener(type, packet) {
      if (!predicate(packet)) {
        return;
      }
      client.removeListener(listener);
      resolve(packet);
    }

    if (predicate) {
      client.addListener(type, listener);
    } else {
      client.addOneTimeListener(type, function (type, packet) {
        resolve(packet);
      });
    }
  });
}

function waitForPause(threadClient) {
  info("Waiting for pause.\n");
  return waitForEvent(threadClient, "paused");
}

function setBreakpoint(sourceClient, location) {
  info("Setting breakpoint.\n");
  return sourceClient.setBreakpoint(location);
}

function source(sourceClient) {
  info("Getting source.\n");
  return sourceClient.source();
}

// Return a promise with a reference to jsterm, opening the split
// console if necessary.  This cleans up the split console pref so
// it won't pollute other tests.
function getSplitConsole(toolbox, win) {
  registerCleanupFunction(() => {
    Services.prefs.clearUserPref("devtools.toolbox.splitconsoleEnabled");
  });

  if (!win) {
    win = toolbox.win;
  }

  if (!toolbox.splitConsole) {
    EventUtils.synthesizeKey("VK_ESCAPE", {}, win);
  }

  return new Promise(resolve => {
    toolbox.getPanelWhenReady("webconsole").then(() => {
      ok(toolbox.splitConsole, "Split console is shown.");
      let jsterm = toolbox.getPanel("webconsole").hud.jsterm;
      resolve(jsterm);
    });
  });
}

// navigation

function waitForNavigation(gPanel) {
  const target = gPanel.panelWin.gTarget;
  const deferred = promise.defer();
  target.once("navigate", () => {
    deferred.resolve();
  });
  info("Waiting for navigation...");
  return deferred.promise;
}

// actions

function bindActionCreators(panel) {
  const win = panel.panelWin;
  const dispatch = win.DebuggerController.dispatch;
  const { bindActionCreators } = win.require("devtools/client/shared/vendor/redux");
  return bindActionCreators(win.actions, dispatch);
}

// Wait until an action of `type` is dispatched. This is different
// then `_afterDispatchDone` because it doesn't wait for async actions
// to be done/errored. Use this if you want to listen for the "start"
// action of an async operation (somewhat rare).
function waitForNextDispatch(store, type) {
  return new Promise(resolve => {
    store.dispatch({
      // Normally we would use `services.WAIT_UNTIL`, but use the
      // internal name here so tests aren't forced to always pass it
      // in
      type: "@@service/waitUntil",
      predicate: action => action.type === type,
      run: (dispatch, getState, action) => {
        resolve(action);
      }
    });
  });
}

// Wait until an action of `type` is dispatched. If it's part of an
// async operation, wait until the `status` field is "done" or "error"
function _afterDispatchDone(store, type) {
  return new Promise(resolve => {
    store.dispatch({
      // Normally we would use `services.WAIT_UNTIL`, but use the
      // internal name here so tests aren't forced to always pass it
      // in
      type: "@@service/waitUntil",
      predicate: action => {
        if (action.type === type) {
          return action.status ?
            (action.status === "done" || action.status === "error") :
            true;
        }
      },
      run: (dispatch, getState, action) => {
        resolve(action);
      }
    });
  });
}

function waitForDispatch(panel, type, eventRepeat = 1) {
  const controller = panel.panelWin.DebuggerController;
  const actionType = panel.panelWin.constants[type];
  let count = 0;

  return Task.spawn(function* () {
    info("Waiting for " + type + " to dispatch " + eventRepeat + " time(s)");
    while (count < eventRepeat) {
      yield _afterDispatchDone(controller, actionType);
      count++;
      info(type + " dispatched " + count + " time(s)");
    }
  });
}

function* initWorkerDebugger(TAB_URL, WORKER_URL) {
  if (!DebuggerServer.initialized) {
    DebuggerServer.init();
    DebuggerServer.addBrowserActors();
  }

  let client = new DebuggerClient(DebuggerServer.connectPipe());
  yield connect(client);

  let tab = yield addTab(TAB_URL);
  let { tabs } = yield listTabs(client);
  let [, tabClient] = yield attachTab(client, findTab(tabs, TAB_URL));

  yield createWorkerInTab(tab, WORKER_URL);

  let { workers } = yield listWorkers(tabClient);
  let [, workerClient] = yield attachWorker(tabClient,
                                             findWorker(workers, WORKER_URL));

  let toolbox = yield gDevTools.showToolbox(TargetFactory.forWorker(workerClient),
                                            "jsdebugger",
                                            Toolbox.HostType.WINDOW);

  let debuggerPanel = toolbox.getCurrentPanel();
  let gDebugger = debuggerPanel.panelWin;

  return {client, tab, tabClient, workerClient, toolbox, gDebugger};
}

