/* 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/>. */

/**
 * Helper methods to drive with the debugger during mochitests. This file can be safely
 * required from other panel test files.
 */

"use strict";

/* eslint-disable no-unused-vars */

// We can't use "import globals from head.js" because of bug 1395426.
// So workaround by manually importing the few symbols we are using from it.
// (Note that only ./mach eslint devtools/client fails while devtools/client/debugger passes)
/* global EXAMPLE_URL, ContentTask */

// Assume that shared-head is always imported before this file
/* import-globals-from ../../../shared/test/shared-head.js */

/**
 * Helper method to create a "dbg" context for other tools to use
 */
function createDebuggerContext(toolbox) {
  const panel = toolbox.getPanel("jsdebugger");
  const win = panel.panelWin;

  return {
    ...win.dbg,
    commands: toolbox.commands,
    toolbox,
    win,
    panel,
  };
}

var { Toolbox } = require("devtools/client/framework/toolbox");
const asyncStorage = require("devtools/shared/async-storage");

const {
  getSelectedLocation,
} = require("devtools/client/debugger/src/utils/selected-location");
const {
  createLocation,
} = require("devtools/client/debugger/src/utils/location");

const {
  resetSchemaVersion,
} = require("devtools/client/debugger/src/utils/prefs");

const {
  safeDecodeItemName,
} = require("devtools/client/debugger/src/utils/sources-tree/utils");

const {
  isGeneratedId,
} = require("devtools/client/shared/source-map-loader/index");

const DEBUGGER_L10N = new LocalizationHelper(
  "devtools/client/locales/debugger.properties"
);

/**
 * Waits for `predicate()` to be true. `state` is the redux app state.
 *
 * @memberof mochitest/waits
 * @param {Object} dbg
 * @param {Function} predicate
 * @return {Promise}
 * @static
 */
function waitForState(dbg, predicate, msg) {
  return new Promise(resolve => {
    info(`Waiting for state change: ${msg || ""}`);
    if (predicate(dbg.store.getState())) {
      info(`Finished waiting for state change: ${msg || ""}`);
      resolve();
      return;
    }

    const unsubscribe = dbg.store.subscribe(() => {
      const result = predicate(dbg.store.getState());
      if (result) {
        info(`Finished waiting for state change: ${msg || ""}`);
        unsubscribe();
        resolve(result);
      }
    });
  });
}

/**
 * Waits for sources to be loaded.
 *
 * @memberof mochitest/waits
 * @param {Object} dbg
 * @param {Array} sources
 * @return {Promise}
 * @static
 */
async function waitForSources(dbg, ...sources) {
  if (sources.length === 0) {
    return;
  }

  info(`Waiting on sources: ${sources.join(", ")}`);
  await Promise.all(
    sources.map(url => {
      if (!sourceExists(dbg, url)) {
        return waitForState(
          dbg,
          () => sourceExists(dbg, url),
          `source ${url} exists`
        );
      }
      return Promise.resolve();
    })
  );

  info(`Finished waiting on sources: ${sources.join(", ")}`);
}

/**
 * Waits for a source to be loaded.
 *
 * @memberof mochitest/waits
 * @param {Object} dbg
 * @param {String} source
 * @return {Promise}
 * @static
 */
function waitForSource(dbg, url) {
  return waitForState(
    dbg,
    state => findSource(dbg, url, { silent: true }),
    "source exists"
  );
}

async function waitForElement(dbg, name, ...args) {
  await waitUntil(() => findElement(dbg, name, ...args));
  return findElement(dbg, name, ...args);
}

/**
 * Wait for a count of given elements to be rendered on screen.
 *
 * @param {DebuggerPanel} dbg
 * @param {String} name
 * @param {Integer} count: Number of elements to match. Defaults to 1.
 * @param {Boolean} countStrictlyEqual: When set to true, will wait until the exact number
 *                  of elements is displayed on screen. When undefined or false, will wait
 *                  until there's at least `${count}` elements on screen (e.g. if count
 *                  is 1, it will resolve if there are 2 elements rendered).
 */
async function waitForAllElements(
  dbg,
  name,
  count = 1,
  countStrictlyEqual = false
) {
  await waitUntil(() => {
    const elsCount = findAllElements(dbg, name).length;
    return countStrictlyEqual ? elsCount === count : elsCount >= count;
  });
  return findAllElements(dbg, name);
}

async function waitForElementWithSelector(dbg, selector) {
  await waitUntil(() => findElementWithSelector(dbg, selector));
  return findElementWithSelector(dbg, selector);
}

function waitForRequestsToSettle(dbg) {
  return dbg.commands.client.waitForRequestsToSettle();
}

function assertClass(el, className, exists = true) {
  if (exists) {
    ok(el.classList.contains(className), `${className} class exists`);
  } else {
    ok(!el.classList.contains(className), `${className} class does not exist`);
  }
}

function waitForSelectedLocation(dbg, line, column) {
  return waitForState(dbg, state => {
    const location = dbg.selectors.getSelectedLocation();
    return (
      location &&
      (line ? location.line == line : true) &&
      (column ? location.column == column : true)
    );
  });
}

/**
 * Wait for a given source to be selected and ready.
 *
 * @memberof mochitest/waits
 * @param {Object} dbg
 * @param {null|string|Source} sourceOrUrl Optional. Either a source URL (string) or a source object (typically fetched via `findSource`)
 * @return {Promise}
 * @static
 */
function waitForSelectedSource(dbg, sourceOrUrl) {
  const {
    getSelectedSourceTextContent,
    getSymbols,
    getBreakableLines,
    getSourceActorsForSource,
    getSourceActorBreakableLines,
    getFirstSourceActorForGeneratedSource,
  } = dbg.selectors;

  return waitForState(
    dbg,
    state => {
      const location = dbg.selectors.getSelectedLocation() || {};
      const sourceTextContent = getSelectedSourceTextContent();
      if (!sourceTextContent) {
        return false;
      }

      if (sourceOrUrl) {
        // Second argument is either a source URL (string)
        // or a Source object.
        if (typeof sourceOrUrl == "string") {
          const url = location.source.url;
          if (typeof url != "string" || !url.includes(encodeURI(sourceOrUrl))) {
            return false;
          }
        } else if (location.source.id != sourceOrUrl.id) {
          return false;
        }
      }

      // Wait for symbols/AST to be parsed
      if (!getSymbols(location) && !isWasmBinarySource(location.source)) {
        return false;
      }

      // Finaly wait for breakable lines to be set
      if (location.source.isHTML) {
        // For HTML sources we need to wait for each source actor to be processed.
        // getBreakableLines will return the aggregation without being able to know
        // if that's complete, with all the source actors.
        const sourceActors = getSourceActorsForSource(location.source.id);
        const allSourceActorsProcessed = sourceActors.every(
          sourceActor => !!getSourceActorBreakableLines(sourceActor.id)
        );
        return allSourceActorsProcessed;
      }

      if (!getBreakableLines(location.source.id)) {
        return false;
      }

      // Also ensure that CodeMirror updated its content
      return getCM(dbg).getValue() !== DEBUGGER_L10N.getStr("loadingText");
    },
    "selected source"
  );
}

/**
 * The generated source of WASM source are WASM binary file,
 * which have many broken/disabled features in the debugger.
 *
 * They especially have a very special behavior in CodeMirror
 * where line labels aren't line number, but hex addresses.
 */
function isWasmBinarySource(source) {
  return source.isWasm && !source.isOriginal;
}

function getVisibleSelectedFrameLine(dbg) {
  const frame = dbg.selectors.getVisibleSelectedFrame();
  return frame?.location.line;
}

function getVisibleSelectedFrameColumn(dbg) {
  const frame = dbg.selectors.getVisibleSelectedFrame();
  return frame?.location.column;
}

/**
 * Assert that a given line is breakable or not.
 * Verify that CodeMirror gutter is grayed out via the empty line classname if not breakable.
 */
function assertLineIsBreakable(dbg, file, line, shouldBeBreakable) {
  const lineInfo = getCM(dbg).lineInfo(line - 1);
  const lineText = `${line}| ${lineInfo.text.substring(0, 50)}${
    lineInfo.text.length > 50 ? "…" : ""
  } — in ${file}`;
  // When a line is not breakable, the "empty-line" class is added
  // and the line is greyed out
  if (shouldBeBreakable) {
    ok(
      !lineInfo.wrapClass?.includes("empty-line"),
      `${lineText} should be breakable`
    );
  } else {
    ok(
      lineInfo?.wrapClass?.includes("empty-line"),
      `${lineText} should NOT be breakable`
    );
  }
}

/**
 * Assert that the debugger is highlighting the correct location.
 *
 * @memberof mochitest/asserts
 * @param {Object} dbg
 * @param {String} source
 * @param {Number} line
 * @static
 */
function assertHighlightLocation(dbg, source, line) {
  source = findSource(dbg, source);

  // Check the selected source
  is(
    dbg.selectors.getSelectedSource().url,
    source.url,
    "source url is correct"
  );

  // Check the highlight line
  const lineEl = findElement(dbg, "highlightLine");
  ok(lineEl, "Line is highlighted");

  is(
    findAllElements(dbg, "highlightLine").length,
    1,
    "Only 1 line is highlighted"
  );

  ok(isVisibleInEditor(dbg, lineEl), "Highlighted line is visible");

  const cm = getCM(dbg);
  const lineInfo = cm.lineInfo(line - 1);
  ok(lineInfo.wrapClass.includes("highlight-line"), "Line is highlighted");
}

/**
 * Helper function for assertPausedAtSourceAndLine.
 *
 * Assert that CodeMirror reports to be paused at the given line/column.
 */
function _assertDebugLine(dbg, line, column) {
  const source = dbg.selectors.getSelectedSource();
  // WASM lines are hex addresses which have to be mapped to decimal line number
  if (isWasmBinarySource(source)) {
    line = dbg.wasmOffsetToLine(source.id, line) + 1;
  }

  // Check the debug line
  const lineInfo = getCM(dbg).lineInfo(line - 1);
  const sourceTextContent = dbg.selectors.getSelectedSourceTextContent();
  if (source && !sourceTextContent) {
    const url = source.url;
    ok(
      false,
      `Looks like the source ${url} is still loading. Try adding waitForLoadedSource in the test.`
    );
    return;
  }

  // Scroll the line into view to make sure the content
  // on the line is rendered and in the dom.
  getCM(dbg).scrollIntoView({ line, ch: 0 });

  if (!lineInfo.wrapClass) {
    const pauseLine = getVisibleSelectedFrameLine(dbg);
    ok(false, `Expected pause line on line ${line}, it is on ${pauseLine}`);
    return;
  }

  ok(
    lineInfo?.wrapClass.includes("new-debug-line"),
    `Line ${line} is not highlighted as paused`
  );

  const debugLine =
    findElement(dbg, "debugLine") || findElement(dbg, "debugErrorLine");

  is(
    findAllElements(dbg, "debugLine").length +
      findAllElements(dbg, "debugErrorLine").length,
    1,
    "There is only one line"
  );

  ok(isVisibleInEditor(dbg, debugLine), "debug line is visible");

  const markedSpans = lineInfo.handle.markedSpans;
  if (markedSpans && markedSpans.length && !isWasmBinarySource(source)) {
    const hasExpectedDebugLine = markedSpans.some(
      span =>
        span.marker.className?.includes("debug-expression") &&
        // When a precise column is expected, ensure that we have at least
        // one "debug line" for the column we expect.
        // (See the React Component: DebugLine.setDebugLine)
        (!column || span.from == column)
    );
    ok(
      hasExpectedDebugLine,
      "Got the expected DebugLine. i.e. got the right marker in codemirror visualizing the breakpoint"
    );
  }
  info(`Paused on line ${line}`);
}

/**
 * Make sure the debugger is paused at a certain source ID and line.
 *
 * @param {Object} dbg
 * @param {String} expectedSourceId
 * @param {Number} expectedLine
 * @param {Number} [expectedColumn]
 */
function assertPausedAtSourceAndLine(
  dbg,
  expectedSourceId,
  expectedLine,
  expectedColumn
) {
  // Check that the debugger is paused.
  assertPaused(dbg);

  // Check that the paused location is correctly rendered.
  ok(isSelectedFrameSelected(dbg), "top frame's source is selected");

  // Check the pause location
  const pauseLine = getVisibleSelectedFrameLine(dbg);
  is(
    pauseLine,
    expectedLine,
    "Redux state for currently selected frame's line is correct"
  );
  const pauseColumn = getVisibleSelectedFrameColumn(dbg);
  if (expectedColumn) {
    is(
      pauseColumn,
      expectedColumn,
      "Redux state for currently selected frame's column is correct"
    );
  }
  _assertDebugLine(dbg, pauseLine, pauseColumn);

  ok(isVisibleInEditor(dbg, getCM(dbg).display.gutters), "gutter is visible");

  const frames = dbg.selectors.getCurrentThreadFrames();
  const selectedSource = dbg.selectors.getSelectedSource();

  // WASM support is limited when we are on the generated binary source
  if (isWasmBinarySource(selectedSource)) {
    return;
  }

  ok(frames.length >= 1, "Got at least one frame");

  // Lets make sure we can assert both original and generated file locations when needed
  const { source, line, column } = isGeneratedId(expectedSourceId)
    ? frames[0].generatedLocation
    : frames[0].location;
  is(source.id, expectedSourceId, "Frame has correct source");
  is(
    line,
    expectedLine,
    `Frame paused at line ${line}, but expected line ${expectedLine}`
  );

  if (expectedColumn) {
    is(
      column,
      expectedColumn,
      `Frame paused at column ${column}, but expected column ${expectedColumn}`
    );
  }
}

async function waitForThreadCount(dbg, count) {
  return waitForState(
    dbg,
    state => dbg.selectors.getThreads(state).length == count
  );
}

async function waitForLoadedScopes(dbg) {
  const scopes = await waitForElement(dbg, "scopes");
  // Since scopes auto-expand, we can assume they are loaded when there is a tree node
  // with the aria-level attribute equal to "2".
  await waitUntil(() => scopes.querySelector('.tree-node[aria-level="2"]'));
}

function waitForBreakpointCount(dbg, count) {
  return waitForState(
    dbg,
    state => dbg.selectors.getBreakpointCount() == count
  );
}

function waitForBreakpoint(dbg, url, line) {
  return waitForState(dbg, () => findBreakpoint(dbg, url, line));
}

function waitForBreakpointRemoved(dbg, url, line) {
  return waitForState(dbg, () => !findBreakpoint(dbg, url, line));
}

/**
 * Returns boolean for whether the debugger is paused.
 *
 * @param {Object} dbg
 */
function isPaused(dbg) {
  return dbg.selectors.getIsCurrentThreadPaused();
}

/**
 * Assert that the debugger is not currently paused.
 *
 * @param {Object} dbg
 * @param {String} msg
 *        Optional assertion message
 */
function assertNotPaused(dbg, msg = "client is not paused") {
  ok(!isPaused(dbg), msg);
}

/**
 * Assert that the debugger is currently paused.
 *
 * @param {Object} dbg
 */
function assertPaused(dbg, msg = "client is paused") {
  ok(isPaused(dbg), msg);
}

/**
 * Waits for the debugger to be fully paused.
 *
 * @param {Object} dbg
 * @param {String} url
 *        Optional URL of the script we should be pausing on.
 */
async function waitForPaused(dbg, url) {
  info("Waiting for the debugger to pause");
  const { getSelectedScope, getCurrentThread, getCurrentThreadFrames } =
    dbg.selectors;

  await waitForState(
    dbg,
    state => isPaused(dbg) && !!getSelectedScope(getCurrentThread()),
    "paused"
  );

  await waitForState(dbg, getCurrentThreadFrames, "fetched frames");
  await waitForLoadedScopes(dbg);
  await waitForSelectedSource(dbg, url);
}

/**
 * Waits for the debugger to resume.
 *
 * @param {Objeect} dbg
 */
function waitForResumed(dbg) {
  info("Waiting for the debugger to resume");
  return waitForState(dbg, state => !dbg.selectors.getIsCurrentThreadPaused());
}

function waitForInlinePreviews(dbg) {
  return waitForState(dbg, () => dbg.selectors.getSelectedInlinePreviews());
}

function waitForCondition(dbg, condition) {
  return waitForState(dbg, state =>
    dbg.selectors
      .getBreakpointsList()
      .find(bp => bp.options.condition == condition)
  );
}

function waitForLog(dbg, logValue) {
  return waitForState(dbg, state =>
    dbg.selectors
      .getBreakpointsList()
      .find(bp => bp.options.logValue == logValue)
  );
}

async function waitForPausedThread(dbg, thread) {
  return waitForState(dbg, state => dbg.selectors.getIsPaused(thread));
}

function isSelectedFrameSelected(dbg, state) {
  const frame = dbg.selectors.getVisibleSelectedFrame();

  // Make sure the source text is completely loaded for the
  // source we are paused in.
  const source = dbg.selectors.getSelectedSource();
  const sourceTextContent = dbg.selectors.getSelectedSourceTextContent();

  if (!source || !sourceTextContent) {
    return false;
  }

  return source.id == frame.location.source.id;
}

/**
 * Checks to see if the frame is selected and the title is correct.
 *
 * @param {Object} dbg
 * @param {Integer} index
 * @param {String} title
 */
function isFrameSelected(dbg, index, title) {
  const $frame = findElement(dbg, "frame", index);

  const {
    selectors: { getSelectedFrame, getCurrentThread },
  } = dbg;

  const frame = getSelectedFrame(getCurrentThread());

  const elSelected = $frame.classList.contains("selected");
  const titleSelected = frame.displayName == title;

  return elSelected && titleSelected;
}

/**
 * Clear all the debugger related preferences.
 */
async function clearDebuggerPreferences(prefs = []) {
  resetSchemaVersion();
  asyncStorage.clear();
  Services.prefs.clearUserPref("devtools.debugger.alphabetize-outline");
  Services.prefs.clearUserPref("devtools.debugger.pause-on-exceptions");
  Services.prefs.clearUserPref("devtools.debugger.pause-on-caught-exceptions");
  Services.prefs.clearUserPref("devtools.debugger.ignore-caught-exceptions");
  Services.prefs.clearUserPref("devtools.debugger.pending-selected-location");
  Services.prefs.clearUserPref("devtools.debugger.expressions");
  Services.prefs.clearUserPref("devtools.debugger.breakpoints-visible");
  Services.prefs.clearUserPref("devtools.debugger.call-stack-visible");
  Services.prefs.clearUserPref("devtools.debugger.scopes-visible");
  Services.prefs.clearUserPref("devtools.debugger.skip-pausing");
  Services.prefs.clearUserPref("devtools.debugger.map-scopes-enabled");

  for (const pref of prefs) {
    await pushPref(...pref);
  }
}

/**
 * Intilializes the debugger.
 *
 * @memberof mochitest
 * @param {String} url
 * @return {Promise} dbg
 * @static
 */

async function initDebugger(url, ...sources) {
  // We depend on EXAMPLE_URLs origin to do cross origin/process iframes via
  // EXAMPLE_REMOTE_URL. If the top level document origin changes,
  // we may break this. So be careful if you want to change EXAMPLE_URL.
  return initDebuggerWithAbsoluteURL(EXAMPLE_URL + url, ...sources);
}

async function initDebuggerWithAbsoluteURL(url, ...sources) {
  await clearDebuggerPreferences();
  const toolbox = await openNewTabAndToolbox(url, "jsdebugger");
  const dbg = createDebuggerContext(toolbox);

  await waitForSources(dbg, ...sources);
  return dbg;
}

async function initPane(url, pane, prefs) {
  await clearDebuggerPreferences(prefs);
  return openNewTabAndToolbox(EXAMPLE_URL + url, pane);
}

/**
 * Returns a source that matches a given filename, or a URL.
 * This also accept a source as input argument, in such case it just returns it.
 *
 * @param {Object} dbg
 * @param {String} filenameOrUrlOrSource
 *        The typical case will be to pass only a filename,
 *        but you may also pass a full URL to match sources without filesnames like data: URL
 *        or pass the source itself, which is just returned.
 * @param {Object} options
 * @param {Boolean} options.silent
 *        If true, won't throw if the source is missing.
 * @return {Object} source
 */
function findSource(
  dbg,
  filenameOrUrlOrSource,
  { silent } = { silent: false }
) {
  if (typeof filenameOrUrlOrSource !== "string") {
    // Support passing in a source object itself all APIs that use this
    // function support both styles
    return filenameOrUrlOrSource;
  }

  const sources = dbg.selectors.getSourceList();
  const source = sources.find(s => {
    // Sources don't have a file name attribute, we need to compute it here:
    const sourceFileName = s.url
      ? safeDecodeItemName(s.url.substring(s.url.lastIndexOf("/") + 1))
      : "";

    // The input argument may either be only the filename, or the complete URL
    // This helps match sources whose URL doesn't contain a filename, like data: URLs
    return (
      sourceFileName == filenameOrUrlOrSource || s.url == filenameOrUrlOrSource
    );
  });

  if (!source) {
    if (silent) {
      return false;
    }

    throw new Error(`Unable to find source: ${filenameOrUrlOrSource}`);
  }

  return source;
}

function findSourceContent(dbg, url, opts) {
  const source = findSource(dbg, url, opts);

  if (!source) {
    return null;
  }
  const content = dbg.selectors.getSettledSourceTextContent(
    createLocation({
      source,
    })
  );

  if (!content) {
    return null;
  }

  if (content.state !== "fulfilled") {
    throw new Error(`Expected loaded source, got${content.value}`);
  }

  return content.value;
}

function sourceExists(dbg, url) {
  return !!findSource(dbg, url, { silent: true });
}

function waitForLoadedSource(dbg, url) {
  return waitForState(
    dbg,
    state => {
      const source = findSource(dbg, url, { silent: true });
      return (
        source &&
        dbg.selectors.getSettledSourceTextContent(
          createLocation({
            source,
          })
        )
      );
    },
    "loaded source"
  );
}

/*
 * Selects the source node for a specific source
 * from the source tree.
 *
 * @param {Object} dbg
 * @param {String} filename - The filename for the specific source
 * @param {Number} sourcePosition - The source node postion in the tree
 * @param {String} message - The info message to display
 */
async function selectSourceFromSourceTree(
  dbg,
  fileName,
  sourcePosition,
  message
) {
  info(message);
  await clickElement(dbg, "sourceNode", sourcePosition);
  await waitForSelectedSource(dbg, fileName);
  await waitFor(
    () => getCM(dbg).getValue() !== `Loading…`,
    "Wait for source to completely load"
  );
}

/*
 * Trigger a context menu in the debugger source tree
 *
 * @param {Object} dbg
 * @param {Obejct} sourceTreeNode - The node in the source tree which the context menu
 *                                  item needs to be triggered on.
 * @param {String} contextMenuItem - The id for the context menu item to be selected
 */
async function triggerSourceTreeContextMenu(
  dbg,
  sourceTreeNode,
  contextMenuItem
) {
  const onContextMenu = waitForContextMenu(dbg);
  rightClickEl(dbg, sourceTreeNode);
  const menupopup = await onContextMenu;
  const onHidden = new Promise(resolve => {
    menupopup.addEventListener("popuphidden", resolve, { once: true });
  });
  selectContextMenuItem(dbg, contextMenuItem);
  await onHidden;
}

/**
 * Selects the source.
 *
 * @memberof mochitest/actions
 * @param {Object} dbg
 * @param {String} url
 * @param {Number} line
 * @param {Number} column
 * @return {Promise}
 * @static
 */
async function selectSource(dbg, url, line, column) {
  const source = findSource(dbg, url);

  await dbg.actions.selectLocation(createLocation({ source, line, column }), {
    keepContext: false,
  });
  return waitForSelectedSource(dbg, source);
}

async function closeTab(dbg, url) {
  await dbg.actions.closeTab(findSource(dbg, url));
}

function countTabs(dbg) {
  return findElement(dbg, "sourceTabs").children.length;
}

/**
 * Steps over.
 *
 * @memberof mochitest/actions
 * @param {Object} dbg
 * @return {Promise}
 * @static
 */
async function stepOver(dbg) {
  const pauseLine = getVisibleSelectedFrameLine(dbg);
  info(`Stepping over from ${pauseLine}`);
  await dbg.actions.stepOver();
  return waitForPaused(dbg);
}

/**
 * Steps in.
 *
 * @memberof mochitest/actions
 * @param {Object} dbg
 * @return {Promise}
 * @static
 */
async function stepIn(dbg) {
  const pauseLine = getVisibleSelectedFrameLine(dbg);
  info(`Stepping in from ${pauseLine}`);
  await dbg.actions.stepIn();
  return waitForPaused(dbg);
}

/**
 * Steps out.
 *
 * @memberof mochitest/actions
 * @param {Object} dbg
 * @return {Promise}
 * @static
 */
async function stepOut(dbg) {
  const pauseLine = getVisibleSelectedFrameLine(dbg);
  info(`Stepping out from ${pauseLine}`);
  await dbg.actions.stepOut();
  return waitForPaused(dbg);
}

/**
 * Resumes.
 *
 * @memberof mochitest/actions
 * @param {Object} dbg
 * @return {Promise}
 * @static
 */
async function resume(dbg) {
  const pauseLine = getVisibleSelectedFrameLine(dbg);
  info(`Resuming from ${pauseLine}`);
  const onResumed = waitForResumed(dbg);
  await dbg.actions.resume();
  return onResumed;
}

function deleteExpression(dbg, input) {
  info(`Delete expression "${input}"`);
  return dbg.actions.deleteExpression({ input });
}

/**
 * Reloads the debuggee.
 *
 * @memberof mochitest/actions
 * @param {Object} dbg
 * @param {Array} sources
 * @return {Promise}
 * @static
 */
async function reload(dbg, ...sources) {
  await reloadBrowser();
  return waitForSources(dbg, ...sources);
}

// Only use this method when the page is paused by the debugger
// during page load and we navigate away without resuming.
//
// In this particular scenario, the page will never be "loaded".
// i.e. emit DOCUMENT_EVENT's dom-complete
// And consequently, debugger panel won't emit "reloaded" event.
async function reloadWhenPausedBeforePageLoaded(dbg, ...sources) {
  // But we can at least listen for the next DOCUMENT_EVENT's dom-loading,
  // which should be fired even if the page is pause the earliest.
  const { resourceCommand } = dbg.commands;
  const { onResource: onTopLevelDomLoading } =
    await resourceCommand.waitForNextResource(
      resourceCommand.TYPES.DOCUMENT_EVENT,
      {
        ignoreExistingResources: true,
        predicate: resource =>
          resource.targetFront.isTopLevel && resource.name === "dom-loading",
      }
    );

  gBrowser.reloadTab(gBrowser.selectedTab);

  info("Wait for DOCUMENT_EVENT dom-loading after reload");
  await onTopLevelDomLoading;
  return waitForSources(dbg, ...sources);
}

/**
 * Navigates the debuggee to another url.
 *
 * @memberof mochitest/actions
 * @param {Object} dbg
 * @param {String} url
 * @param {Array} sources
 * @return {Promise}
 * @static
 */
async function navigate(dbg, url, ...sources) {
  return navigateToAbsoluteURL(dbg, EXAMPLE_URL + url, ...sources);
}

/**
 * Navigates the debuggee to another absolute url.
 *
 * @memberof mochitest/actions
 * @param {Object} dbg
 * @param {String} url
 * @param {Array} sources
 * @return {Promise}
 * @static
 */
async function navigateToAbsoluteURL(dbg, url, ...sources) {
  await navigateTo(url);
  return waitForSources(dbg, ...sources);
}

function getFirstBreakpointColumn(dbg, source, line) {
  const position = dbg.selectors.getFirstBreakpointPosition(
    createLocation({
      line,
      source,
    })
  );

  return getSelectedLocation(position, source).column;
}

function isMatchingLocation(location1, location2) {
  return (
    location1?.source.id == location2?.source.id &&
    location1?.line == location2?.line &&
    location1?.column == location2?.column
  );
}

function getBreakpointForLocation(dbg, location) {
  if (!location) {
    return undefined;
  }

  const isGeneratedSource = isGeneratedId(location.source.id);
  return dbg.selectors.getBreakpointsList().find(bp => {
    const loc = isGeneratedSource ? bp.generatedLocation : bp.location;
    return isMatchingLocation(loc, location);
  });
}

/**
 * Adds a breakpoint to a source at line/col.
 *
 * @memberof mochitest/actions
 * @param {Object} dbg
 * @param {String} source
 * @param {Number} line
 * @param {Number} col
 * @return {Promise}
 * @static
 */
async function addBreakpoint(dbg, source, line, column, options) {
  source = findSource(dbg, source);
  const bpCount = dbg.selectors.getBreakpointCount();
  const onBreakpoint = waitForDispatch(dbg.store, "SET_BREAKPOINT");
  await dbg.actions.addBreakpoint(
    createLocation({ source, line, column }),
    options
  );
  await onBreakpoint;
  is(
    dbg.selectors.getBreakpointCount(),
    bpCount + 1,
    "a new breakpoint was created"
  );
}

/**
 * Similar to `addBreakpoint`, but uses the UI instead or calling
 * the actions directly. This only support breakpoint on lines,
 * not on a specific column.
 */
async function addBreakpointViaGutter(dbg, line) {
  info(`Add breakpoint via the editor on line ${line}`);
  await clickGutter(dbg, line);
  return waitForDispatch(dbg.store, "SET_BREAKPOINT");
}

function disableBreakpoint(dbg, source, line, column) {
  column = column || getFirstBreakpointColumn(dbg, source, line);
  const location = createLocation({
    source,
    line,
    column,
  });
  const bp = getBreakpointForLocation(dbg, location);
  return dbg.actions.disableBreakpoint(bp);
}

function findBreakpoint(dbg, url, line) {
  const source = findSource(dbg, url);
  return dbg.selectors.getBreakpointsForSource(source, line)[0];
}

// helper for finding column breakpoints.
function findColumnBreakpoint(dbg, url, line, column) {
  const source = findSource(dbg, url);
  const lineBreakpoints = dbg.selectors.getBreakpointsForSource(source, line);

  return lineBreakpoints.find(bp => {
    return source.isOriginal
      ? bp.location.column === column
      : bp.generatedLocation.column === column;
  });
}

async function loadAndAddBreakpoint(dbg, filename, line, column) {
  const {
    selectors: { getBreakpoint, getBreakpointCount, getBreakpointsMap },
  } = dbg;

  await waitForSources(dbg, filename);

  ok(true, "Original sources exist");
  const source = findSource(dbg, filename);

  await selectSource(dbg, source);

  // Test that breakpoint is not off by a line.
  await addBreakpoint(dbg, source, line, column);

  is(getBreakpointCount(), 1, "One breakpoint exists");
  if (!getBreakpoint(createLocation({ source, line, column }))) {
    const breakpoints = getBreakpointsMap();
    const id = Object.keys(breakpoints).pop();
    const loc = breakpoints[id].location;
    ok(
      false,
      `Breakpoint has correct line ${line}, column ${column}, but was line ${loc.line} column ${loc.column}`
    );
  }

  return source;
}

async function invokeWithBreakpoint(
  dbg,
  fnName,
  filename,
  { line, column },
  handler
) {
  const source = await loadAndAddBreakpoint(dbg, filename, line, column);

  const invokeResult = invokeInTab(fnName);

  const invokeFailed = await Promise.race([
    waitForPaused(dbg),
    invokeResult.then(
      () => new Promise(() => {}),
      () => true
    ),
  ]);

  if (invokeFailed) {
    await invokeResult;
    return;
  }

  assertPausedAtSourceAndLine(dbg, findSource(dbg, filename).id, line, column);

  await removeBreakpoint(dbg, source.id, line, column);

  is(dbg.selectors.getBreakpointCount(), 0, "Breakpoint reverted");

  await handler(source);

  await resume(dbg);

  // eslint-disable-next-line max-len
  // If the invoke errored later somehow, capture here so the error is reported nicely.
  await invokeResult;
}

function prettyPrint(dbg) {
  const sourceId = dbg.selectors.getSelectedSourceId();
  return dbg.actions.togglePrettyPrint(sourceId);
}

async function expandAllScopes(dbg) {
  const scopes = await waitForElement(dbg, "scopes");
  const scopeElements = scopes.querySelectorAll(
    '.tree-node[aria-level="1"][data-expandable="true"]:not([aria-expanded="true"])'
  );
  const indices = Array.from(scopeElements, el => {
    return Array.prototype.indexOf.call(el.parentNode.childNodes, el);
  }).reverse();

  for (const index of indices) {
    await toggleScopeNode(dbg, index + 1);
  }
}

async function assertScopes(dbg, items) {
  await expandAllScopes(dbg);

  for (const [i, val] of items.entries()) {
    if (Array.isArray(val)) {
      is(getScopeNodeLabel(dbg, i + 1), val[0]);
      is(
        getScopeNodeValue(dbg, i + 1),
        val[1],
        `"${val[0]}" has the expected "${val[1]}" value`
      );
    } else {
      is(getScopeNodeLabel(dbg, i + 1), val);
    }
  }

  is(getScopeNodeLabel(dbg, items.length + 1), "Window");
}

function findSourceTreeThreadByName(dbg, name) {
  return [...findAllElements(dbg, "sourceTreeThreads")].find(el => {
    return el.textContent.includes(name);
  });
}

function findSourceNodeWithText(dbg, text) {
  return [...findAllElements(dbg, "sourceNodes")].find(el => {
    return el.textContent.includes(text);
  });
}

/**
 * Assert the icon type used in the SourceTree for a given source
 *
 * @param {Object} dbg
 * @param {String} sourceName
 *        Name of the source displayed in the source tree
 * @param {String} icon
 *        Expected icon CSS classname
 */
function assertSourceIcon(dbg, sourceName, icon) {
  const sourceItem = findSourceNodeWithText(dbg, sourceName);
  ok(sourceItem, `Found the source item for ${sourceName}`);
  is(
    sourceItem.querySelector(".source-icon").className,
    `img source-icon ${icon}`,
    `The icon for ${sourceName} is correct`
  );
}

async function expandSourceTree(dbg) {
  // Click on expand all context menu for all top level "expandable items".
  // If there is no project root, it will be thread items.
  // But when there is a project root, it can be directory or group items.
  // Select only expandable in order to ignore source items.
  for (const rootNode of dbg.win.document.querySelectorAll(
    ".sources-list > .tree > .tree-node[data-expandable=true]"
  )) {
    await expandAllSourceNodes(dbg, rootNode);
  }
}

async function expandAllSourceNodes(dbg, treeNode) {
  return triggerSourceTreeContextMenu(dbg, treeNode, "#node-menu-expand-all");
}

/**
 * Removes a breakpoint from a source at line/col.
 *
 * @memberof mochitest/actions
 * @param {Object} dbg
 * @param {String} source
 * @param {Number} line
 * @param {Number} col
 * @return {Promise}
 * @static
 */
function removeBreakpoint(dbg, sourceId, line, column) {
  const source = dbg.selectors.getSource(sourceId);
  column = column || getFirstBreakpointColumn(dbg, source, line);
  const location = createLocation({
    source,
    line,
    column,
  });
  const bp = getBreakpointForLocation(dbg, location);
  return dbg.actions.removeBreakpoint(bp);
}

/**
 * Toggles the Pause on exceptions feature in the debugger.
 *
 * @memberof mochitest/actions
 * @param {Object} dbg
 * @param {Boolean} pauseOnExceptions
 * @param {Boolean} pauseOnCaughtExceptions
 * @return {Promise}
 * @static
 */
async function togglePauseOnExceptions(
  dbg,
  pauseOnExceptions,
  pauseOnCaughtExceptions
) {
  return dbg.actions.pauseOnExceptions(
    pauseOnExceptions,
    pauseOnCaughtExceptions
  );
}

// Helpers

/**
 * Invokes a global function in the debuggee tab.
 *
 * @memberof mochitest/helpers
 * @param {String} fnc The name of a global function on the content window to
 *                     call. This is applied to structured clones of the
 *                     remaining arguments to invokeInTab.
 * @param {Any} ...args Remaining args to serialize and pass to fnc.
 * @return {Promise}
 * @static
 */
function invokeInTab(fnc, ...args) {
  info(`Invoking in tab: ${fnc}(${args.map(uneval).join(",")})`);
  return ContentTask.spawn(gBrowser.selectedBrowser, { fnc, args }, options =>
    content.wrappedJSObject[options.fnc](...options.args)
  );
}

function clickElementInTab(selector) {
  info(`click element ${selector} in tab`);

  return SpecialPowers.spawn(
    gBrowser.selectedBrowser,
    [selector],
    function (_selector) {
      const element = content.document.querySelector(_selector);
      // Run the click in another event loop in order to immediately resolve spawn's promise.
      // Otherwise if we pause on click and navigate, the JSWindowActor used by spawn will
      // be destroyed while its query is still pending. And this would reject the promise.
      content.setTimeout(() => {
        element.click();
      });
    }
  );
}

const isLinux = Services.appinfo.OS === "Linux";
const isMac = Services.appinfo.OS === "Darwin";
const cmdOrCtrl = isMac ? { metaKey: true } : { ctrlKey: true };
const shiftOrAlt = isMac
  ? { accelKey: true, shiftKey: true }
  : { accelKey: true, altKey: true };

const cmdShift = isMac
  ? { accelKey: true, shiftKey: true, metaKey: true }
  : { accelKey: true, shiftKey: true, ctrlKey: true };

// On Mac, going to beginning/end only works with meta+left/right.  On
// Windows, it only works with home/end.  On Linux, apparently, either
// ctrl+left/right or home/end work.
const endKey = isMac
  ? { code: "VK_RIGHT", modifiers: cmdOrCtrl }
  : { code: "VK_END" };
const startKey = isMac
  ? { code: "VK_LEFT", modifiers: cmdOrCtrl }
  : { code: "VK_HOME" };

const keyMappings = {
  close: { code: "w", modifiers: cmdOrCtrl },
  commandKeyDown: { code: "VK_META", modifiers: { type: "keydown" } },
  commandKeyUp: { code: "VK_META", modifiers: { type: "keyup" } },
  debugger: { code: "s", modifiers: shiftOrAlt },
  // test conditional panel shortcut
  toggleCondPanel: { code: "b", modifiers: cmdShift },
  inspector: { code: "c", modifiers: shiftOrAlt },
  quickOpen: { code: "p", modifiers: cmdOrCtrl },
  quickOpenFunc: { code: "o", modifiers: cmdShift },
  quickOpenLine: { code: ":", modifiers: cmdOrCtrl },
  fileSearch: { code: "f", modifiers: cmdOrCtrl },
  projectSearch: { code: "f", modifiers: cmdShift },
  fileSearchNext: { code: "g", modifiers: { metaKey: true } },
  fileSearchPrev: { code: "g", modifiers: cmdShift },
  goToLine: { code: "g", modifiers: { ctrlKey: true } },
  Enter: { code: "VK_RETURN" },
  ShiftEnter: { code: "VK_RETURN", modifiers: { shiftKey: true } },
  AltEnter: {
    code: "VK_RETURN",
    modifiers: { altKey: true },
  },
  Up: { code: "VK_UP" },
  Down: { code: "VK_DOWN" },
  Right: { code: "VK_RIGHT" },
  Left: { code: "VK_LEFT" },
  End: endKey,
  Start: startKey,
  Tab: { code: "VK_TAB" },
  Escape: { code: "VK_ESCAPE" },
  Delete: { code: "VK_DELETE" },
  pauseKey: { code: "VK_F8" },
  resumeKey: { code: "VK_F8" },
  stepOverKey: { code: "VK_F10" },
  stepInKey: { code: "VK_F11" },
  stepOutKey: {
    code: "VK_F11",
    modifiers: { shiftKey: true },
  },
  Backspace: { code: "VK_BACK_SPACE" },
};

/**
 * Simulates a key press in the debugger window.
 *
 * @memberof mochitest/helpers
 * @param {Object} dbg
 * @param {String} keyName
 * @return {Promise}
 * @static
 */
function pressKey(dbg, keyName) {
  const keyEvent = keyMappings[keyName];
  const { code, modifiers } = keyEvent;
  info(`The ${keyName} key is pressed`);
  return EventUtils.synthesizeKey(code, modifiers || {}, dbg.win);
}

function type(dbg, string) {
  string.split("").forEach(char => EventUtils.synthesizeKey(char, {}, dbg.win));
}

/*
 * Checks to see if the inner element is visible inside the editor.
 *
 * @memberof mochitest/helpers
 * @param {Object} dbg
 * @param {HTMLElement} inner element
 * @return {boolean}
 * @static
 */

function isVisibleInEditor(dbg, element) {
  return isVisible(findElement(dbg, "codeMirror"), element);
}

/*
 * Checks to see if the inner element is visible inside the
 * outer element.
 *
 * Note, the inner element does not need to be entirely visible,
 * it is possible for it to be somewhat clipped by the outer element's
 * bounding element or for it to span the entire length, starting before the
 * outer element and ending after.
 *
 * @memberof mochitest/helpers
 * @param {HTMLElement} outer element
 * @param {HTMLElement} inner element
 * @return {boolean}
 * @static
 */
function isVisible(outerEl, innerEl) {
  if (!innerEl || !outerEl) {
    return false;
  }

  const innerRect = innerEl.getBoundingClientRect();
  const outerRect = outerEl.getBoundingClientRect();

  const verticallyVisible =
    innerRect.top >= outerRect.top ||
    innerRect.bottom <= outerRect.bottom ||
    (innerRect.top < outerRect.top && innerRect.bottom > outerRect.bottom);

  const horizontallyVisible =
    innerRect.left >= outerRect.left ||
    innerRect.right <= outerRect.right ||
    (innerRect.left < outerRect.left && innerRect.right > outerRect.right);

  const visible = verticallyVisible && horizontallyVisible;
  return visible;
}

async function getEditorLineGutter(dbg, line) {
  const lineEl = await getEditorLineEl(dbg, line);
  return lineEl.firstChild;
}

async function getEditorLineEl(dbg, line) {
  let el = await codeMirrorGutterElement(dbg, line);
  while (el && !el.matches(".CodeMirror-code > div")) {
    el = el.parentElement;
  }

  return el;
}

/**
 * Opens the debugger editor context menu in either codemirror or the
 * the debugger gutter.
 * @param {Object} dbg
 * @param {String} elementName
 *                  The element to select
 * @param {Number} line
 *                  The line to open the context menu on.
 */
async function openContextMenuInDebugger(dbg, elementName, line) {
  const waitForOpen = waitForContextMenu(dbg);
  info(`Open ${elementName} context menu on line ${line || ""}`);
  rightClickElement(dbg, elementName, line);
  return waitForOpen;
}

/**
 * Select a range of lines in the editor and open the contextmenu
 * @param {Object} dbg
 * @param {Object} lines
 * @returns
 */
async function selectEditorLinesAndOpenContextMenu(dbg, lines) {
  const { startLine, endLine } = lines;
  const elementName = "line";
  if (!endLine) {
    await clickElement(dbg, elementName, startLine);
  } else {
    getCM(dbg).setSelection(
      { line: startLine - 1, ch: 0 },
      { line: endLine, ch: 0 }
    );
  }
  return openContextMenuInDebugger(dbg, elementName, startLine);
}

/**
 * Asserts that the styling for ignored lines are applied
 * @param {Object} dbg
 * @param {Object} options
 *                 lines {null | Number[]} [lines] Line(s) to assert.
 *                   - If null is passed, the assertion is on all the blackboxed lines
 *                   - If an array of one item (start line) is passed, the assertion is on the specified line
 *                   - If an array (start and end lines) is passed, the assertion is on the multiple lines seelected
 *                 hasBlackboxedLinesClass
 *                   If `true` assert that style exist, else assert that style does not exist
 */
function assertIgnoredStyleInSourceLines(
  dbg,
  { lines, hasBlackboxedLinesClass }
) {
  if (lines) {
    let currentLine = lines[0];
    do {
      const element = findElement(dbg, "line", currentLine);
      const hasStyle = hasBlackboxedLinesClass
        ? element.parentNode.classList.contains("blackboxed-line")
        : !element.parentNode.classList.contains("blackboxed-line");
      ok(
        hasStyle,
        `Line ${currentLine} ${
          hasBlackboxedLinesClass ? "does not have" : "has"
        } ignored styling`
      );
      currentLine = currentLine + 1;
    } while (currentLine <= lines[1]);
  } else {
    const codeLines = findAllElementsWithSelector(
      dbg,
      ".CodeMirror-code .CodeMirror-line"
    );
    const blackboxedLines = findAllElementsWithSelector(
      dbg,
      ".CodeMirror-code .blackboxed-line"
    );
    is(
      hasBlackboxedLinesClass ? codeLines.length : 0,
      blackboxedLines.length,
      `${blackboxedLines.length} of ${codeLines.length} lines are blackboxed`
    );
  }
}

/**
 * Assert the text content on the line matches what is
 * expected.
 *
 * @param {Object} dbg
 * @param {Number} line
 * @param {String} expectedTextContent
 */
function assertTextContentOnLine(dbg, line, expectedTextContent) {
  const lineInfo = getCM(dbg).lineInfo(line - 1);
  const textContent = lineInfo.text.trim();
  is(textContent, expectedTextContent, `Expected text content on line ${line}`);
}

/*
 * Assert that no breakpoint is set on a given line of
 * the currently selected source in the editor.
 *
 * @memberof mochitest/helpers
 * @param {Object} dbg
 * @param {Number} line Line where to check for a breakpoint in the editor
 * @static
 */
async function assertNoBreakpoint(dbg, line) {
  const el = await getEditorLineEl(dbg, line);

  const exists = !!el.querySelector(".new-breakpoint");
  ok(!exists, `Breakpoint doesn't exists on line ${line}`);
}

/*
 * Assert that a regular breakpoint is set in the currently
 * selected source in the editor. (no conditional, nor log breakpoint)
 *
 * @memberof mochitest/helpers
 * @param {Object} dbg
 * @param {Number} line Line where to check for a breakpoint
 * @static
 */
async function assertBreakpoint(dbg, line) {
  const el = await getEditorLineEl(dbg, line);

  const exists = !!el.querySelector(".new-breakpoint");
  ok(exists, `Breakpoint exists on line ${line}`);

  const hasConditionClass = el.classList.contains("has-condition");

  ok(
    !hasConditionClass,
    `Regular breakpoint doesn't have condition on line ${line}`
  );

  const hasLogClass = el.classList.contains("has-log");

  ok(!hasLogClass, `Regular breakpoint doesn't have log on line ${line}`);
}

/*
 * Assert that a conditionnal breakpoint is set.
 *
 * @memberof mochitest/helpers
 * @param {Object} dbg
 * @param {Number} line Line where to check for a breakpoint
 * @static
 */
async function assertConditionBreakpoint(dbg, line) {
  const el = await getEditorLineEl(dbg, line);

  const exists = !!el.querySelector(".new-breakpoint");
  ok(exists, `Breakpoint exists on line ${line}`);

  const hasConditionClass = el.classList.contains("has-condition");

  ok(hasConditionClass, `Conditional breakpoint on line ${line}`);

  const hasLogClass = el.classList.contains("has-log");

  ok(
    !hasLogClass,
    `Conditional breakpoint doesn't have log breakpoint on line ${line}`
  );
}

/*
 * Assert that a log breakpoint is set.
 *
 * @memberof mochitest/helpers
 * @param {Object} dbg
 * @param {Number} line Line where to check for a breakpoint
 * @static
 */
async function assertLogBreakpoint(dbg, line) {
  const el = await getEditorLineEl(dbg, line);

  const exists = !!el.querySelector(".new-breakpoint");
  ok(exists, `Breakpoint exists on line ${line}`);

  const hasConditionClass = el.classList.contains("has-condition");

  ok(
    !hasConditionClass,
    `Log breakpoint doesn't have condition on line ${line}`
  );

  const hasLogClass = el.classList.contains("has-log");

  ok(hasLogClass, `Log breakpoint on line ${line}`);
}

function assertBreakpointSnippet(dbg, index, snippet) {
  const actualSnippet = findElement(dbg, "breakpointLabel", 2).innerText;
  is(snippet, actualSnippet, `Breakpoint ${index} snippet`);
}

const selectors = {
  callStackHeader: ".call-stack-pane ._header",
  callStackBody: ".call-stack-pane .pane",
  domMutationItem: ".dom-mutation-list li",
  expressionNode: i =>
    `.expressions-list .expression-container:nth-child(${i}) .object-label`,
  expressionValue: i =>
    // eslint-disable-next-line max-len
    `.expressions-list .expression-container:nth-child(${i}) .object-delimiter + *`,
  expressionClose: i =>
    `.expressions-list .expression-container:nth-child(${i}) .close`,
  expressionInput: ".watch-expressions-pane input.input-expression",
  expressionNodes: ".expressions-list .tree-node",
  expressionPlus: ".watch-expressions-pane button.plus",
  expressionRefresh: ".watch-expressions-pane button.refresh",
  scopesHeader: ".scopes-pane ._header",
  breakpointItem: i => `.breakpoints-list div:nth-of-type(${i})`,
  breakpointLabel: i => `${selectors.breakpointItem(i)} .breakpoint-label`,
  breakpointHeadings: ".breakpoints-list .breakpoint-heading",
  breakpointItems: ".breakpoints-list .breakpoint",
  breakpointContextMenu: {
    disableSelf: "#node-menu-disable-self",
    disableAll: "#node-menu-disable-all",
    disableOthers: "#node-menu-disable-others",
    enableSelf: "#node-menu-enable-self",
    enableOthers: "#node-menu-enable-others",
    disableDbgStatement: "#node-menu-disable-dbgStatement",
    enableDbgStatement: "#node-menu-enable-dbgStatement",
    remove: "#node-menu-delete-self",
    removeOthers: "#node-menu-delete-other",
    removeCondition: "#node-menu-remove-condition",
  },
  editorContextMenu: {
    continueToHere: "#node-menu-continue-to-here",
  },
  columnBreakpoints: ".column-breakpoint",
  scopes: ".scopes-list",
  scopeNodes: ".scopes-list .object-label",
  scopeNode: i => `.scopes-list .tree-node:nth-child(${i}) .object-label`,
  scopeValue: i =>
    `.scopes-list .tree-node:nth-child(${i}) .object-delimiter + *`,
  mapScopesCheckbox: ".map-scopes-header input",
  frame: i => `.frames [role="list"] [role="listitem"]:nth-child(${i})`,
  frames: '.frames [role="list"] [role="listitem"]',
  gutter: i => `.CodeMirror-code *:nth-child(${i}) .CodeMirror-linenumber`,
  line: i => `.CodeMirror-code div:nth-child(${i}) .CodeMirror-line`,
  addConditionItem:
    "#node-menu-add-condition, #node-menu-add-conditional-breakpoint",
  editConditionItem:
    "#node-menu-edit-condition, #node-menu-edit-conditional-breakpoint",
  addLogItem: "#node-menu-add-log-point",
  editLogItem: "#node-menu-edit-log-point",
  disableItem: "#node-menu-disable-breakpoint",
  menuitem: i => `menupopup menuitem:nth-child(${i})`,
  pauseOnExceptions: ".pause-exceptions",
  breakpoint: ".CodeMirror-code > .new-breakpoint",
  highlightLine: ".CodeMirror-code > .highlight-line",
  debugLine: ".new-debug-line",
  debugErrorLine: ".new-debug-line-error",
  codeMirror: ".CodeMirror",
  resume: ".resume.active",
  pause: ".pause.active",
  sourceTabs: ".source-tabs",
  activeTab: ".source-tab.active",
  stepOver: ".stepOver.active",
  stepOut: ".stepOut.active",
  stepIn: ".stepIn.active",
  trace: ".debugger-trace-menu-button",
  prettyPrintButton: ".source-footer .prettyPrint",
  sourceMapLink: ".source-footer .mapped-source",
  sourcesFooter: ".sources-panel .source-footer",
  editorFooter: ".editor-pane .source-footer",
  sourceNode: i => `.sources-list .tree-node:nth-child(${i}) .node`,
  sourceNodes: ".sources-list .tree-node",
  sourceTreeThreads: '.sources-list .tree-node[aria-level="1"]',
  sourceTreeThreadsNodes:
    '.sources-list .tree-node[aria-level="1"] > .node > span:nth-child(1)',
  sourceTreeFiles: ".sources-list .tree-node[data-expandable=false]",
  threadSourceTree: i => `.threads-list .sources-pane:nth-child(${i})`,
  threadSourceTreeSourceNode: (i, j) =>
    `${selectors.threadSourceTree(i)} .tree-node:nth-child(${j}) .node`,
  sourceDirectoryLabel: i => `.sources-list .tree-node:nth-child(${i}) .label`,
  resultItems: ".result-list .result-item",
  resultItemName: (name, i) =>
    `${selectors.resultItems}:nth-child(${i})[title$="${name}"]`,
  fileMatch: ".project-text-search .line-value",
  popup: ".popover",
  previewPopup: ".preview-popup",
  openInspector: "button.open-inspector",
  outlineItem: i =>
    `.outline-list__element:nth-child(${i}) .function-signature`,
  outlineItems: ".outline-list__element",
  conditionalPanel: ".conditional-breakpoint-panel",
  conditionalPanelInput: ".conditional-breakpoint-panel textarea",
  conditionalBreakpointInSecPane: ".breakpoint.is-conditional",
  logPointPanel: ".conditional-breakpoint-panel.log-point",
  logPointInSecPane: ".breakpoint.is-log",
  searchField: ".search-field",
  blackbox: ".action.black-box",
  projectSearchSearchInput: ".project-text-search .search-field input",
  projectSearchCollapsed: ".project-text-search .arrow:not(.expanded)",
  projectSearchExpandedResults: ".project-text-search .result",
  projectSearchFileResults: ".project-text-search .file-result",
  projectSearchModifiersCaseSensitive:
    ".project-text-search button.case-sensitive-btn",
  projectSearchModifiersRegexMatch:
    ".project-text-search button.regex-match-btn",
  projectSearchModifiersWholeWordMatch:
    ".project-text-search button.whole-word-btn",
  threadsPaneItems: ".threads-pane .thread",
  threadsPaneItem: i => `.threads-pane .thread:nth-child(${i})`,
  threadsPaneItemPause: i => `${selectors.threadsPaneItem(i)} .pause-badge`,
  CodeMirrorLines: ".CodeMirror-lines",
  inlinePreviewLabels: ".inline-preview .inline-preview-label",
  inlinePreviewValues: ".inline-preview .inline-preview-value",
  inlinePreviewOpenInspector: ".inline-preview-value button.open-inspector",
  watchpointsSubmenu: "#node-menu-watchpoints",
  addGetWatchpoint: "#node-menu-add-get-watchpoint",
  addSetWatchpoint: "#node-menu-add-set-watchpoint",
  removeWatchpoint: "#node-menu-remove-watchpoint",
  logEventsCheckbox: ".events-header input",
  previewPopupInvokeGetterButton: ".preview-popup .invoke-getter",
  previewPopupObjectNumber: ".preview-popup .objectBox-number",
  previewPopupObjectObject: ".preview-popup .objectBox-object",
  sourceTreeRootNode: ".sources-panel .node .window",
  sourceTreeFolderNode: ".sources-panel .node .folder",
  excludePatternsInput: ".project-text-search .exclude-patterns-field input",
  fileSearchInput: ".search-bar input",
};

function getSelector(elementName, ...args) {
  let selector = selectors[elementName];
  if (!selector) {
    throw new Error(`The selector ${elementName} is not defined`);
  }

  if (typeof selector == "function") {
    selector = selector(...args);
  }

  return selector;
}

function findElement(dbg, elementName, ...args) {
  const selector = getSelector(elementName, ...args);
  return findElementWithSelector(dbg, selector);
}

function findElementWithSelector(dbg, selector) {
  return dbg.win.document.querySelector(selector);
}

function findAllElements(dbg, elementName, ...args) {
  const selector = getSelector(elementName, ...args);
  return findAllElementsWithSelector(dbg, selector);
}

function findAllElementsWithSelector(dbg, selector) {
  return dbg.win.document.querySelectorAll(selector);
}

function getSourceNodeLabel(dbg, index) {
  return findElement(dbg, "sourceNode", index)
    .textContent.trim()
    .replace(/^[\s\u200b]*/g, "");
}

/**
 * Simulates a mouse click in the debugger DOM.
 *
 * @memberof mochitest/helpers
 * @param {Object} dbg
 * @param {String} elementName
 * @param {Array} args
 * @return {Promise}
 * @static
 */
async function clickElement(dbg, elementName, ...args) {
  const selector = getSelector(elementName, ...args);
  const el = await waitForElementWithSelector(dbg, selector);

  el.scrollIntoView();

  return clickElementWithSelector(dbg, selector);
}

function clickElementWithSelector(dbg, selector) {
  clickDOMElement(dbg, findElementWithSelector(dbg, selector));
}

function clickDOMElement(dbg, element, options = {}) {
  EventUtils.synthesizeMouseAtCenter(element, options, dbg.win);
}

function dblClickElement(dbg, elementName, ...args) {
  const selector = getSelector(elementName, ...args);

  return EventUtils.synthesizeMouseAtCenter(
    findElementWithSelector(dbg, selector),
    { clickCount: 2 },
    dbg.win
  );
}

function clickElementWithOptions(dbg, elementName, options, ...args) {
  const selector = getSelector(elementName, ...args);
  const el = findElementWithSelector(dbg, selector);
  el.scrollIntoView();

  return EventUtils.synthesizeMouseAtCenter(el, options, dbg.win);
}

function altClickElement(dbg, elementName, ...args) {
  return clickElementWithOptions(dbg, elementName, { altKey: true }, ...args);
}

function shiftClickElement(dbg, elementName, ...args) {
  return clickElementWithOptions(dbg, elementName, { shiftKey: true }, ...args);
}

function rightClickElement(dbg, elementName, ...args) {
  const selector = getSelector(elementName, ...args);
  const doc = dbg.win.document;
  return rightClickEl(dbg, doc.querySelector(selector));
}

function rightClickEl(dbg, el) {
  const doc = dbg.win.document;
  el.scrollIntoView();
  EventUtils.synthesizeMouseAtCenter(el, { type: "contextmenu" }, dbg.win);
}

async function clearElement(dbg, elementName) {
  await clickElement(dbg, elementName);
  await pressKey(dbg, "End");
  const selector = getSelector(elementName);
  const el = findElementWithSelector(dbg, getSelector(elementName));
  let len = el.value.length;
  while (len) {
    pressKey(dbg, "Backspace");
    len--;
  }
}

async function clickGutter(dbg, line) {
  const el = await codeMirrorGutterElement(dbg, line);
  clickDOMElement(dbg, el);
}

async function cmdClickGutter(dbg, line) {
  const el = await codeMirrorGutterElement(dbg, line);
  clickDOMElement(dbg, el, cmdOrCtrl);
}

function findContextMenu(dbg, selector) {
  // the context menu is in the toolbox window
  const doc = dbg.toolbox.topDoc;

  // there are several context menus, we want the one with the menu-api
  const popup = doc.querySelector('menupopup[menu-api="true"]');

  return popup.querySelector(selector);
}

// Waits for the context menu to exist and to fully open. Once this function
// completes, selectContextMenuItem can be called.
// waitForContextMenu must be called after menu opening has been triggered, e.g.
// after synthesizing a right click / contextmenu event.
async function waitForContextMenu(dbg) {
  // the context menu is in the toolbox window
  const doc = dbg.toolbox.topDoc;

  // there are several context menus, we want the one with the menu-api
  const popup = await waitFor(() =>
    doc.querySelector('menupopup[menu-api="true"]')
  );

  if (popup.state == "open") {
    return popup;
  }

  await new Promise(resolve => {
    popup.addEventListener("popupshown", () => resolve(), { once: true });
  });

  return popup;
}

/**
 * Closes and open context menu popup.
 *
 * @memberof mochitest/helpers
 * @param {Object} dbg
 * @param {String} popup - The currently opened popup returned by
 *                         `waitForContextMenu`.
 * @return {Promise}
 */

async function closeContextMenu(dbg, popup) {
  const onHidden = new Promise(resolve => {
    popup.addEventListener("popuphidden", resolve, { once: true });
  });
  popup.hidePopup();
  return onHidden;
}

function selectContextMenuItem(dbg, selector) {
  const item = findContextMenu(dbg, selector);
  item.closest("menupopup").activateItem(item);
}

async function openContextMenuSubmenu(dbg, selector) {
  const item = findContextMenu(dbg, selector);
  const popup = item.menupopup;
  const popupshown = new Promise(resolve => {
    popup.addEventListener("popupshown", () => resolve(), { once: true });
  });
  item.openMenu(true);
  await popupshown;
  return popup;
}

async function assertContextMenuLabel(dbg, selector, expectedLabel) {
  const item = await waitFor(() => findContextMenu(dbg, selector));
  is(
    item.label,
    expectedLabel,
    "The label of the context menu item shown to the user"
  );
}

async function typeInPanel(dbg, text) {
  await waitForElement(dbg, "conditionalPanelInput");
  // Position cursor reliably at the end of the text.
  pressKey(dbg, "End");
  type(dbg, text);
  pressKey(dbg, "Enter");
}

function toggleScopes(dbg) {
  return findElement(dbg, "scopesHeader").click();
}

function toggleExpressionNode(dbg, index) {
  return toggleObjectInspectorNode(findElement(dbg, "expressionNode", index));
}

function toggleScopeNode(dbg, index) {
  return toggleObjectInspectorNode(findElement(dbg, "scopeNode", index));
}

function rightClickScopeNode(dbg, index) {
  rightClickObjectInspectorNode(dbg, findElement(dbg, "scopeNode", index));
}

function getScopeNodeLabel(dbg, index) {
  return findElement(dbg, "scopeNode", index).innerText;
}

function getScopeNodeValue(dbg, index) {
  return findElement(dbg, "scopeValue", index).innerText;
}

function toggleObjectInspectorNode(node) {
  const objectInspector = node.closest(".object-inspector");
  const properties = objectInspector.querySelectorAll(".node").length;

  info(`Toggling node ${node.innerText}`);
  node.click();
  return waitUntil(
    () => objectInspector.querySelectorAll(".node").length !== properties
  );
}

function rightClickObjectInspectorNode(dbg, node) {
  const objectInspector = node.closest(".object-inspector");
  const properties = objectInspector.querySelectorAll(".node").length;

  info(`Right clicking node ${node.innerText}`);
  rightClickEl(dbg, node);

  return waitUntil(
    () => objectInspector.querySelectorAll(".node").length !== properties
  );
}

function getCM(dbg) {
  const el = dbg.win.document.querySelector(".CodeMirror");
  return el.CodeMirror;
}

function getCoordsFromPosition(cm, { line, ch }) {
  return cm.charCoords({ line: ~~line, ch: ~~ch });
}

async function getTokenFromPosition(dbg, { line, column = 0 }) {
  info(`Get token at ${line}:${column}`);
  const cm = getCM(dbg);

  // CodeMirror is 0-based while line and column arguments are 1-based.
  // Pass "ch=-1" when there is no column argument passed.
  const cmPosition = { line: line - 1, ch: column - 1 };

  const onScrolled = waitForScrolling(cm);
  cm.scrollIntoView(cmPosition, 0);

  // Ensure the line is visible with margin because the bar at the bottom of
  // the editor overlaps into what the editor thinks is its own space, blocking
  // the click event below.
  await onScrolled;

  const { left, top } = getCoordsFromPosition(cm, cmPosition);

  // Adds a vertical offset due to increased line height
  // https://github.com/firefox-devtools/debugger/pull/7934
  const lineHeightOffset = 3;

  // Note that we might end up retrieving any popup if one is still shown over the expected token
  return dbg.win.document.elementFromPoint(left, top + lineHeightOffset);
}

async function waitForScrolling(codeMirror) {
  return new Promise(resolve => {
    codeMirror.on("scroll", resolve);
    setTimeout(resolve, 500);
  });
}

async function codeMirrorGutterElement(dbg, line) {
  info(`CodeMirror line ${line}`);
  const cm = getCM(dbg);

  const position = { line: line - 1, ch: 0 };
  cm.scrollIntoView(position, 0);
  await waitForScrolling(cm);

  const coords = getCoordsFromPosition(cm, position);

  const { left, top } = coords;

  // Adds a vertical offset due to increased line height
  // https://github.com/firefox-devtools/debugger/pull/7934
  const lineHeightOffset = 3;

  // Click in the center of the line/breakpoint
  const leftOffset = 10;

  const tokenEl = dbg.win.document.elementFromPoint(
    left - leftOffset,
    top + lineHeightOffset
  );

  if (!tokenEl) {
    throw new Error(`Failed to find element for line ${line}`);
  }
  return tokenEl;
}

async function clickAtPos(dbg, pos) {
  const tokenEl = await getTokenFromPosition(dbg, pos);

  if (!tokenEl) {
    return;
  }

  const { top, left } = tokenEl.getBoundingClientRect();
  info(
    `Clicking on token ${tokenEl.innerText} in line ${tokenEl.parentNode.innerText}`
  );
  tokenEl.dispatchEvent(
    new MouseEvent("click", {
      bubbles: true,
      cancelable: true,
      view: dbg.win,
      clientX: left,
      clientY: top,
    })
  );
}

async function rightClickAtPos(dbg, pos) {
  const el = await getTokenFromPosition(dbg, pos);
  if (!el) {
    return;
  }

  EventUtils.synthesizeMouseAtCenter(el, { type: "contextmenu" }, dbg.win);
}

async function hoverAtPos(dbg, pos) {
  const tokenEl = await getTokenFromPosition(dbg, pos);

  if (!tokenEl) {
    return;
  }

  hoverToken(tokenEl);
}

function hoverToken(tokenEl) {
  info(`Hovering on token "${tokenEl.innerText}"`);

  // This first event helps utils/editor/token-events.js to receive the right mouseover event
  EventUtils.synthesizeMouseAtCenter(
    tokenEl,
    {
      type: "mouseover",
    },
    tokenEl.ownerGlobal
  );

  // This second event helps Popover to have :hover pseudoclass set on the token element
  EventUtils.synthesizeMouseAtCenter(
    tokenEl,
    {
      type: "mousemove",
    },
    tokenEl.ownerGlobal
  );
}

/**
 * Helper to close a variable preview popup.
 *
 * @param {Object} dbg
 * @param {DOM Element} tokenEl
 *        The DOM element on which we hovered to display the popup.
 * @param {String} previewType
 *        Based on the actual JS value being hovered we may have two different kinds
 *        of popups: popup (for js objects) or previewPopup (for primitives)
 */
async function closePreviewForToken(
  dbg,
  tokenEl,
  previewType = "previewPopup"
) {
  ok(
    findElement(dbg, previewType),
    "A preview was opened before trying to close it"
  );

  // Force "mousing out" from all elements.
  //
  // This helps utils/editor/token-events.js to receive the right mouseleave event.
  // This is super important as it will then allow re-emitting a tokenenter event if you try to re-preview the same token!
  EventUtils.synthesizeMouseAtCenter(
    tokenEl,
    {
      type: "mouseout",
    },
    tokenEl.ownerGlobal
  );

  // This second event helps Popover to have :hover pseudoclass removed on the token element
  //
  // For some unexplained reason, the precise element onto which we emit mousemove is actually important.
  // Emitting it on documentElement, or random other element within CodeMirror would cause
  // a "mousemove" event to be emitted on preview-popup element instead and wouldn't cause :hover
  // pseudoclass to be dropped.
  const element = tokenEl.ownerDocument.querySelector(
    ".debugger-settings-menu-button"
  );
  EventUtils.synthesizeMouseAtCenter(
    element,
    {
      type: "mousemove",
    },
    element.ownerGlobal
  );

  await waitUntil(() => findElement(dbg, previewType) == null);
  info("Preview closed");
}

// tryHovering will hover at a position every second until we
// see a preview element (popup, tooltip) appear. Once it appears,
// it considers it a success.
async function tryHovering(dbg, line, column, elementName) {
  ok(
    !findElement(dbg, elementName),
    "The expected preview element on hover should not exist beforehand"
  );

  const tokenEl = await getTokenFromPosition(dbg, { line, column });
  hoverToken(tokenEl);

  // Wait for the preview element to be created
  const element = await waitForElement(dbg, elementName);

  return { element, tokenEl };
}
/**
 * Hovers and asserts tooltip previews with simple text expressions (i.e numbers and strings)
 * @param {*} dbg
 * @param {Number} line
 * @param {Number} column
 * @param {Object} options
 * @param {String}  options.result - Expected text shown in the preview
 * @param {String}  options.expression - The expression hovered over
 * @param {Boolean} options.doNotClose - Set to true to not close the tooltip
 */
async function assertPreviewTextValue(
  dbg,
  line,
  column,
  { result, expression, doNotClose = false }
) {
  const { element: previewEl, tokenEl } = await tryHovering(
    dbg,
    line,
    column,
    "previewPopup"
  );

  ok(
    tokenEl.innerText.includes(expression),
    "Popup preview hovered expression is correct. Got: " +
      tokenEl.innerText +
      " Expected: " +
      expression
  );

  ok(
    previewEl.innerText.includes(result),
    "Popup preview text shown to user. Got: " +
      previewEl.innerText +
      " Expected: " +
      result
  );

  if (!doNotClose) {
    await closePreviewForToken(dbg, tokenEl);
  }
}

/**
 * Asserts multiple previews
 * @param {*} dbg
 * @param {Array} previews
 */
async function assertPreviews(dbg, previews) {
  for (const { line, column, expression, result, header, fields } of previews) {
    info(" # Assert preview on " + line + ":" + column);

    if (result) {
      await assertPreviewTextValue(dbg, line, column, {
        expression,
        result,
      });
    }

    if (fields) {
      const { element: popupEl, tokenEl } = await tryHovering(
        dbg,
        line,
        column,
        "popup"
      );

      info("Wait for child nodes to load");
      await waitUntil(
        () => popupEl.querySelectorAll(".preview-popup .node").length > 1
      );
      ok(true, "child nodes loaded");

      const oiNodes = Array.from(
        popupEl.querySelectorAll(".preview-popup .node")
      );

      if (header) {
        is(
          oiNodes[0].querySelector(".objectBox").textContent,
          header,
          "popup has expected value"
        );
      }

      for (const [field, value] of fields) {
        const node = oiNodes.find(
          oiNode => oiNode.querySelector(".object-label")?.textContent === field
        );
        if (!node) {
          ok(false, `The "${field}" property is not displayed in the popup`);
        } else {
          is(
            node.querySelector(".object-label").textContent,
            field,
            `The "${field}" property is displayed in the popup`
          );
          if (value !== undefined) {
            is(
              node.querySelector(".objectBox").textContent,
              value,
              `The "${field}" property has the expected value`
            );
          }
        }
      }

      await closePreviewForToken(dbg, tokenEl, "popup");
    }
  }
}

/**
 * Asserts the inline expression preview value
 * @param {*} dbg
 * @param {Number} line
 * @param {Number} column
 * @param {Object} options
 * @param {String}  options.result - Expected text shown in the preview
 * @param {String}  options.expression - The expression hovered over
 * @param {Array}  options.fields - The expected stacktrace information
 */
async function assertInlineExceptionPreview(
  dbg,
  line,
  column,
  { expression, result, fields }
) {
  info(" # Assert preview on " + line + ":" + column);
  const { element: popupEl, tokenEl } = await tryHovering(
    dbg,
    line,
    column,
    "previewPopup"
  );

  info("Wait for top level node to expand and child nodes to load");
  await waitForElementWithSelector(
    dbg,
    ".exception-popup .exception-message .arrow.expanded"
  );

  is(
    popupEl.querySelector(".preview-popup .exception-message .objectBox")
      .textContent,
    result,
    "The correct result is not displayed in the popup"
  );

  await waitFor(() =>
    popupEl.querySelectorAll(".preview-popup .exception-stacktrace .frame")
  );
  const stackFrameNodes = Array.from(
    popupEl.querySelectorAll(".preview-popup .exception-stacktrace .frame")
  );

  for (const [field, value] of fields) {
    const node = stackFrameNodes.find(
      frameNode => frameNode.querySelector(".title")?.textContent === field
    );
    if (!node) {
      ok(false, `The "${field}" property is not displayed in the popup`);
    } else {
      is(
        node.querySelector(".location").textContent,
        value,
        `The "${field}" property has the expected value`
      );
    }
  }

  await closePreviewForToken(dbg, tokenEl, "previewPopup");
}

async function waitForBreakableLine(dbg, source, lineNumber) {
  await waitForState(
    dbg,
    state => {
      const currentSource = findSource(dbg, source);

      const breakableLines =
        currentSource && dbg.selectors.getBreakableLines(currentSource.id);

      return breakableLines && breakableLines.includes(lineNumber);
    },
    `waiting for breakable line ${lineNumber}`
  );
}

async function waitForSourceTreeThreadsCount(dbg, i) {
  info(`waiting for ${i} threads in the source tree`);
  await waitUntil(() => {
    return findAllElements(dbg, "sourceTreeThreads").length === i;
  });
}

async function waitForSourcesInSourceTree(
  dbg,
  sources,
  { noExpand = false } = {}
) {
  info(`waiting for ${sources.length} files in the source tree`);
  function getDisplayedSources() {
    // Replace some non visible space characters that prevents Array.includes from working correctly
    return [...findAllElements(dbg, "sourceTreeFiles")].map(e => {
      return e.textContent.trim().replace(/^[\s\u200b]*/g, "");
    });
  }
  try {
    // Use custom timeout and retry count for waitFor as the test method is slow to resolve
    // and default value makes the timeout unecessarily long
    await waitFor(
      async () => {
        if (!noExpand) {
          await expandSourceTree(dbg);
        }
        const displayedSources = getDisplayedSources();
        return (
          displayedSources.length == sources.length &&
          sources.every(source => displayedSources.includes(source))
        );
      },
      null,
      100,
      50
    );
  } catch (e) {
    // Craft a custom error message to help understand what's wrong with the Source Tree content
    const displayedSources = getDisplayedSources();
    let msg = "Invalid Source Tree Content.\n";
    const missingElements = [];
    for (const source of sources) {
      const idx = displayedSources.indexOf(source);
      if (idx != -1) {
        displayedSources.splice(idx, 1);
      } else {
        missingElements.push(source);
      }
    }
    if (missingElements.length) {
      msg += "Missing elements: " + missingElements.join(", ") + "\n";
    }
    if (displayedSources.length) {
      msg += "Unexpected elements: " + displayedSources.join(", ");
    }
    throw new Error(msg);
  }
}

async function waitForNodeToGainFocus(dbg, index) {
  await waitUntil(() => {
    const element = findElement(dbg, "sourceNode", index);

    if (element) {
      return element.classList.contains("focused");
    }

    return false;
  }, `waiting for source node ${index} to be focused`);
}

async function assertNodeIsFocused(dbg, index) {
  await waitForNodeToGainFocus(dbg, index);
  const node = findElement(dbg, "sourceNode", index);
  ok(node.classList.contains("focused"), `node ${index} is focused`);
}

/**
 * Asserts that the debugger is paused and the debugger tab is
 * highlighted.
 * @param {*} toolbox
 * @returns
 */
async function assertDebuggerIsHighlightedAndPaused(toolbox) {
  info("Wait for the debugger to be automatically selected on pause");
  await waitUntil(() => toolbox.currentToolId == "jsdebugger");
  ok(true, "Debugger selected");

  // Wait for the debugger to finish loading.
  await toolbox.getPanelWhenReady("jsdebugger");

  // And to be fully paused
  const dbg = createDebuggerContext(toolbox);
  await waitForPaused(dbg);

  ok(toolbox.isHighlighted("jsdebugger"), "Debugger is highlighted");

  return dbg;
}

async function addExpression(dbg, input) {
  info("Adding an expression");

  const plusIcon = findElementWithSelector(dbg, selectors.expressionPlus);
  if (plusIcon) {
    plusIcon.click();
  }
  findElementWithSelector(dbg, selectors.expressionInput).focus();
  type(dbg, input);
  const evaluated = waitForDispatch(dbg.store, "EVALUATE_EXPRESSION");
  pressKey(dbg, "Enter");
  await evaluated;
}

async function editExpression(dbg, input) {
  info("Updating the expression");
  dblClickElement(dbg, "expressionNode", 1);
  // Position cursor reliably at the end of the text.
  pressKey(dbg, "End");
  type(dbg, input);
  const evaluated = waitForDispatch(dbg.store, "EVALUATE_EXPRESSIONS");
  pressKey(dbg, "Enter");
  await evaluated;
}

/**
 * Get the text representation of a watch expression label given its position in the panel
 *
 * @param {Object} dbg
 * @param {Number} index: Position in the panel of the expression we want the label of
 * @returns {String}
 */
function getWatchExpressionLabel(dbg, index) {
  return findElement(dbg, "expressionNode", index).innerText;
}

/**
 * Get the text representation of a watch expression value given its position in the panel
 *
 * @param {Object} dbg
 * @param {Number} index: Position in the panel of the expression we want the value of
 * @returns {String}
 */
function getWatchExpressionValue(dbg, index) {
  return findElement(dbg, "expressionValue", index).innerText;
}

// 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.
async function getDebuggerSplitConsole(dbg) {
  let { toolbox, win } = dbg;

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

  if (!toolbox.splitConsole) {
    pressKey(dbg, "Escape");
  }

  await toolbox.openSplitConsole();
  return toolbox.getPanel("webconsole");
}

// Return a promise that resolves with the result of a thread evaluating a
// string in the topmost frame.
async function evaluateInTopFrame(dbg, text) {
  const threadFront = dbg.toolbox.target.threadFront;
  const { frames } = await threadFront.getFrames(0, 1);
  ok(frames.length == 1, "Got one frame");
  const response = await dbg.commands.scriptCommand.execute(text, {
    frameActor: frames[0].actorID,
  });
  return response.result.type == "undefined" ? undefined : response.result;
}

// Return a promise that resolves when a thread evaluates a string in the
// topmost frame, ensuring the result matches the expected value.
async function checkEvaluateInTopFrame(dbg, text, expected) {
  const rval = await evaluateInTopFrame(dbg, text);
  ok(rval == expected, `Eval returned ${expected}`);
}

async function findConsoleMessage({ toolbox }, query) {
  const [message] = await findConsoleMessages(toolbox, query);
  const value = message.querySelector(".message-body").innerText;
  // There are console messages which might not have a link e.g Error messages
  const link = message.querySelector(".frame-link-source")?.innerText;
  return { value, link };
}

async function findConsoleMessages(toolbox, query) {
  const webConsole = await toolbox.getPanel("webconsole");
  const win = webConsole._frameWindow;
  return Array.prototype.filter.call(
    win.document.querySelectorAll(".message"),
    e => e.innerText.includes(query)
  );
}

async function hasConsoleMessage({ toolbox }, msg) {
  return waitFor(async () => {
    const messages = await findConsoleMessages(toolbox, msg);
    return !!messages.length;
  });
}

function evaluateExpressionInConsole(hud, expression) {
  const seenMessages = new Set(
    JSON.parse(
      hud.ui.outputNode
        .querySelector("[data-visible-messages]")
        .getAttribute("data-visible-messages")
    )
  );
  const onResult = new Promise(res => {
    const onNewMessage = messages => {
      for (const message of messages) {
        if (
          message.node.classList.contains("result") &&
          !seenMessages.has(message.node.getAttribute("data-message-id"))
        ) {
          hud.ui.off("new-messages", onNewMessage);
          res(message.node);
        }
      }
    };
    hud.ui.on("new-messages", onNewMessage);
  });
  hud.ui.wrapper.dispatchEvaluateExpression(expression);
  return onResult;
}

function waitForInspectorPanelChange(dbg) {
  return dbg.toolbox.getPanelWhenReady("inspector");
}

function getEagerEvaluationElement(hud) {
  return hud.ui.outputNode.querySelector(".eager-evaluation-result");
}

async function waitForEagerEvaluationResult(hud, text) {
  await waitUntil(() => {
    const elem = getEagerEvaluationElement(hud);
    if (elem) {
      if (text instanceof RegExp) {
        return text.test(elem.innerText);
      }
      return elem.innerText == text;
    }
    return false;
  });
  ok(true, `Got eager evaluation result ${text}`);
}

function setInputValue(hud, value) {
  const onValueSet = hud.jsterm.once("set-input-value");
  hud.jsterm._setValue(value);
  return onValueSet;
}

function assertMenuItemChecked(menuItem, isChecked) {
  is(
    !!menuItem.getAttribute("aria-checked"),
    isChecked,
    `Item has expected state: ${isChecked ? "checked" : "unchecked"}`
  );
}

async function toggleDebbuggerSettingsMenuItem(dbg, { className, isChecked }) {
  const menuButton = findElementWithSelector(
    dbg,
    ".debugger-settings-menu-button"
  );
  const { parent } = dbg.panel.panelWin;
  const { document } = parent;

  menuButton.click();
  // Waits for the debugger settings panel to appear.
  await waitFor(() => {
    const menuListEl = document.querySelector("#debugger-settings-menu-list");
    // Lets check the offsetParent property to make sure the menu list is actually visible
    // by its parents display property being no longer "none".
    return menuListEl && menuListEl.offsetParent !== null;
  });

  const menuItem = document.querySelector(className);

  assertMenuItemChecked(menuItem, isChecked);

  menuItem.click();

  // Waits for the debugger settings panel to disappear.
  await waitFor(() => menuButton.getAttribute("aria-expanded") === "false");
}

async function setLogPoint(dbg, index, value) {
  rightClickElement(dbg, "gutter", index);
  await waitForContextMenu(dbg);
  selectContextMenuItem(
    dbg,
    `${selectors.addLogItem},${selectors.editLogItem}`
  );
  const onBreakpointSet = waitForDispatch(dbg.store, "SET_BREAKPOINT");
  await typeInPanel(dbg, value);
  await onBreakpointSet;
}
/**
 * Opens the project search panel
 *
 * @param {Object} dbg
 * @return {Boolean} The project search is open
 */
function openProjectSearch(dbg) {
  info("Opening the project search panel");
  synthesizeKeyShortcut("CmdOrCtrl+Shift+F");
  return waitForState(
    dbg,
    state => dbg.selectors.getActiveSearch() === "project"
  );
}

/**
 * Starts a project search based on the specified search term
 *
 * @param {Object} dbg
 * @param {String} searchTerm - The test to search for
 * @return {Array} List of search results element nodes
 */
async function doProjectSearch(dbg, searchTerm) {
  await clearElement(dbg, "projectSearchSearchInput");
  type(dbg, searchTerm);
  pressKey(dbg, "Enter");
  return waitForSearchResults(dbg);
}

/**
 * Waits for the search resluts node to render
 *
 * @param {Object} dbg
 * @param {Number} expectedResults - The expected no of results to wait for
 * @return (Array) List of search result element nodes
 */
async function waitForSearchResults(dbg, expectedResults) {
  await waitForState(dbg, state => state.projectTextSearch.status === "DONE");
  if (expectedResults) {
    await waitUntil(
      () =>
        findAllElements(dbg, "projectSearchFileResults").length ==
        expectedResults
    );
  }
  return findAllElements(dbg, "projectSearchFileResults");
}

/**
 * Get the no of expanded search results
 *
 * @param {Object} dbg
 * @return {Number} No of expanded results
 */
function getExpandedResultsCount(dbg) {
  return findAllElements(dbg, "projectSearchExpandedResults").length;
}

// This module is also loaded for Browser Toolbox tests, within the browser toolbox process
// which doesn't contain mochitests resource://testing-common URL.
// This isn't important to allow rejections in the context of the browser toolbox tests.
const protocolHandler = Services.io
  .getProtocolHandler("resource")
  .QueryInterface(Ci.nsIResProtocolHandler);
if (protocolHandler.hasSubstitution("testing-common")) {
  const { PromiseTestUtils } = ChromeUtils.importESModule(
    "resource://testing-common/PromiseTestUtils.sys.mjs"
  );
  PromiseTestUtils.allowMatchingRejectionsGlobally(/Connection closed/);
  this.PromiseTestUtils = PromiseTestUtils;

  // Debugger operations that are canceled because they were rendered obsolete by
  // a navigation or pause/resume end up as uncaught rejections. These never
  // indicate errors and are allowed in all debugger tests.
  // All the following are related to context middleware throwing on obsolete async actions:
  PromiseTestUtils.allowMatchingRejectionsGlobally(/DebuggerContextError/);
}

/**
 * Selects the specific black box context menu item
 * @param {Object} dbg
 * @param {String} itemName
 *                  The name of the context menu item.
 */
async function selectBlackBoxContextMenuItem(dbg, itemName) {
  let wait = null;
  if (itemName == "blackbox-line" || itemName == "blackbox-lines") {
    wait = Promise.any([
      waitForDispatch(dbg.store, "BLACKBOX_SOURCE_RANGES"),
      waitForDispatch(dbg.store, "UNBLACKBOX_SOURCE_RANGES"),
    ]);
  } else if (itemName == "blackbox") {
    wait = Promise.any([
      waitForDispatch(dbg.store, "BLACKBOX_WHOLE_SOURCES"),
      waitForDispatch(dbg.store, "UNBLACKBOX_WHOLE_SOURCES"),
    ]);
  }

  info(`Select the ${itemName} context menu item`);
  selectContextMenuItem(dbg, `#node-menu-${itemName}`);
  return wait;
}
