summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/test/mochitest/shared-head.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/debugger/test/mochitest/shared-head.js')
-rw-r--r--devtools/client/debugger/test/mochitest/shared-head.js2719
1 files changed, 2719 insertions, 0 deletions
diff --git a/devtools/client/debugger/test/mochitest/shared-head.js b/devtools/client/debugger/test/mochitest/shared-head.js
new file mode 100644
index 0000000000..cf8e45ff74
--- /dev/null
+++ b/devtools/client/debugger/test/mochitest/shared-head.js
@@ -0,0 +1,2719 @@
+/* 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,
+ isOriginalId,
+ originalToGeneratedId,
+} = require("devtools/client/shared/source-map-loader/index");
+
+/**
+ * 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") {
+ if (!location.source.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;
+ }
+ return getBreakableLines(location.source.id);
+ },
+ "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 source = dbg.selectors.getSelectedSource();
+
+ // WASM support is limited when we are on the generated binary source
+ if (isWasmBinarySource(source)) {
+ 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 { sourceId, line, column } = isGeneratedId(expectedSourceId)
+ ? frames[0].generatedLocation
+ : frames[0].location;
+ is(sourceId, 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 sourceId = frame.location.sourceId;
+ const source = dbg.selectors.getSelectedSource();
+ const sourceTextContent = dbg.selectors.getSelectedSourceTextContent();
+
+ if (!source || !sourceTextContent) {
+ return false;
+ }
+
+ return source.id == sourceId;
+}
+
+/**
+ * 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"
+ );
+}
+
+function getContext(dbg) {
+ return dbg.selectors.getContext();
+}
+
+function getThreadContext(dbg) {
+ return dbg.selectors.getThreadContext();
+}
+
+/*
+ * 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(
+ getContext(dbg),
+ createLocation({ source, line, column }),
+ { keepContext: false }
+ );
+ return waitForSelectedSource(dbg, source);
+}
+
+async function closeTab(dbg, url) {
+ await dbg.actions.closeTab(getContext(dbg), 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(getThreadContext(dbg));
+ 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(getThreadContext(dbg));
+ 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(getThreadContext(dbg));
+ 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?.sourceId == location2?.sourceId &&
+ location1?.line == location2?.line &&
+ location1?.column == location2?.column
+ );
+}
+
+function getBreakpointForLocation(dbg, location) {
+ if (!location) {
+ return undefined;
+ }
+
+ const isGeneratedSource = isGeneratedId(location.sourceId);
+ 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(
+ getContext(dbg),
+ 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,
+ sourceUrl: source.url,
+ line,
+ column,
+ });
+ const bp = getBreakpointForLocation(dbg, location);
+ return dbg.actions.disableBreakpoint(getContext(dbg), bp);
+}
+
+function findBreakpoint(dbg, url, line) {
+ const source = findSource(dbg, url);
+ return dbg.selectors.getBreakpointsForSource(source.id, line)[0];
+}
+
+// helper for finding column breakpoints.
+function findColumnBreakpoint(dbg, url, line, column) {
+ const source = findSource(dbg, url);
+ const lineBreakpoints = dbg.selectors.getBreakpointsForSource(
+ source.id,
+ 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(getContext(dbg), 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(getScopeLabel(dbg, i + 1), val[0]);
+ is(
+ getScopeValue(dbg, i + 1),
+ val[1],
+ `"${val[0]}" has the expected "${val[1]}" value`
+ );
+ } else {
+ is(getScopeLabel(dbg, i + 1), val);
+ }
+ }
+
+ is(getScopeLabel(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,
+ sourceUrl: source.url,
+ line,
+ column,
+ });
+ const bp = getBreakpointForLocation(dbg, location);
+ return dbg.actions.removeBreakpoint(getContext(dbg), 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",
+ tooltip: ".tooltip",
+ 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 getScopeLabel(dbg, index) {
+ return findElement(dbg, "scopeNode", index).innerText;
+}
+
+function getScopeValue(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, ch }) {
+ info(`Get token at ${line}, ${ch}`);
+ const cm = getCM(dbg);
+ cm.scrollIntoView({ line: line - 1, ch }, 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 waitForScrolling(cm);
+
+ const coords = getCoordsFromPosition(cm, { line: line - 1, ch });
+
+ const { left, top } = coords;
+
+ // Adds a vertical offset due to increased line height
+ // https://github.com/firefox-devtools/debugger/pull/7934
+ const lineHeightOffset = 3;
+
+ 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;
+ }
+
+ info(`Hovering on token ${tokenEl.innerText}`);
+ tokenEl.dispatchEvent(
+ new MouseEvent("mouseover", {
+ bubbles: true,
+ cancelable: true,
+ view: dbg.win,
+ })
+ );
+
+ InspectorUtils.addPseudoClassLock(tokenEl, ":hover");
+}
+
+async function closePreviewAtPos(dbg, line, column) {
+ const pos = { line, ch: column - 1 };
+ const tokenEl = await getTokenFromPosition(dbg, pos);
+
+ if (!tokenEl) {
+ return;
+ }
+
+ InspectorUtils.removePseudoClassLock(tokenEl, ":hover");
+
+ const gutterEl = await getEditorLineGutter(dbg, line);
+
+ // The popup gets hidden when "mouseleave" is emitted on the tokenEl.
+ // EventUtils can't send "mouseleave" event, and since the mouse could have been moved
+ // since the tooltip was displayed, move it back to the token and then to the gutter,
+ // which should trigger a mouseleave event.
+ EventUtils.synthesizeMouseAtCenter(tokenEl, { type: "mousemove" }, dbg.win);
+ EventUtils.synthesizeMouseAtCenter(gutterEl, { type: "mousemove" }, dbg.win);
+ await waitUntil(() => findElement(dbg, "previewPopup") == null);
+}
+
+// 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.
+function tryHovering(dbg, line, column, elementName) {
+ return new Promise((resolve, reject) => {
+ const element = waitForElement(dbg, elementName);
+ let count = 0;
+
+ element.then(() => {
+ clearInterval(interval);
+ resolve(element);
+ });
+
+ const interval = setInterval(() => {
+ if (count++ == 5) {
+ clearInterval(interval);
+ reject("failed to preview");
+ }
+
+ hoverAtPos(dbg, { line, ch: column - 1 });
+ }, 1000);
+ });
+}
+
+async function assertPreviewTextValue(dbg, line, column, { text, expression }) {
+ const previewEl = await tryHovering(dbg, line, column, "previewPopup");
+
+ ok(previewEl.innerText.includes(text), "Preview text shown to user");
+
+ const preview = dbg.selectors.getPreview();
+ is(preview.expression, expression, "Preview.expression");
+}
+
+async function assertPreviewTooltip(dbg, line, column, { result, expression }) {
+ const previewEl = await tryHovering(dbg, line, column, "tooltip");
+
+ is(previewEl.innerText, result, "Preview text shown to user");
+
+ const preview = dbg.selectors.getPreview();
+ is(`${preview.resultGrip}`, result, "Preview.result");
+ is(preview.expression, expression, "Preview.expression");
+}
+
+async function assertPreviews(dbg, previews) {
+ for (const { line, column, expression, result, fields } of previews) {
+ if (fields && result) {
+ throw new Error("Invalid test fixture");
+ }
+
+ if (fields) {
+ const popupEl = await tryHovering(dbg, line, column, "popup");
+ const oiNodes = Array.from(
+ popupEl.querySelectorAll(".preview-popup .node")
+ );
+
+ 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(".objectBox").textContent,
+ value,
+ `The "${field}" property has the expected value`
+ );
+ }
+ }
+ } else {
+ await assertPreviewTextValue(dbg, line, column, {
+ expression,
+ text: result,
+ });
+ }
+
+ const { target } = dbg.selectors.getPreview(getContext(dbg));
+ InspectorUtils.removePseudoClassLock(target, ":hover");
+ dbg.actions.clearPreview(getContext(dbg));
+ }
+}
+
+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"
+ );
+
+ // 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.
+ PromiseTestUtils.allowMatchingRejectionsGlobally(/Page has navigated/);
+ PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Current thread has changed/
+ );
+ PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Current thread has paused or resumed/
+ );
+ PromiseTestUtils.allowMatchingRejectionsGlobally(/Connection closed/);
+ this.PromiseTestUtils = PromiseTestUtils;
+}
+
+/**
+ * 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;
+}