/* Any copyright is dedicated to the Public Domain.
   http://creativecommons.org/publicdomain/zero/1.0/ */
/* eslint no-unused-vars: ["error", {"vars": "local"}] */
/* eslint-disable no-shadow */

"use strict";
var CC = Components.Constructor;

// Populate AppInfo before anything (like the shared loader) accesses
// System.appinfo, which is a lazy getter.
const appInfo = ChromeUtils.importESModule(
  "resource://testing-common/AppInfo.sys.mjs"
);
appInfo.updateAppInfo({
  ID: "devtools@tests.mozilla.org",
  name: "devtools-tests",
  version: "1",
  platformVersion: "42",
  crashReporter: true,
});

const { require, loader } = ChromeUtils.importESModule(
  "resource://devtools/shared/loader/Loader.sys.mjs"
);
const { worker } = ChromeUtils.importESModule(
  "resource://devtools/shared/loader/worker-loader.sys.mjs"
);

const { NetUtil } = ChromeUtils.importESModule(
  "resource://gre/modules/NetUtil.sys.mjs"
);

// Always log packets when running tests. runxpcshelltests.py will throw
// the output away anyway, unless you give it the --verbose flag.
Services.prefs.setBoolPref("devtools.debugger.log", false);
// Enable remote debugging for the relevant tests.
Services.prefs.setBoolPref("devtools.debugger.remote-enabled", true);

const makeDebugger = require("resource://devtools/server/actors/utils/make-debugger.js");
const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
const {
  ActorRegistry,
} = require("resource://devtools/server/actors/utils/actor-registry.js");
const {
  DevToolsServer,
} = require("resource://devtools/server/devtools-server.js");
const { DevToolsServer: WorkerDevToolsServer } = worker.require(
  "resource://devtools/server/devtools-server.js"
);
const {
  DevToolsClient,
} = require("resource://devtools/client/devtools-client.js");
const { ObjectFront } = require("resource://devtools/client/fronts/object.js");
const {
  LongStringFront,
} = require("resource://devtools/client/fronts/string.js");
const {
  createCommandsDictionary,
} = require("resource://devtools/shared/commands/index.js");
const {
  CommandsFactory,
} = require("resource://devtools/shared/commands/commands-factory.js");

const { addDebuggerToGlobal } = ChromeUtils.importESModule(
  "resource://gre/modules/jsdebugger.sys.mjs"
);

const { AddonTestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/AddonTestUtils.sys.mjs"
);
const { getAppInfo } = ChromeUtils.importESModule(
  "resource://testing-common/AppInfo.sys.mjs"
);

const systemPrincipal = Cc["@mozilla.org/systemprincipal;1"].createInstance(
  Ci.nsIPrincipal
);

var { loadSubScript, loadSubScriptWithOptions } = Services.scriptloader;

/**
 * The logic here must resemble the logic of --start-debugger-server as closely
 * as possible. DevToolsStartup.sys.mjs uses a distinct loader that results in
 * the existence of two isolated module namespaces. In practice, this can cause
 * bugs such as bug 1837185.
 */
function getDistinctDevToolsServer() {
  const {
    useDistinctSystemPrincipalLoader,
    releaseDistinctSystemPrincipalLoader,
  } = ChromeUtils.importESModule(
    "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs",
    { global: "shared" }
  );
  const requester = {};
  const distinctLoader = useDistinctSystemPrincipalLoader(requester);
  registerCleanupFunction(() => {
    releaseDistinctSystemPrincipalLoader(requester);
  });

  const { DevToolsServer: DistinctDevToolsServer } = distinctLoader.require(
    "resource://devtools/server/devtools-server.js"
  );
  return DistinctDevToolsServer;
}

/**
 * Initializes any test that needs to work with add-ons.
 *
 * Should be called once per test script that needs to use AddonTestUtils (and
 * not once per test task!).
 */
async function startupAddonsManager() {
  // Create a directory for extensions.
  const profileDir = do_get_profile().clone();
  profileDir.append("extensions");

  AddonTestUtils.init(globalThis);
  AddonTestUtils.overrideCertDB();
  AddonTestUtils.appInfo = getAppInfo();

  await AddonTestUtils.promiseStartupManager();
}

async function createTargetForFakeTab(title) {
  const client = await startTestDevToolsServer(title);

  const tabs = await listTabs(client);
  const tabDescriptor = findTab(tabs, title);

  // These xpcshell tests use mocked actors (xpcshell-test/testactors)
  // which still don't support watcher actor.
  // Because of that we still can't enable server side targets and target swiching.
  tabDescriptor.disableTargetSwitching();

  return tabDescriptor.getTarget();
}

async function createTargetForMainProcess() {
  const commands = await CommandsFactory.forMainProcess();
  return commands.descriptorFront.getTarget();
}

/**
 * Create a MemoryFront for a fake test tab.
 */
async function createTabMemoryFront() {
  const target = await createTargetForFakeTab("test_memory");

  // MemoryFront requires the HeadSnapshotActor actor to be available
  // as a global actor. This isn't registered by startTestDevToolsServer which
  // only register the target actors and not the browser ones.
  DevToolsServer.registerActors({ browser: true });

  const memoryFront = await target.getFront("memory");
  await memoryFront.attach();

  registerCleanupFunction(async () => {
    await memoryFront.detach();

    // On XPCShell, the target isn't for a local tab and so target.destroy
    // won't close the client. So do it so here. It will automatically destroy the target.
    await target.client.close();
  });

  return { target, memoryFront };
}

/**
 * Same as createTabMemoryFront but attaches the MemoryFront to the MemoryActor
 * scoped to the full runtime rather than to a tab.
 */
async function createMainProcessMemoryFront() {
  const target = await createTargetForMainProcess();

  const memoryFront = await target.getFront("memory");
  await memoryFront.attach();

  registerCleanupFunction(async () => {
    await memoryFront.detach();
    // For XPCShell, the main process target actor is ContentProcessTargetActor
    // which doesn't expose any `detach` method. So that the target actor isn't
    // destroyed when calling target.destroy.
    // Close the client to cleanup everything.
    await target.client.close();
  });

  return { client: target.client, memoryFront };
}

function createLongStringFront(conn, form) {
  // CAUTION -- do not replicate in the codebase. Instead, use marshalling
  // This code is simulating how the LongStringFront would be created by protocol.js
  // We should not use it like this in the codebase, this is done only for testing
  // purposes until we can return a proper LongStringFront from the server.
  const front = new LongStringFront(conn, form);
  front.actorID = form.actor;
  front.manage(front);
  return front;
}

function createTestGlobal(name, options) {
  const principal = Cc["@mozilla.org/systemprincipal;1"].createInstance(
    Ci.nsIPrincipal
  );
  // NOTE: The Sandbox constructor behaves differently based on the argument
  //       length.
  const sandbox = options
    ? Cu.Sandbox(principal, options)
    : Cu.Sandbox(principal);
  sandbox.__name = name;
  // Expose a few mocks to better represent a Window object.
  // These attributes will be used by DOCUMENT_EVENT resource listener.
  sandbox.performance = { timing: {} };
  sandbox.document = {
    readyState: "complete",
    defaultView: sandbox,
  };
  return sandbox;
}

function connect(client) {
  dump("Connecting client.\n");
  return client.connect();
}

function close(client) {
  dump("Closing client.\n");
  return client.close();
}

function listTabs(client) {
  dump("Listing tabs.\n");
  return client.mainRoot.listTabs();
}

function findTab(tabs, title) {
  dump("Finding tab with title '" + title + "'.\n");
  for (const tab of tabs) {
    if (tab.title === title) {
      return tab;
    }
  }
  return null;
}

function waitForNewSource(threadFront, url) {
  dump("Waiting for new source with url '" + url + "'.\n");
  return waitForEvent(threadFront, "newSource", function (packet) {
    return packet.source.url === url;
  });
}

function resume(threadFront) {
  dump("Resuming thread.\n");
  return threadFront.resume();
}

async function addWatchpoint(threadFront, frame, variable, property, type) {
  const path = `${variable}.${property}`;
  info(`Add an ${path} ${type} watchpoint`);
  const environment = await frame.getEnvironment();
  const obj = environment.bindings.variables[variable];
  const objFront = threadFront.pauseGrip(obj.value);
  return objFront.addWatchpoint(property, path, type);
}

function getSources(threadFront) {
  dump("Getting sources.\n");
  return threadFront.getSources();
}

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

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

function waitForProperty(dbg, property) {
  return new Promise(resolve => {
    Object.defineProperty(dbg, property, {
      set(newValue) {
        resolve(newValue);
      },
    });
  });
}

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

function getPrototypeAndProperties(objClient) {
  dump("getting prototype and properties.\n");

  return objClient.getPrototypeAndProperties();
}

function dumpn(msg) {
  dump("DBG-TEST: " + msg + "\n");
}

function testExceptionHook(ex) {
  try {
    do_report_unexpected_exception(ex);
  } catch (e) {
    return { throw: e };
  }
  return undefined;
}

// Convert an nsIScriptError 'logLevel' value into an appropriate string.
function scriptErrorLogLevel(message) {
  switch (message.logLevel) {
    case Ci.nsIConsoleMessage.info:
      return "info";
    case Ci.nsIConsoleMessage.warn:
      return "warning";
    default:
      Assert.equal(message.logLevel, Ci.nsIConsoleMessage.error);
      return "error";
  }
}

// Register a console listener, so console messages don't just disappear
// into the ether.
var errorCount = 0;
var listener = {
  observe(message) {
    try {
      let string;
      errorCount++;
      try {
        // If we've been given an nsIScriptError, then we can print out
        // something nicely formatted, for tools like Emacs to pick up.
        message.QueryInterface(Ci.nsIScriptError);
        dumpn(
          message.sourceName +
            ":" +
            message.lineNumber +
            ": " +
            scriptErrorLogLevel(message) +
            ": " +
            message.errorMessage
        );
        string = message.errorMessage;
      } catch (e1) {
        // Be a little paranoid with message, as the whole goal here is to lose
        // no information.
        try {
          string = "" + message.message;
        } catch (e2) {
          string = "<error converting error message to string>";
        }
      }

      // Make sure we exit all nested event loops so that the test can finish.
      while (
        DevToolsServer &&
        DevToolsServer.xpcInspector &&
        DevToolsServer.xpcInspector.eventLoopNestLevel > 0
      ) {
        DevToolsServer.xpcInspector.exitNestedEventLoop();
      }

      // In the world before bug 997440, exceptions were getting lost because of
      // the arbitrary JSContext being used in nsXPCWrappedJS::CallMethod.
      // In the new world, the wanderers have returned. However, because of the,
      // currently very-broken, exception reporting machinery in
      // nsXPCWrappedJS these get reported as errors to the console, even if
      // there's actually JS on the stack above that will catch them.  If we
      // throw an error here because of them our tests start failing.  So, we'll
      // just dump the message to the logs instead, to make sure the information
      // isn't lost.
      dumpn("head_dbg.js observed a console message: " + string);
    } catch (_) {
      // Swallow everything to avoid console reentrancy errors. We did our best
      // to log above, but apparently that didn't cut it.
    }
  },
};

Services.console.registerListener(listener);

function addTestGlobal(name, server = DevToolsServer) {
  const global = createTestGlobal(name);
  server.addTestGlobal(global);
  return global;
}

// List the DevToolsClient |client|'s tabs, look for one whose title is
// |title|.
async function getTestTab(client, title) {
  const tabs = await client.mainRoot.listTabs();
  for (const tab of tabs) {
    if (tab.title === title) {
      return tab;
    }
  }
  return null;
}
/**
 *  Attach to the client's tab whose title is specified
 * @param {Object} client
 * @param {Object} title
 * @returns commands
 */
async function attachTestTab(client, title) {
  const descriptorFront = await getTestTab(client, title);

  // These xpcshell tests use mocked actors (xpcshell-test/testactors)
  // which still don't support watcher actor.
  // Because of that we still can't enable server side targets and target swiching.
  descriptorFront.disableTargetSwitching();

  const commands = await createCommandsDictionary(descriptorFront);
  await commands.targetCommand.startListening();
  return commands;
}

/**
 * Attach to the client's tab whose title is specified, and then attach to
 * that tab's thread.
 * @param {Object} client
 * @param {Object} title
 * @returns {Object}
 *         targetFront
 *         threadFront
 *         commands
 */
async function attachTestThread(client, title) {
  const commands = await attachTestTab(client, title);
  const targetFront = commands.targetCommand.targetFront;

  // Pass any configuration, in order to ensure starting all the thread actors
  // and have them to handle debugger statements.
  await commands.threadConfigurationCommand.updateConfiguration({
    skipBreakpoints: false,
  });

  const threadFront = await targetFront.getFront("thread");
  Assert.equal(threadFront.state, "attached", "Thread front is attached");
  return { targetFront, threadFront, commands };
}

/**
 * Initialize the testing devtools server.
 */
function initTestDevToolsServer(server = DevToolsServer) {
  if (server === WorkerDevToolsServer) {
    const { createRootActor } = worker.require("xpcshell-test/testactors");
    server.setRootActor(createRootActor);
  } else {
    const { createRootActor } = require("xpcshell-test/testactors");
    server.setRootActor(createRootActor);
  }

  // Allow incoming connections.
  server.init(function () {
    return true;
  });
}

/**
 * Initialize the testing devtools server with a tab whose title is |title|.
 */
async function startTestDevToolsServer(title, server = DevToolsServer) {
  initTestDevToolsServer(server);
  addTestGlobal(title);
  DevToolsServer.registerActors({ target: true });

  const transport = DevToolsServer.connectPipe();
  const client = new DevToolsClient(transport);

  await connect(client);
  return client;
}

async function finishClient(client) {
  await client.close();
  DevToolsServer.destroy();
  do_test_finished();
}

/**
 * Takes a relative file path and returns the absolute file url for it.
 */
function getFileUrl(name, allowMissing = false) {
  const file = do_get_file(name, allowMissing);
  return Services.io.newFileURI(file).spec;
}

/**
 * Returns the full path of the file with the specified name in a
 * platform-independent and URL-like form.
 */
function getFilePath(
  name,
  allowMissing = false,
  usePlatformPathSeparator = false
) {
  const file = do_get_file(name, allowMissing);
  let path = Services.io.newFileURI(file).spec;
  let filePrePath = "file://";
  if ("nsILocalFileWin" in Ci && file instanceof Ci.nsILocalFileWin) {
    filePrePath += "/";
  }

  path = path.slice(filePrePath.length);

  if (usePlatformPathSeparator && path.match(/^\w:/)) {
    path = path.replace(/\//g, "\\");
  }

  return path;
}

/**
 * Returns the full text contents of the given file.
 */
function readFile(fileName) {
  const f = do_get_file(fileName);
  const s = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
    Ci.nsIFileInputStream
  );
  s.init(f, -1, -1, false);
  try {
    return NetUtil.readInputStreamToString(s, s.available());
  } finally {
    s.close();
  }
}

function writeFile(fileName, content) {
  const file = do_get_file(fileName, true);
  const stream = Cc["@mozilla.org/network/file-output-stream;1"].createInstance(
    Ci.nsIFileOutputStream
  );
  stream.init(file, -1, -1, 0);
  try {
    do {
      const numWritten = stream.write(content, content.length);
      content = content.slice(numWritten);
    } while (content.length);
  } finally {
    stream.close();
  }
}

function StubTransport() {}
StubTransport.prototype.ready = function () {};
StubTransport.prototype.send = function () {};
StubTransport.prototype.close = function () {};

// Create async version of the object where calling each method
// is equivalent of calling it with asyncall. Mainly useful for
// destructuring objects with methods that take callbacks.
const Async = target => new Proxy(target, Async);
Async.get = (target, name) =>
  typeof target[name] === "function"
    ? asyncall.bind(null, target[name], target)
    : target[name];

// Calls async function that takes callback and errorback and returns
// returns promise representing result.
const asyncall = (fn, self, ...args) =>
  new Promise((...etc) => fn.call(self, ...args, ...etc));

const Test = task => () => {
  add_task(task);
  run_next_test();
};

const assert = Assert.ok.bind(Assert);

/**
 * Create a promise that is resolved on the next occurence of the given event.
 *
 * @param ThreadFront threadFront
 * @param String event
 * @param Function predicate
 * @returns Promise
 */
function waitForEvent(front, type, predicate) {
  if (!predicate) {
    return front.once(type);
  }

  return new Promise(function (resolve) {
    function listener(packet) {
      if (!predicate(packet)) {
        return;
      }
      front.off(type, listener);
      resolve(packet);
    }
    front.on(type, listener);
  });
}

/**
 * Execute the action on the next tick and return a promise that is resolved on
 * the next pause.
 *
 * When using promises and Task.jsm, we often want to do an action that causes a
 * pause and continue the task once the pause has ocurred. Unfortunately, if we
 * do the action that causes the pause within the task's current tick we will
 * pause before we have a chance to yield the promise that waits for the pause
 * and we enter a dead lock. The solution is to create the promise that waits
 * for the pause, schedule the action to run on the next tick of the event loop,
 * and finally yield the promise.
 *
 * @param Function action
 * @param ThreadFront threadFront
 * @returns Promise
 */
function executeOnNextTickAndWaitForPause(action, threadFront) {
  const paused = waitForPause(threadFront);
  executeSoon(action);
  return paused;
}

function evalCallback(debuggeeGlobal, func) {
  Cu.evalInSandbox("(" + func + ")()", debuggeeGlobal, "1.8", "test.js", 1);
}

/**
 * Interrupt JS execution for the specified thread.
 *
 * @param ThreadFront threadFront
 * @returns Promise
 */
function interrupt(threadFront) {
  dumpn("Interrupting.");
  return threadFront.interrupt();
}

/**
 * Resume JS execution for the specified thread and then wait for the next pause
 * event.
 *
 * @param DevToolsClient client
 * @param ThreadFront threadFront
 * @returns Promise
 */
async function resumeAndWaitForPause(threadFront) {
  const paused = waitForPause(threadFront);
  await resume(threadFront);
  return paused;
}

/**
 * Resume JS execution for a single step and wait for the pause after the step
 * has been taken.
 *
 * @param ThreadFront threadFront
 * @returns Promise
 */
function stepIn(threadFront) {
  dumpn("Stepping in.");
  const paused = waitForPause(threadFront);
  return threadFront.stepIn().then(() => paused);
}

/**
 * Resume JS execution for a step over and wait for the pause after the step
 * has been taken.
 *
 * @param ThreadFront threadFront
 * @returns Promise
 */
async function stepOver(threadFront, frameActor) {
  dumpn("Stepping over.");
  await threadFront.stepOver(frameActor);
  return waitForPause(threadFront);
}

/**
 * Resume JS execution for a step out and wait for the pause after the step
 * has been taken.
 *
 * @param DevToolsClient client
 * @param ThreadFront threadFront
 * @returns Promise
 */
async function stepOut(threadFront, frameActor) {
  dumpn("Stepping out.");
  await threadFront.stepOut(frameActor);
  return waitForPause(threadFront);
}

/**
 * Restart specific frame and wait for the pause after the restart
 * has been taken.
 *
 * @param DevToolsClient client
 * @param ThreadFront threadFront
 * @returns Promise
 */
async function restartFrame(threadFront, frameActor) {
  dumpn("Restarting frame.");
  await threadFront.restart(frameActor);
  return waitForPause(threadFront);
}

/**
 * Get the list of `count` frames currently on stack, starting at the index
 * `first` for the specified thread.
 *
 * @param ThreadFront threadFront
 * @param Number first
 * @param Number count
 * @returns Promise
 */
function getFrames(threadFront, first, count) {
  dumpn("Getting frames.");
  return threadFront.getFrames(first, count);
}

/**
 * Black box the specified source.
 *
 * @param SourceFront sourceFront
 * @returns Promise
 */
async function blackBox(sourceFront, range = null) {
  dumpn("Black boxing source: " + sourceFront.actor);
  const pausedInSource = await sourceFront.blackBox(range);
  ok(true, "blackBox didn't throw");
  return pausedInSource;
}

/**
 * Stop black boxing the specified source.
 *
 * @param SourceFront sourceFront
 * @returns Promise
 */
async function unBlackBox(sourceFront, range = null) {
  dumpn("Un-black boxing source: " + sourceFront.actor);
  await sourceFront.unblackBox(range);
  ok(true, "unblackBox didn't throw");
}

/**
 * Get a source at the specified url.
 *
 * @param ThreadFront threadFront
 * @param string url
 * @returns Promise<SourceFront>
 */
async function getSource(threadFront, url) {
  const source = await getSourceForm(threadFront, url);
  if (source) {
    return threadFront.source(source);
  }

  throw new Error("source not found");
}

async function getSourceById(threadFront, id) {
  const form = await getSourceFormById(threadFront, id);
  return threadFront.source(form);
}

async function getSourceForm(threadFront, url) {
  const { sources } = await threadFront.getSources();
  return sources.find(s => s.url === url);
}

async function getSourceFormById(threadFront, id) {
  const { sources } = await threadFront.getSources();
  return sources.find(source => source.actor == id);
}

async function checkFramesLength(threadFront, expectedFrames) {
  const frameResponse = await threadFront.getFrames(0, null);
  Assert.equal(
    frameResponse.frames.length,
    expectedFrames,
    "Thread front has the expected number of frames"
  );
}

/**
 * Do a reload which clears the thread debugger
 *
 * @param TabFront tabFront
 * @returns Promise<response>
 */
function reload(tabFront) {
  return tabFront.reload({});
}

/**
 * Returns an array of stack location strings given a thread and a sample.
 *
 * @param object thread
 * @param object sample
 * @returns object
 */
function getInflatedStackLocations(thread, sample) {
  const stackTable = thread.stackTable;
  const frameTable = thread.frameTable;
  const stringTable = thread.stringTable;
  const SAMPLE_STACK_SLOT = thread.samples.schema.stack;
  const STACK_PREFIX_SLOT = stackTable.schema.prefix;
  const STACK_FRAME_SLOT = stackTable.schema.frame;
  const FRAME_LOCATION_SLOT = frameTable.schema.location;

  // Build the stack from the raw data and accumulate the locations in
  // an array.
  let stackIndex = sample[SAMPLE_STACK_SLOT];
  const locations = [];
  while (stackIndex !== null) {
    const stackEntry = stackTable.data[stackIndex];
    const frame = frameTable.data[stackEntry[STACK_FRAME_SLOT]];
    locations.push(stringTable[frame[FRAME_LOCATION_SLOT]]);
    stackIndex = stackEntry[STACK_PREFIX_SLOT];
  }

  // The profiler tree is inverted, so reverse the array.
  return locations.reverse();
}

async function setupTestFromUrl(url) {
  do_test_pending();

  const { createRootActor } = require("xpcshell-test/testactors");
  DevToolsServer.setRootActor(createRootActor);
  DevToolsServer.init(() => true);

  const global = createTestGlobal("test");
  DevToolsServer.addTestGlobal(global);

  const devToolsClient = new DevToolsClient(DevToolsServer.connectPipe());
  await connect(devToolsClient);

  const tabs = await listTabs(devToolsClient);
  const descriptorFront = findTab(tabs, "test");

  // These xpcshell tests use mocked actors (xpcshell-test/testactors)
  // which still don't support watcher actor.
  // Because of that we still can't enable server side targets and target swiching.
  descriptorFront.disableTargetSwitching();

  const targetFront = await descriptorFront.getTarget();

  const commands = await createCommandsDictionary(descriptorFront);

  // Pass any configuration, in order to ensure starting all the thread actor
  // and have it to notify about all sources
  await commands.threadConfigurationCommand.updateConfiguration({
    skipBreakpoints: false,
  });

  const threadFront = await targetFront.getFront("thread");

  const sourceUrl = getFileUrl(url);
  const promise = waitForNewSource(threadFront, sourceUrl);
  loadSubScript(sourceUrl, global);
  const { source } = await promise;

  const sourceFront = threadFront.source(source);
  return { global, devToolsClient, threadFront, sourceFront };
}

/**
 * Run the given test function twice, one with a regular DevToolsServer,
 * testing against a fake tab. And another one against a WorkerDevToolsServer,
 * testing the worker codepath.
 *
 * @param Function test
 *        Test function to run twice.
 *        This test function is called with a dictionary:
 *        - Sandbox debuggee
 *          The custom JS debuggee created for this test. This is a Sandbox using system
 *           principals by default.
 *        - ThreadFront threadFront
 *          A reference to a ThreadFront instance that is attached to the debuggee.
 *        - DevToolsClient client
 *          A reference to the DevToolsClient used to communicated with the RDP server.
 * @param Object options
 *        Optional arguments to tweak test environment
 *        - JSPrincipal principal
 *          Principal to use for the debuggee. Defaults to systemPrincipal.
 *        - boolean doNotRunWorker
 *          If true, do not run this tests in worker debugger context. Defaults to false.
 *        - bool wantXrays
 *          Whether the debuggee wants Xray vision with respect to same-origin objects
 *          outside the sandbox. Defaults to true.
 *        - bool waitForFinish
 *          Whether to wait for a call to threadFrontTestFinished after the test
 *          function finishes.
 */
function threadFrontTest(test, options = {}) {
  const {
    principal = systemPrincipal,
    doNotRunWorker = false,
    wantXrays = true,
    waitForFinish = false,
  } = options;

  async function runThreadFrontTestWithServer(server, test) {
    // Setup a server and connect a client to it.
    initTestDevToolsServer(server);

    // Create a custom debuggee and register it to the server.
    // We are using a custom Sandbox as debuggee. Create a new zone because
    // debugger and debuggee must be in different compartments.
    const debuggee = Cu.Sandbox(principal, { freshZone: true, wantXrays });
    const scriptName = "debuggee.js";
    debuggee.__name = scriptName;
    server.addTestGlobal(debuggee);

    const client = new DevToolsClient(server.connectPipe());
    await client.connect();

    // Attach to the fake tab target and retrieve the ThreadFront instance.
    // Automatically resume as the thread is paused by default after attach.
    const { targetFront, threadFront, commands } = await attachTestThread(
      client,
      scriptName
    );

    // Cross the client/server boundary to retrieve the target actor & thread
    // actor instances, used by some tests.
    const rootActor = client.transport._serverConnection.rootActor;
    const targetActor =
      rootActor._parameters.tabList.getTargetActorForTab("debuggee.js");
    const { threadActor } = targetActor;

    // Run the test function
    const args = {
      threadActor,
      threadFront,
      debuggee,
      client,
      server,
      targetFront,
      commands,
      isWorkerServer: server === WorkerDevToolsServer,
    };
    if (waitForFinish) {
      // Use dispatchToMainThread so that the test function does not have to
      // finish executing before the test itself finishes.
      const promise = new Promise(
        resolve => (threadFrontTestFinished = resolve)
      );
      Services.tm.dispatchToMainThread(() => test(args));
      await promise;
    } else {
      await test(args);
    }

    // Cleanup the client after the test ran
    await client.close();

    server.removeTestGlobal(debuggee);

    // Also cleanup the created server
    server.destroy();
  }

  return async () => {
    dump(">>> Run thread front test against a regular DevToolsServer\n");
    await runThreadFrontTestWithServer(DevToolsServer, test);

    // Skip tests that fail in the worker context
    if (!doNotRunWorker) {
      dump(">>> Run thread front test against a worker DevToolsServer\n");
      await runThreadFrontTestWithServer(WorkerDevToolsServer, test);
    }
  };
}

// This callback is used in tandem with the waitForFinish option of
// threadFrontTest to support thread front tests that use promises to
// asynchronously finish the tests, instead of using async/await.
// Newly written tests should avoid using this. See bug 1596114 for migrating
// existing tests to async/await and removing this functionality.
let threadFrontTestFinished;
