/* 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/>. */
// This file is loaded in a `spawn` context sometimes which doesn't have,
// `Assert`, so we can't use its comparison functions.
/* eslint-disable mozilla/no-comparison-or-assignment-inside-ok */
/**
* 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 {
getUnicodeUrlPath,
} = require(
"resource://devtools/client/shared/unicode-url.js");
const {
isGeneratedId,
} = require(
"devtools/client/shared/source-map-loader/index");
const DEBUGGER_L10N =
new LocalizationHelper(
"devtools/client/locales/debugger.properties"
);
const isCm6Enabled = Services.prefs.getBoolPref(
"devtools.debugger.features.codemirror-next"
);
/**
* Waits for `predicate()` to be true. `state` is the redux app state.
*
* @param {Object} dbg
* @param {Function} predicate
* @param {String} msg
* @return {Promise}
*/
function waitForState(dbg, predicate, msg =
"") {
return new Promise(resolve => {
info(`Waiting
for state change: ${msg}`);
let result = predicate(dbg.store.getState());
if (result) {
info(
`--> The state was immediately correct (should rather
do an immediate assertion?)`
);
resolve(result);
return;
}
const unsubscribe = dbg.store.subscribe(() => {
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,
() => 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, () => {
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,
getSelectedFrame,
getCurrentThread,
} = dbg.selectors;
return waitForState(
dbg,
() => {
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;
}
}
// Only when we are paused on that specific source (and this isn't a WASM source, which has no AST):
// wait for symbols/AST to be parsed
if (
getSelectedFrame(getCurrentThread())?.location.source.id ==
location.source.id &&
!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 getEditorContent(dbg) !== 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.
*/
async
function assertLineIsBreakable(dbg, file, line, shouldBeBreakable) {
const el = await getNodeAtEditorGutterLine(dbg, line);
const lineText = `${line}| ${el.innerText.substring(0, 50)}${
el.innerText.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(!el.classList.contains(
"empty-line"), `${lineText} should be breakable`);
}
else {
ok(
el.classList.contains(
"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 lineInfo = getCMEditor(dbg).lineInfo(isCm6Enabled ? line : 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.
*/
async
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 = wasmOffsetToLine(dbg, source.id, line);
}
// Check the debug line
// cm6 lines are 1-based, while cm5 are 0-based, to keep compatibility with
// .lineInfo usage in other locations.
const lineInfo = getCMEditor(dbg).lineInfo(isCm6Enabled ? line : 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.
await scrollEditorIntoView(dbg, line, 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]
*/
async
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) {
// `pauseColumn` is 0-based, coming from internal state,
// while `expectedColumn` is manually passed from test scripts and so is 1-based.
is(
pauseColumn + 1,
expectedColumn,
"Redux state for currently selected frame's column is correct"
);
}
await _assertDebugLine(dbg, pauseLine, pauseColumn);
ok(isVisibleInEditor(dbg, findElement(dbg,
"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) {
// `column` is 0-based, coming from internal state,
// while `expectedColumn` is manually passed from test scripts and so is 1-based.
is(
column + 1,
expectedColumn,
`Frame paused at column ${
column + 1
}, 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, () => 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.
* @param {Object} options
* {Boolean} shouldWaitForLoadScopes
* When paused in original files with original variable mapping disabled, scopes are
* not going to exist, lets not wait for it. defaults to true
*/
async
function waitForPaused(
dbg,
url,
options = { shouldWaitForLoadedScopes:
true }
) {
info(
"Waiting for the debugger to pause");
const { getSelectedScope, getCurrentThread, getCurrentThreadFrames } =
dbg.selectors;
await waitForState(
dbg,
() => isPaused(dbg) && !!getSelectedScope(getCurrentThread()),
"paused"
);
await waitForState(dbg, getCurrentThreadFrames,
"fetched frames");
if (options.shouldWaitForLoadedScopes) {
await waitForLoadedScopes(dbg);
}
// Note that this will wait for symbols, which are fetched on pause
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, () => !dbg.selectors.getIsCurrentThreadPaused());
}
function waitForInlinePreviews(dbg) {
return waitForState(dbg, () => dbg.selectors.getInlinePreviews());
}
function waitForCondition(dbg, condition) {
return waitForState(dbg, () =>
dbg.selectors
.getBreakpointsList()
.find(bp => bp.options.condition == condition)
);
}
function waitForLog(dbg, logValue) {
return waitForState(dbg, () =>
dbg.selectors
.getBreakpointsList()
.find(bp => bp.options.logValue == logValue)
);
}
async
function waitForPausedThread(dbg,
thread) {
return waitForState(dbg, () => dbg.selectors.getIsPaused(
thread));
}
function isSelectedFrameSelected(dbg) {
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 displayed title is correct.
*
* @param {Object} dbg
* @param {DOM Node} frameElement
* @param {String} expectedTitle
*/
function assertFrameIsSelected(dbg, frameElement, expectedTitle) {
const selectedFrame = dbg.selectors.getSelectedFrame();
ok(frameElement.classList.contains(
"selected"),
"The frame is selected");
is(
frameElement.querySelector(
".title").innerText,
expectedTitle,
"The selected frame element has the expected title"
);
// For `<anonymous>` frames, there is likely no displayName
is(
selectedFrame.displayName,
expectedTitle ==
"" ? undefined : expectedTitle,
"The selected frame has the correct display title"
);
}
/**
* Checks to see if the frame is not selected.
*
* @param {Object} dbg
* @param {DOM Node} frameElement
* @param {String} expectedTitle
*/
function assertFrameIsNotSelected(dbg, frameElement, expectedTitle) {
const selectedFrame = dbg.selectors.getSelectedFrame();
ok(!frameElement.classList.contains(
"selected"),
"The frame is selected");
is(
frameElement.querySelector(
".title").innerText,
expectedTitle,
"The selected frame element has the expected title"
);
}
/**
* 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");
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
? getUnicodeUrlPath(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,
() => {
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(
() => getEditorContent(dbg) !== `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
* @param {Object} pauseOptions
* @return {Promise}
* @static
*/
async
function stepOver(dbg, pauseOptions) {
const pauseLine = getVisibleSelectedFrameLine(dbg);
info(`Stepping over from ${pauseLine}`);
await dbg.actions.stepOver();
return waitForPaused(dbg,
null, pauseOptions);
}
/**
* 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(
// column is 0-based internally, but tests are using 1-based.
createLocation({ source, line, column: column - 1 }),
options
);
await onBreakpoint;
is(
dbg.selectors.getBreakpointCount(),
bpCount + 1,
"a new breakpoint was created"
);
}
// use shortcut to open conditional panel.
function setConditionalBreakpointWithKeyboardShortcut(dbg, condition) {
pressKey(dbg,
"toggleCondPanel");
return typeInPanel(dbg, condition);
}
/**
* 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");
}
async
function removeBreakpointViaGutter(dbg, line) {
const onRemoved = waitForDispatch(dbg.store,
"REMOVE_BREAKPOINT");
await clickGutter(dbg, line);
await onRemoved;
}
function disableBreakpoint(dbg, source, line, column) {
if (column === 0) {
throw new Error(
"disableBreakpoint expect a 1-based column argument");
}
// `internalColumn` is 0-based internally, while `column` manually defined in test scripts is 1-based.
const internalColumn = column
? column - 1
: getFirstBreakpointColumn(dbg, source, line);
const location = createLocation({
source,
line,
column: internalColumn,
});
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");
// column is 0-based internally, but tests are using 1-based.
if (!getBreakpoint(createLocation({ source, line, column: column - 1 }))) {
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 + 1}`
);
}
return source;
}
async
function invokeWithBreakpoint(
dbg,
fnName,
filename,
{ line, column },
handler,
pauseOptions
) {
const source = await loadAndAddBreakpoint(dbg, filename, line, column);
const invokeResult = invokeInTab(fnName);
const invokeFailed = await Promise.race([
waitForPaused(dbg,
null, pauseOptions),
invokeResult.then(
() =>
new Promise(() => {}),
() =>
true
),
]);
if (invokeFailed) {
await invokeResult;
return;
}
await 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 source = dbg.selectors.getSelectedSource();
return dbg.actions.prettyPrintAndSelectSource(source);
}
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 findSourceTreeGroupByName(dbg, name) {
return [...findAllElements(dbg,
"sourceTreeGroups")].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 is 0-based internally, but tests are using 1-based.
column = column ? column - 1 : 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 },
toggleLogPanel: { code:
"y", modifiers: cmdShift },
toggleBreakpoint: { code:
"b", modifiers: cmdOrCtrl },
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 },
},
Space: { code:
"VK_SPACE" },
Up: { code:
"VK_UP" },
Down: { code:
"VK_DOWN" },
Right: { code:
"VK_RIGHT" },
Left: { code:
"VK_LEFT" },
End: endKey,
Start: startKey,
Tab: { code:
"VK_TAB" },
ShiftTab: { code:
"VK_TAB", modifiers: { shiftKey:
true } },
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;
}
/**
* Get the element in the editor gutter at the specified line
* @param {Object} dbg
* @param {Number} line
* @returns
*/
async
function getEditorLineGutter(dbg, line) {
let el = await codeMirrorGutterElement(dbg, line);
while (el && !el.matches(
".CodeMirror-code > div")) {
el = el.parentElement;
}
return el;
}
// Handles virtualization scenarios
async
function scrollAndGetEditorLineGutterElement(dbg, line) {
const editor = getCMEditor(dbg);
await scrollEditorIntoView(dbg, line, 0);
const selectedSource = dbg.selectors.getSelectedSource();
// For WASM sources get the hexadecimal line number displayed in the gutter
if (editor.isWasm && !selectedSource.isOriginal) {
const wasmLineFormatter = editor.getWasmLineNumberFormatter();
line = wasmLineFormatter(line);
}
const els = findAllElementsWithSelector(
dbg,
isCm6Enabled
?
".cm-gutter.cm-lineNumbers .cm-gutterElement"
:
".CodeMirror-code .CodeMirror-linenumber"
);
return [...els].find(el => el.innerText == line);
}
/**
* Gets node at a specific line in the editor
* @param {*} dbg
* @param {Number} line
* @returns {Element} DOM Element
*/
async
function getNodeAtEditorLine(dbg, line) {
if (isCm6Enabled) {
// To handle virtualized lines accurately, lets use the
// cm6 utility here.
await scrollEditorIntoView(dbg, line, 0);
return getCMEditor(dbg).getElementAtLine(line);
}
return getEditorLineGutter(dbg, line);
}
/**
* Gets node at a specific line in the gutter
* @param {*} dbg
* @param {Number} line
* @returns {Element} DOM Element
*/
async
function getNodeAtEditorGutterLine(dbg, line) {
if (isCm6Enabled) {
return scrollAndGetEditorLineGutterElement(dbg, line);
}
// Note: In CM5 both the line gutter elements and the
// line content elements are within the editor line.
return getEditorLineGutter(dbg, line);
}
async
function getConditionalPanelAtLine(dbg, line) {
info(`Get conditional panel at line ${line}`);
let el = await getNodeAtEditorLine(dbg, line);
if (isCm6Enabled) {
// In CM6 the conditional panel for a specific line
// is injected in a sibling node just after.
el = el.nextSibling;
}
return el.querySelector(
".conditional-breakpoint-panel");
}
async
function waitForConditionalPanelFocus(dbg) {
if (isCm6Enabled) {
return waitFor(
() =>
dbg.win.document.activeElement.classList.contains(
"cm-content") &&
dbg.win.document.activeElement.closest(
".conditional-breakpoint-panel")
);
}
return waitFor(() => dbg.win.document.activeElement.tagName ===
"TEXTAREA");
}
/**
* 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
* @param {String} elementName
* @returns
*/
async
function selectEditorLinesAndOpenContextMenu(
dbg,
lines,
elementName =
"line"
) {
const { startLine, endLine } = lines;
setSelection(dbg, startLine, endLine ?? startLine);
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
*/
async
function assertIgnoredStyleInSourceLines(
dbg,
{ lines, hasBlackboxedLinesClass }
) {
if (lines) {
let currentLine = lines[0];
do {
const element = await getNodeAtEditorLine(dbg, currentLine);
const hasStyle = element.classList.contains(
"blackboxed-line");
is(
hasStyle,
hasBlackboxedLinesClass,
`Line ${currentLine} ${
hasBlackboxedLinesClass ?
"does not have" :
"has"
} ignored styling`
);
currentLine = currentLine + 1;
}
while (currentLine <= lines[1]);
}
else {
const codeLines = findAllElements(dbg,
"codeLines");
const blackboxedLines = findAllElements(dbg,
"blackboxedLines");
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 = getCMEditor(dbg).lineInfo(isCm6Enabled ? line : 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 getNodeAtEditorGutterLine(dbg, line);
const exists = el.classList.contains(
isCm6Enabled ?
"cm6-gutter-breakpoint" :
"new-breakpioint"
);
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) {
let el = await getNodeAtEditorGutterLine(dbg, line);
el = isCm6Enabled ? el.firstChild : el;
ok(
el.classList.contains(selectors.gutterBreakpoint),
`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) {
let el = await getNodeAtEditorGutterLine(dbg, line);
el = isCm6Enabled ? el.firstChild : el;
ok(
el.classList.contains(selectors.gutterBreakpoint),
`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) {
let el = await getNodeAtEditorGutterLine(dbg, line);
el = isCm6Enabled ? el.firstChild : el;
ok(
el.classList.contains(selectors.gutterBreakpoint),
`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, expectedSnippet) {
const actualSnippet = findElement(dbg,
"breakpointLabel", 2).innerText;
is(actualSnippet, expectedSnippet, `Breakpoint ${index} snippet`);
}
const selectors = {
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 + *`,
expressionInput:
".watch-expressions-pane input.input-expression",
expressionNodes:
".expressions-list .tree-node",
expressionPlus:
".watch-expressions-pane button.plus",
expressionRefresh:
".watch-expressions-pane button.refresh",
expressionsHeader:
".watch-expressions-pane ._header .header-label",
scopesHeader:
".scopes-pane ._header .header-label",
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",
},
blackboxedLines: isCm6Enabled
?
".cm-content > .blackboxed-line"
:
".CodeMirror-code .blackboxed-line",
codeLines: isCm6Enabled
?
".cm-content > .cm-line"
:
".CodeMirror-code .CodeMirror-line",
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",
asyncframe: i =>
`.frames div[role=listbox] .location-async-cause:nth-child(${i})`,
frame: i => `.frames div[role=listbox] .frame:nth-child(${i})`,
frames:
".frames [role='listbox'] .frame",
gutterBreakpoint: isCm6Enabled ?
"breakpoint-marker" :
"new-breakpoint",
// This is used to trigger events (click etc) on the gutter
gutterElement: i =>
isCm6Enabled
? `.cm-gutter.cm-lineNumbers .cm-gutterElement:nth-child(${i + 1})`
: `.CodeMirror-code *:nth-child(${i}) .CodeMirror-linenumber`,
gutters: isCm6Enabled ? `.cm-gutters` : `.CodeMirror-gutters`,
line: i =>
isCm6Enabled
? `.cm-content > div.cm-line:nth-child(${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",
breakpoint: isCm6Enabled
?
".cm-gutter > .cm6-gutter-breakpoint"
:
".CodeMirror-code > .new-breakpoint",
highlightLine: isCm6Enabled
?
".cm-content > .highlight-line"
:
".CodeMirror-code > .highlight-line",
debugLine:
".new-debug-line",
debugErrorLine:
".new-debug-line-error",
codeMirror: isCm6Enabled ?
".cm-editor" :
".CodeMirror",
resume:
".resume.active",
pause:
".pause.active",
sourceTabs:
".source-tabs",
activeTab:
".source-tab.active",
stepOver:
".stepOver.active",
stepOut:
".stepOut.active",
stepIn:
".stepIn.active",
prettyPrintButton:
".source-footer .prettyPrint",
mappedSourceLink:
".source-footer .mapped-source",
sourceMapFooterButton:
".debugger-source-map-button",
sourceNode: i => `.sources-list .tree-node:nth-child(${i}) .node`,
sourceNodes:
".sources-list .tree-node",
sourceTreeThreads:
'.sources-list .tree-node[aria-level="1"]',
sourceTreeGroups:
'.sources-list .tree-node[aria-level="2"]',
sourceTreeFiles:
".sources-list .tree-node[data-expandable=false]",
threadSourceTree: i => `.threads-list .sources-pane:nth-child(${i})`,
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 ${
isCm6Enabled ?
".cm-content" :
"textarea"
}`,
logPanelInput: `.conditional-breakpoint-panel.log-point ${
isCm6Enabled ?
".cm-content" :
"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",
projectSearchRefreshButton:
".project-text-search button.refresh-btn",
threadsPaneItems:
".threads-pane .thread",
threadsPaneItem: i => `.threads-pane .
thread:nth-child(${i})`,
threadsPaneItemPause: i => `${selectors.threadsPaneItem(i)}.paused`,
CodeMirrorLines: isCm6Enabled ?
".cm-content" :
".CodeMirror-lines",
CodeMirrorCode: isCm6Enabled ?
".cm-content" :
".CodeMirror-code",
inlinePreview: isCm6Enabled
?
".cm-content .inline-preview"
:
".CodeMirror-code .CodeMirror-widget",
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",
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",
watchExpressionsHeader:
".watch-expressions-pane ._header .header-label",
watchExpressionsAddButton:
".watch-expressions-pane ._header .plus",
editorNotificationFooter:
".editor-notification-footer",
};
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);
return rightClickEl(dbg, dbg.win.document.querySelector(selector));
}
function rightClickEl(dbg, el) {
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 (isCm6Enabled
? scrollAndGetEditorLineGutterElement(dbg, line)
: codeMirrorGutterElement(dbg, line));
clickDOMElement(dbg, el);
}
async
function cmdClickGutter(dbg, line) {
const el = await (isCm6Enabled
? scrollAndGetEditorLineGutterElement(dbg, line)
: 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,
--> --------------------
--> maximum size reached
--> --------------------