summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/actions/breakpoints
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/debugger/src/actions/breakpoints')
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js249
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/index.js421
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/modify.js395
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/moz.build14
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/remapLocations.js29
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js194
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap131
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/tests/breakpointPositions.spec.js112
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js487
9 files changed, 2032 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js b/devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js
new file mode 100644
index 0000000000..e28c325f01
--- /dev/null
+++ b/devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js
@@ -0,0 +1,249 @@
+/* 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/>. */
+
+// @flow
+
+import {
+ isOriginalId,
+ isGeneratedId,
+ originalToGeneratedId,
+} from "devtools-source-map";
+import { uniqBy, zip } from "lodash";
+
+import {
+ getSource,
+ getSourceFromId,
+ getBreakpointPositionsForSource,
+ getSourceActorsForSource,
+} from "../../selectors";
+
+import type {
+ MappedLocation,
+ Range,
+ Source,
+ SourceLocation,
+ SourceId,
+ BreakpointPositions,
+ Context,
+} from "../../types";
+
+import { makeBreakpointId } from "../../utils/breakpoint";
+import {
+ memoizeableAction,
+ type MemoizedAction,
+} from "../../utils/memoizableAction";
+import { fulfilled } from "../../utils/async-value";
+import type { ThunkArgs } from "../../actions/types";
+import { loadSourceActorBreakpointColumns } from "../source-actors";
+
+type LocationsList = {
+ number: ?(number[]),
+};
+
+async function mapLocations(
+ generatedLocations: SourceLocation[],
+ { sourceMaps }: ThunkArgs
+): Promise<MappedLocation[]> {
+ if (generatedLocations.length == 0) {
+ return [];
+ }
+
+ const originalLocations = await sourceMaps.getOriginalLocations(
+ generatedLocations
+ );
+
+ return zip(
+ originalLocations,
+ generatedLocations
+ ).map(([location, generatedLocation]) => ({ location, generatedLocation }));
+}
+
+// Filter out positions, that are not in the original source Id
+function filterBySource(
+ positions: MappedLocation[],
+ sourceId: SourceId
+): MappedLocation[] {
+ if (!isOriginalId(sourceId)) {
+ return positions;
+ }
+ return positions.filter(position => position.location.sourceId == sourceId);
+}
+
+function filterByUniqLocation(positions: MappedLocation[]): MappedLocation[] {
+ return uniqBy(positions, ({ location }) => makeBreakpointId(location));
+}
+
+function convertToList(
+ results: LocationsList,
+ source: Source
+): SourceLocation[] {
+ const { id, url } = source;
+ const positions = [];
+
+ for (const line in results) {
+ for (const column of results[line]) {
+ positions.push({
+ line: Number(line),
+ column,
+ sourceId: id,
+ sourceUrl: url,
+ });
+ }
+ }
+
+ return positions;
+}
+
+function groupByLine(results: MappedLocation[], sourceId: SourceId, line) {
+ const isOriginal = isOriginalId(sourceId);
+ const positions = {};
+
+ // Ensure that we have an entry for the line fetched
+ if (typeof line === "number") {
+ positions[line] = [];
+ }
+
+ for (const result of results) {
+ const location = isOriginal ? result.location : result.generatedLocation;
+
+ if (!positions[location.line]) {
+ positions[location.line] = [];
+ }
+
+ positions[location.line].push(result);
+ }
+
+ return positions;
+}
+
+async function _setBreakpointPositions(
+ cx: Context,
+ sourceId: SourceId,
+ line,
+ thunkArgs: ThunkArgs
+): Promise<void> {
+ const { client, dispatch, getState, sourceMaps } = thunkArgs;
+ let generatedSource = getSource(getState(), sourceId);
+ if (!generatedSource) {
+ return;
+ }
+
+ const results = {};
+ if (isOriginalId(sourceId)) {
+ // Explicitly typing ranges is required to work around the following issue
+ // https://github.com/facebook/flow/issues/5294
+ const ranges: Range[] = await sourceMaps.getGeneratedRangesForOriginal(
+ sourceId,
+ true
+ );
+ const generatedSourceId = originalToGeneratedId(sourceId);
+ generatedSource = getSourceFromId(getState(), generatedSourceId);
+
+ // Note: While looping here may not look ideal, in the vast majority of
+ // cases, the number of ranges here should be very small, and is quite
+ // likely to only be a single range.
+ for (const range of ranges) {
+ // Wrap infinite end positions to the next line to keep things simple
+ // and because we know we don't care about the end-line whitespace
+ // in this case.
+ if (range.end.column === Infinity) {
+ range.end = {
+ line: range.end.line + 1,
+ column: 0,
+ };
+ }
+
+ const actorBps = await Promise.all(
+ getSourceActorsForSource(getState(), generatedSource.id).map(actor =>
+ client.getSourceActorBreakpointPositions(actor, range)
+ )
+ );
+
+ for (const actorPositions of actorBps) {
+ for (const rangeLine of Object.keys(actorPositions)) {
+ let columns = actorPositions[parseInt(rangeLine, 10)];
+ const existing = results[rangeLine];
+ if (existing) {
+ columns = [...new Set([...existing, ...columns])];
+ }
+
+ results[rangeLine] = columns;
+ }
+ }
+ }
+ } else {
+ if (typeof line !== "number") {
+ throw new Error("Line is required for generated sources");
+ }
+
+ const actorColumns = await Promise.all(
+ getSourceActorsForSource(getState(), generatedSource.id).map(actor =>
+ dispatch(loadSourceActorBreakpointColumns({ id: actor.id, line, cx }))
+ )
+ );
+
+ for (const columns of actorColumns) {
+ results[line] = (results[line] || []).concat(columns);
+ }
+ }
+
+ let positions = convertToList(results, generatedSource);
+ positions = await mapLocations(positions, thunkArgs);
+
+ positions = filterBySource(positions, sourceId);
+ positions = filterByUniqLocation(positions);
+ positions = groupByLine(positions, sourceId, line);
+
+ const source = getSource(getState(), sourceId);
+ // NOTE: it's possible that the source was removed during a navigate
+ if (!source) {
+ return;
+ }
+
+ dispatch({
+ type: "ADD_BREAKPOINT_POSITIONS",
+ cx,
+ source,
+ positions,
+ });
+}
+
+function generatedSourceActorKey(state, sourceId: SourceId): string {
+ const generatedSource = getSource(
+ state,
+ isOriginalId(sourceId) ? originalToGeneratedId(sourceId) : sourceId
+ );
+ const actors = generatedSource
+ ? getSourceActorsForSource(state, generatedSource.id).map(
+ ({ actor }) => actor
+ )
+ : [];
+ return [sourceId, ...actors].join(":");
+}
+
+export const setBreakpointPositions: MemoizedAction<
+ {| cx: Context, sourceId: SourceId, line?: number |},
+ ?BreakpointPositions
+> = memoizeableAction("setBreakpointPositions", {
+ getValue: ({ sourceId, line }, { getState }) => {
+ const positions = getBreakpointPositionsForSource(getState(), sourceId);
+ if (!positions) {
+ return null;
+ }
+
+ if (isGeneratedId(sourceId) && line && !positions[line]) {
+ // We always return the full position dataset, but if a given line is
+ // not available, we treat the whole set as loading.
+ return null;
+ }
+
+ return fulfilled(positions);
+ },
+ createKey({ sourceId, line }, { getState }) {
+ const key = generatedSourceActorKey(getState(), sourceId);
+ return isGeneratedId(sourceId) && line ? `${key}-${line}` : key;
+ },
+ action: async ({ cx, sourceId, line }, thunkArgs) =>
+ _setBreakpointPositions(cx, sourceId, line, thunkArgs),
+});
diff --git a/devtools/client/debugger/src/actions/breakpoints/index.js b/devtools/client/debugger/src/actions/breakpoints/index.js
new file mode 100644
index 0000000000..7d8e2ca737
--- /dev/null
+++ b/devtools/client/debugger/src/actions/breakpoints/index.js
@@ -0,0 +1,421 @@
+/* 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/>. */
+
+// @flow
+
+/**
+ * Redux actions for breakpoints
+ * @module actions/breakpoints
+ */
+
+import { PROMISE } from "../utils/middleware/promise";
+import {
+ getBreakpointsList,
+ getXHRBreakpoints,
+ getSelectedSource,
+ getBreakpointAtLocation,
+ getBreakpointsForSource,
+ getBreakpointsAtLine,
+} from "../../selectors";
+import { createXHRBreakpoint } from "../../utils/breakpoint";
+import {
+ addBreakpoint,
+ removeBreakpoint,
+ enableBreakpoint,
+ disableBreakpoint,
+} from "./modify";
+import remapLocations from "./remapLocations";
+
+// this will need to be changed so that addCLientBreakpoint is removed
+
+import type { ThunkArgs } from "../types";
+import type {
+ Breakpoint,
+ Source,
+ SourceLocation,
+ SourceId,
+ XHRBreakpoint,
+ Context,
+} from "../../types";
+
+export * from "./breakpointPositions";
+export * from "./modify";
+export * from "./syncBreakpoint";
+
+export function addHiddenBreakpoint(cx: Context, location: SourceLocation) {
+ return ({ dispatch }: ThunkArgs) => {
+ return dispatch(addBreakpoint(cx, location, { hidden: true }));
+ };
+}
+
+/**
+ * Disable all breakpoints in a source
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function disableBreakpointsInSource(cx: Context, source: Source) {
+ return async ({ dispatch, getState, client }: ThunkArgs) => {
+ const breakpoints = getBreakpointsForSource(getState(), source.id);
+ for (const breakpoint of breakpoints) {
+ if (!breakpoint.disabled) {
+ dispatch(disableBreakpoint(cx, breakpoint));
+ }
+ }
+ };
+}
+
+/**
+ * Enable all breakpoints in a source
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function enableBreakpointsInSource(cx: Context, source: Source) {
+ return async ({ dispatch, getState, client }: ThunkArgs) => {
+ const breakpoints = getBreakpointsForSource(getState(), source.id);
+ for (const breakpoint of breakpoints) {
+ if (breakpoint.disabled) {
+ dispatch(enableBreakpoint(cx, breakpoint));
+ }
+ }
+ };
+}
+
+/**
+ * Toggle All Breakpoints
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function toggleAllBreakpoints(
+ cx: Context,
+ shouldDisableBreakpoints: boolean
+) {
+ return async ({ dispatch, getState, client }: ThunkArgs) => {
+ const breakpoints = getBreakpointsList(getState());
+
+ for (const breakpoint of breakpoints) {
+ if (shouldDisableBreakpoints) {
+ dispatch(disableBreakpoint(cx, breakpoint));
+ } else {
+ dispatch(enableBreakpoint(cx, breakpoint));
+ }
+ }
+ };
+}
+
+/**
+ * Toggle Breakpoints
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function toggleBreakpoints(
+ cx: Context,
+ shouldDisableBreakpoints: boolean,
+ breakpoints: Breakpoint[]
+) {
+ return async ({ dispatch }: ThunkArgs) => {
+ const promises = breakpoints.map(breakpoint =>
+ shouldDisableBreakpoints
+ ? dispatch(disableBreakpoint(cx, breakpoint))
+ : dispatch(enableBreakpoint(cx, breakpoint))
+ );
+
+ await Promise.all(promises);
+ };
+}
+
+export function toggleBreakpointsAtLine(
+ cx: Context,
+ shouldDisableBreakpoints: boolean,
+ line: number
+) {
+ return async ({ dispatch, getState }: ThunkArgs) => {
+ const breakpoints = getBreakpointsAtLine(getState(), line);
+ return dispatch(
+ toggleBreakpoints(cx, shouldDisableBreakpoints, breakpoints)
+ );
+ };
+}
+
+/**
+ * Removes all breakpoints
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function removeAllBreakpoints(cx: Context) {
+ return async ({ dispatch, getState }: ThunkArgs) => {
+ const breakpointList = getBreakpointsList(getState());
+ await Promise.all(
+ breakpointList.map(bp => dispatch(removeBreakpoint(cx, bp)))
+ );
+ dispatch({ type: "REMOVE_BREAKPOINTS" });
+ };
+}
+
+/**
+ * Removes breakpoints
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function removeBreakpoints(cx: Context, breakpoints: Breakpoint[]) {
+ return async ({ dispatch }: ThunkArgs) => {
+ return Promise.all(
+ breakpoints.map(bp => dispatch(removeBreakpoint(cx, bp)))
+ );
+ };
+}
+
+/**
+ * Removes all breakpoints in a source
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function removeBreakpointsInSource(cx: Context, source: Source) {
+ return async ({ dispatch, getState, client }: ThunkArgs) => {
+ const breakpoints = getBreakpointsForSource(getState(), source.id);
+ for (const breakpoint of breakpoints) {
+ dispatch(removeBreakpoint(cx, breakpoint));
+ }
+ };
+}
+
+export function remapBreakpoints(cx: Context, sourceId: SourceId) {
+ return async ({ dispatch, getState, sourceMaps }: ThunkArgs) => {
+ const breakpoints = getBreakpointsForSource(getState(), sourceId);
+ const newBreakpoints = await remapLocations(
+ breakpoints,
+ sourceId,
+ sourceMaps
+ );
+
+ // Normally old breakpoints will be clobbered if we re-add them, but when
+ // remapping we have changed the source maps and the old breakpoints will
+ // have different locations than the new ones. Manually remove the
+ // old breakpoints before adding the new ones.
+ for (const bp of breakpoints) {
+ dispatch(removeBreakpoint(cx, bp));
+ }
+
+ for (const bp of newBreakpoints) {
+ await dispatch(addBreakpoint(cx, bp.location, bp.options, bp.disabled));
+ }
+ };
+}
+
+export function toggleBreakpointAtLine(cx: Context, line: number) {
+ return ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => {
+ const state = getState();
+ const selectedSource = getSelectedSource(state);
+
+ if (!selectedSource) {
+ return;
+ }
+
+ const bp = getBreakpointAtLocation(state, { line, column: undefined });
+ if (bp) {
+ return dispatch(removeBreakpoint(cx, bp));
+ }
+ return dispatch(
+ addBreakpoint(cx, {
+ sourceId: selectedSource.id,
+ sourceUrl: selectedSource.url,
+ line,
+ })
+ );
+ };
+}
+
+export function addBreakpointAtLine(
+ cx: Context,
+ line: number,
+ shouldLog: boolean = false,
+ disabled: boolean = false
+) {
+ return ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => {
+ const state = getState();
+ const source = getSelectedSource(state);
+
+ if (!source) {
+ return;
+ }
+ const breakpointLocation = {
+ sourceId: source.id,
+ sourceUrl: source.url,
+ column: undefined,
+ line,
+ };
+
+ const options = {};
+ if (shouldLog) {
+ options.logValue = "displayName";
+ }
+
+ return dispatch(addBreakpoint(cx, breakpointLocation, options, disabled));
+ };
+}
+
+export function removeBreakpointsAtLine(
+ cx: Context,
+ sourceId: SourceId,
+ line: number
+) {
+ return ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => {
+ const breakpointsAtLine = getBreakpointsForSource(
+ getState(),
+ sourceId,
+ line
+ );
+ return dispatch(removeBreakpoints(cx, breakpointsAtLine));
+ };
+}
+
+export function disableBreakpointsAtLine(
+ cx: Context,
+ sourceId: SourceId,
+ line: number
+) {
+ return ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => {
+ const breakpointsAtLine = getBreakpointsForSource(
+ getState(),
+ sourceId,
+ line
+ );
+ return dispatch(toggleBreakpoints(cx, true, breakpointsAtLine));
+ };
+}
+
+export function enableBreakpointsAtLine(
+ cx: Context,
+ sourceId: SourceId,
+ line: number
+) {
+ return ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => {
+ const breakpointsAtLine = getBreakpointsForSource(
+ getState(),
+ sourceId,
+ line
+ );
+ return dispatch(toggleBreakpoints(cx, false, breakpointsAtLine));
+ };
+}
+
+export function toggleDisabledBreakpoint(cx: Context, breakpoint: Breakpoint) {
+ return ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => {
+ if (!breakpoint.disabled) {
+ return dispatch(disableBreakpoint(cx, breakpoint));
+ }
+ return dispatch(enableBreakpoint(cx, breakpoint));
+ };
+}
+
+export function enableXHRBreakpoint(index: number, bp?: XHRBreakpoint) {
+ return ({ dispatch, getState, client }: ThunkArgs) => {
+ const xhrBreakpoints = getXHRBreakpoints(getState());
+ const breakpoint = bp || xhrBreakpoints[index];
+ const enabledBreakpoint = {
+ ...breakpoint,
+ disabled: false,
+ };
+
+ return dispatch({
+ type: "ENABLE_XHR_BREAKPOINT",
+ breakpoint: enabledBreakpoint,
+ index,
+ [PROMISE]: client.setXHRBreakpoint(breakpoint.path, breakpoint.method),
+ });
+ };
+}
+
+export function disableXHRBreakpoint(index: number, bp?: XHRBreakpoint) {
+ return ({ dispatch, getState, client }: ThunkArgs) => {
+ const xhrBreakpoints = getXHRBreakpoints(getState());
+ const breakpoint = bp || xhrBreakpoints[index];
+ const disabledBreakpoint = {
+ ...breakpoint,
+ disabled: true,
+ };
+
+ return dispatch({
+ type: "DISABLE_XHR_BREAKPOINT",
+ breakpoint: disabledBreakpoint,
+ index,
+ [PROMISE]: client.removeXHRBreakpoint(breakpoint.path, breakpoint.method),
+ });
+ };
+}
+
+export function updateXHRBreakpoint(
+ index: number,
+ path: string,
+ method: string
+) {
+ return ({ dispatch, getState, client }: ThunkArgs) => {
+ const xhrBreakpoints = getXHRBreakpoints(getState());
+ const breakpoint = xhrBreakpoints[index];
+
+ const updatedBreakpoint = {
+ ...breakpoint,
+ path,
+ method,
+ text: L10N.getFormatStr("xhrBreakpoints.item.label", path),
+ };
+
+ return dispatch({
+ type: "UPDATE_XHR_BREAKPOINT",
+ breakpoint: updatedBreakpoint,
+ index,
+ [PROMISE]: Promise.all([
+ client.removeXHRBreakpoint(breakpoint.path, breakpoint.method),
+ client.setXHRBreakpoint(path, method),
+ ]),
+ });
+ };
+}
+export function togglePauseOnAny() {
+ return ({ dispatch, getState }: ThunkArgs) => {
+ const xhrBreakpoints = getXHRBreakpoints(getState());
+ const index = xhrBreakpoints.findIndex(({ path }) => path.length === 0);
+ if (index < 0) {
+ return dispatch(setXHRBreakpoint("", "ANY"));
+ }
+
+ const bp = xhrBreakpoints[index];
+ if (bp.disabled) {
+ return dispatch(enableXHRBreakpoint(index, bp));
+ }
+
+ return dispatch(disableXHRBreakpoint(index, bp));
+ };
+}
+
+export function setXHRBreakpoint(path: string, method: string) {
+ return ({ dispatch, getState, client }: ThunkArgs) => {
+ const breakpoint = createXHRBreakpoint(path, method);
+
+ return dispatch({
+ type: "SET_XHR_BREAKPOINT",
+ breakpoint,
+ [PROMISE]: client.setXHRBreakpoint(path, method),
+ });
+ };
+}
+
+export function removeXHRBreakpoint(index: number) {
+ return ({ dispatch, getState, client }: ThunkArgs) => {
+ const xhrBreakpoints = getXHRBreakpoints(getState());
+ const breakpoint = xhrBreakpoints[index];
+ return dispatch({
+ type: "REMOVE_XHR_BREAKPOINT",
+ breakpoint,
+ index,
+ [PROMISE]: client.removeXHRBreakpoint(breakpoint.path, breakpoint.method),
+ });
+ };
+}
diff --git a/devtools/client/debugger/src/actions/breakpoints/modify.js b/devtools/client/debugger/src/actions/breakpoints/modify.js
new file mode 100644
index 0000000000..9979380d07
--- /dev/null
+++ b/devtools/client/debugger/src/actions/breakpoints/modify.js
@@ -0,0 +1,395 @@
+/* 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/>. */
+
+// @flow
+
+import {
+ makeBreakpointLocation,
+ makeBreakpointId,
+ getASTLocation,
+} from "../../utils/breakpoint";
+
+import {
+ getBreakpoint,
+ getBreakpointPositionsForLocation,
+ getFirstBreakpointPosition,
+ getSymbols,
+ getSource,
+ getSourceContent,
+ getBreakpointsList,
+ getPendingBreakpointList,
+ isMapScopesEnabled,
+} from "../../selectors";
+
+import { setBreakpointPositions } from "./breakpointPositions";
+import { setSkipPausing } from "../pause/skipPausing";
+
+import { PROMISE } from "../utils/middleware/promise";
+import { recordEvent } from "../../utils/telemetry";
+import { comparePosition } from "../../utils/location";
+import { getTextAtPosition } from "../../utils/source";
+import { getMappedScopesForLocation } from "../pause/mapScopes";
+import { isOriginalSource } from "../../utils/source-maps";
+import { validateNavigateContext } from "../../utils/context";
+
+import type { ThunkArgs } from "../types";
+import type {
+ Breakpoint,
+ BreakpointOptions,
+ BreakpointPosition,
+ SourceLocation,
+ Context,
+} from "../../types";
+import type { ParserDispatcher } from "../../workers/parser";
+import type { State } from "../../reducers/types";
+
+// This file has the primitive operations used to modify individual breakpoints
+// and keep them in sync with the breakpoints installed on server threads. These
+// are collected here to make it easier to preserve the following invariant:
+//
+// Breakpoints are included in reducer state iff they are disabled or requests
+// have been dispatched to set them in all server threads.
+//
+// To maintain this property, updates to the reducer and installed breakpoints
+// must happen with no intervening await. Using await allows other operations to
+// modify the breakpoint state in the interim and potentially cause breakpoint
+// state to go out of sync.
+//
+// The reducer is optimistically updated when users set or remove a breakpoint,
+// but it might take a little while before the breakpoints have been set or
+// removed in each thread. Once all outstanding requests sent to a thread have
+// been processed, the reducer and server threads will be in sync.
+//
+// There is another exception to the above invariant when first connecting to
+// the server: breakpoints have been installed on all generated locations in the
+// pending breakpoints, but no breakpoints have been added to the reducer. When
+// a matching source appears, either the server breakpoint will be removed or a
+// breakpoint will be added to the reducer, to restore the above invariant.
+// See syncBreakpoint.js for more.
+
+async function clientSetBreakpoint(
+ client,
+ cx,
+ { getState, dispatch }: ThunkArgs,
+ breakpoint: Breakpoint
+) {
+ const breakpointLocation = makeBreakpointLocation(
+ getState(),
+ breakpoint.generatedLocation
+ );
+ const shouldMapBreakpointExpressions =
+ isMapScopesEnabled(getState()) &&
+ isOriginalSource(getSource(getState(), breakpoint.location?.sourceId)) &&
+ (breakpoint.options.logValue || breakpoint.options.condition);
+
+ if (shouldMapBreakpointExpressions) {
+ breakpoint = await dispatch(updateBreakpointSourceMapping(cx, breakpoint));
+ }
+ return client.setBreakpoint(breakpointLocation, breakpoint.options);
+}
+
+function clientRemoveBreakpoint(
+ client,
+ state: State,
+ generatedLocation: SourceLocation
+) {
+ const breakpointLocation = makeBreakpointLocation(state, generatedLocation);
+ return client.removeBreakpoint(breakpointLocation);
+}
+
+export function enableBreakpoint(cx: Context, initialBreakpoint: Breakpoint) {
+ return (thunkArgs: ThunkArgs) => {
+ const { dispatch, getState, client } = thunkArgs;
+ const breakpoint = getBreakpoint(getState(), initialBreakpoint.location);
+ if (!breakpoint || !breakpoint.disabled) {
+ return;
+ }
+
+ dispatch(setSkipPausing(false));
+ return dispatch({
+ type: "SET_BREAKPOINT",
+ cx,
+ breakpoint: { ...breakpoint, disabled: false },
+ [PROMISE]: clientSetBreakpoint(client, cx, thunkArgs, breakpoint),
+ });
+ };
+}
+
+export function addBreakpoint(
+ cx: Context,
+ initialLocation: SourceLocation,
+ options: BreakpointOptions = {},
+ disabled: boolean = false,
+ shouldCancel: () => boolean = () => false
+) {
+ return async (thunkArgs: ThunkArgs) => {
+ const { dispatch, getState, client } = thunkArgs;
+ recordEvent("add_breakpoint");
+
+ const { sourceId, column, line } = initialLocation;
+
+ await dispatch(setBreakpointPositions({ cx, sourceId, line }));
+
+ const position: ?BreakpointPosition = column
+ ? getBreakpointPositionsForLocation(getState(), initialLocation)
+ : getFirstBreakpointPosition(getState(), initialLocation);
+
+ if (!position) {
+ return;
+ }
+
+ const { location, generatedLocation } = position;
+
+ const source = getSource(getState(), location.sourceId);
+ const generatedSource = getSource(getState(), generatedLocation.sourceId);
+
+ if (!source || !generatedSource) {
+ return;
+ }
+
+ const symbols = getSymbols(getState(), source);
+ const astLocation = getASTLocation(source, symbols, location);
+
+ const originalContent = getSourceContent(getState(), source.id);
+ const originalText = getTextAtPosition(
+ source.id,
+ originalContent,
+ location
+ );
+
+ const content = getSourceContent(getState(), generatedSource.id);
+ const text = getTextAtPosition(
+ generatedSource.id,
+ content,
+ generatedLocation
+ );
+
+ const id = makeBreakpointId(location);
+ const breakpoint = {
+ id,
+ disabled,
+ options,
+ location,
+ astLocation,
+ generatedLocation,
+ text,
+ originalText,
+ };
+
+ if (shouldCancel()) {
+ return;
+ }
+
+ dispatch(setSkipPausing(false));
+ return dispatch({
+ type: "SET_BREAKPOINT",
+ cx,
+ breakpoint,
+ // If we just clobbered an enabled breakpoint with a disabled one, we need
+ // to remove any installed breakpoint in the server.
+ [PROMISE]: disabled
+ ? clientRemoveBreakpoint(client, getState(), generatedLocation)
+ : clientSetBreakpoint(client, cx, thunkArgs, breakpoint),
+ });
+ };
+}
+
+/**
+ * Remove a single breakpoint
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function removeBreakpoint(cx: Context, initialBreakpoint: Breakpoint) {
+ return ({ dispatch, getState, client }: ThunkArgs) => {
+ recordEvent("remove_breakpoint");
+
+ const breakpoint = getBreakpoint(getState(), initialBreakpoint.location);
+ if (!breakpoint) {
+ return;
+ }
+
+ dispatch(setSkipPausing(false));
+ return dispatch({
+ type: "REMOVE_BREAKPOINT",
+ cx,
+ location: breakpoint.location,
+ // If the breakpoint is disabled then it is not installed in the server.
+ [PROMISE]: breakpoint.disabled
+ ? Promise.resolve()
+ : clientRemoveBreakpoint(
+ client,
+ getState(),
+ breakpoint.generatedLocation
+ ),
+ });
+ };
+}
+
+/**
+ * Remove all installed, pending, and client breakpoints associated with a
+ * target generated location.
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function removeBreakpointAtGeneratedLocation(
+ cx: Context,
+ target: SourceLocation
+) {
+ return ({ dispatch, getState, client }: ThunkArgs) => {
+ // remove breakpoint from the server
+ const onBreakpointRemoved = clientRemoveBreakpoint(
+ client,
+ getState(),
+ target
+ );
+ // Remove any breakpoints matching the generated location.
+ const breakpoints = getBreakpointsList(getState());
+ for (const { location, generatedLocation } of breakpoints) {
+ if (
+ generatedLocation.sourceId == target.sourceId &&
+ comparePosition(generatedLocation, target)
+ ) {
+ dispatch({
+ type: "REMOVE_BREAKPOINT",
+ cx,
+ location,
+ [PROMISE]: onBreakpointRemoved,
+ });
+ }
+ }
+
+ // Remove any remaining pending breakpoints matching the generated location.
+ const pending = getPendingBreakpointList(getState());
+ for (const { location, generatedLocation } of pending) {
+ if (
+ generatedLocation.sourceUrl == target.sourceUrl &&
+ comparePosition(generatedLocation, target)
+ ) {
+ dispatch({
+ type: "REMOVE_PENDING_BREAKPOINT",
+ cx,
+ location,
+ });
+ }
+ }
+ return onBreakpointRemoved;
+ };
+}
+
+/**
+ * Disable a single breakpoint
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function disableBreakpoint(cx: Context, initialBreakpoint: Breakpoint) {
+ return ({ dispatch, getState, client }: ThunkArgs) => {
+ const breakpoint = getBreakpoint(getState(), initialBreakpoint.location);
+ if (!breakpoint || breakpoint.disabled) {
+ return;
+ }
+
+ dispatch(setSkipPausing(false));
+ return dispatch({
+ type: "SET_BREAKPOINT",
+ cx,
+ breakpoint: { ...breakpoint, disabled: true },
+ [PROMISE]: clientRemoveBreakpoint(
+ client,
+ getState(),
+ breakpoint.generatedLocation
+ ),
+ });
+ };
+}
+
+/**
+ * Update the options of a breakpoint.
+ *
+ * @throws {Error} "not implemented"
+ * @memberof actions/breakpoints
+ * @static
+ * @param {SourceLocation} location
+ * @see DebuggerController.Breakpoints.addBreakpoint
+ * @param {Object} options
+ * Any options to set on the breakpoint
+ */
+export function setBreakpointOptions(
+ cx: Context,
+ location: SourceLocation,
+ options: BreakpointOptions = {}
+) {
+ return (thunkArgs: ThunkArgs) => {
+ const { dispatch, getState, client } = thunkArgs;
+ let breakpoint = getBreakpoint(getState(), location);
+ if (!breakpoint) {
+ return dispatch(addBreakpoint(cx, location, options));
+ }
+
+ // Note: setting a breakpoint's options implicitly enables it.
+ breakpoint = { ...breakpoint, disabled: false, options };
+
+ return dispatch({
+ type: "SET_BREAKPOINT",
+ cx,
+ breakpoint,
+ [PROMISE]: clientSetBreakpoint(client, cx, thunkArgs, breakpoint),
+ });
+ };
+}
+
+async function updateExpression(
+ evaluationsParser: ParserDispatcher,
+ mappings,
+ originalExpression: string
+) {
+ const mapped = await evaluationsParser.mapExpression(
+ originalExpression,
+ mappings,
+ [],
+ false,
+ false
+ );
+ if (!mapped) {
+ return originalExpression;
+ }
+ if (!originalExpression.trimEnd().endsWith(";")) {
+ return mapped.expression.replace(/;$/, "");
+ }
+ return mapped.expression;
+}
+
+function updateBreakpointSourceMapping(cx: Context, breakpoint: Breakpoint) {
+ return async ({ getState, dispatch, evaluationsParser }: ThunkArgs) => {
+ const options = { ...breakpoint.options };
+
+ const mappedScopes = await dispatch(
+ getMappedScopesForLocation(breakpoint.location)
+ );
+ if (!mappedScopes) {
+ return breakpoint;
+ }
+ const { mappings } = mappedScopes;
+
+ if (options.condition) {
+ options.condition = await updateExpression(
+ evaluationsParser,
+ mappings,
+ options.condition
+ );
+ }
+ if (options.logValue) {
+ options.logValue = await updateExpression(
+ evaluationsParser,
+ mappings,
+ options.logValue
+ );
+ }
+
+ validateNavigateContext(getState(), cx);
+ return { ...breakpoint, options };
+ };
+}
diff --git a/devtools/client/debugger/src/actions/breakpoints/moz.build b/devtools/client/debugger/src/actions/breakpoints/moz.build
new file mode 100644
index 0000000000..b686529199
--- /dev/null
+++ b/devtools/client/debugger/src/actions/breakpoints/moz.build
@@ -0,0 +1,14 @@
+# vim: set filetype=python:
+# 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/.
+
+DIRS += []
+
+CompiledModules(
+ "breakpointPositions.js",
+ "index.js",
+ "modify.js",
+ "remapLocations.js",
+ "syncBreakpoint.js",
+)
diff --git a/devtools/client/debugger/src/actions/breakpoints/remapLocations.js b/devtools/client/debugger/src/actions/breakpoints/remapLocations.js
new file mode 100644
index 0000000000..0afbc640bc
--- /dev/null
+++ b/devtools/client/debugger/src/actions/breakpoints/remapLocations.js
@@ -0,0 +1,29 @@
+/* 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/>. */
+
+// @flow
+
+import typeof SourceMaps from "devtools-source-map";
+
+import type { Breakpoint, SourceId } from "../../types";
+
+export default function remapLocations(
+ breakpoints: Breakpoint[],
+ sourceId: SourceId,
+ sourceMaps: SourceMaps
+): Promise<Breakpoint[]> {
+ const sourceBreakpoints: Promise<Breakpoint>[] = breakpoints.map(
+ async breakpoint => {
+ if (breakpoint.location.sourceId !== sourceId) {
+ return breakpoint;
+ }
+ const location = await sourceMaps.getOriginalLocation(
+ breakpoint.location
+ );
+ return { ...breakpoint, location };
+ }
+ );
+
+ return Promise.all(sourceBreakpoints);
+}
diff --git a/devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js b/devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js
new file mode 100644
index 0000000000..bed0193313
--- /dev/null
+++ b/devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js
@@ -0,0 +1,194 @@
+/* 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/>. */
+
+// @flow
+
+import { setBreakpointPositions } from "./breakpointPositions";
+import { setSymbols } from "../sources/symbols";
+import {
+ assertPendingBreakpoint,
+ findFunctionByName,
+ findPosition,
+ makeBreakpointLocation,
+} from "../../utils/breakpoint";
+
+import { comparePosition, createLocation } from "../../utils/location";
+
+import { originalToGeneratedId, isOriginalId } from "devtools-source-map";
+import { getSource } from "../../selectors";
+import { addBreakpoint, removeBreakpointAtGeneratedLocation } from ".";
+
+import type { ThunkArgs } from "../types";
+import type { LoadedSymbols } from "../../reducers/types";
+
+import type {
+ SourceLocation,
+ ASTLocation,
+ PendingBreakpoint,
+ SourceId,
+ Source,
+ BreakpointPositions,
+ Context,
+} from "../../types";
+
+async function findBreakpointPosition(
+ cx: Context,
+ { getState, dispatch }: ThunkArgs,
+ location: SourceLocation
+) {
+ const { sourceId, line } = location;
+ const positions: BreakpointPositions = await dispatch(
+ setBreakpointPositions({ cx, sourceId, line })
+ );
+
+ const position = findPosition(positions, location);
+ return position?.generatedLocation;
+}
+
+async function findNewLocation(
+ cx: Context,
+ { name, offset, index }: ASTLocation,
+ location: SourceLocation,
+ source: Source,
+ thunkArgs: ThunkArgs
+) {
+ const symbols: LoadedSymbols = await thunkArgs.dispatch(
+ setSymbols({ cx, source })
+ );
+ const func = symbols ? findFunctionByName(symbols, name, index) : null;
+
+ // Fallback onto the location line, if we do not find a function.
+ let { line } = location;
+ if (func) {
+ line = func.location.start.line + offset.line;
+ }
+
+ return {
+ line,
+ column: location.column,
+ sourceUrl: source.url,
+ sourceId: source.id,
+ };
+}
+
+// Breakpoint syncing occurs when a source is found that matches either the
+// original or generated URL of a pending breakpoint. A new breakpoint is
+// constructed that might have a different original and/or generated location,
+// if the original source has changed since the pending breakpoint was created.
+// There are a couple subtle aspects to syncing:
+//
+// - We handle both the original and generated source because there is no
+// guarantee that seeing the generated source means we will also see the
+// original source. When connecting, a breakpoint will be installed in the
+// client for the generated location in the pending breakpoint, and we need
+// to make sure that either a breakpoint is added to the reducer or that this
+// client breakpoint is deleted.
+//
+// - If we see both the original and generated sources and the source mapping
+// has changed, we need to make sure that only a single breakpoint is added
+// to the reducer for the new location corresponding to the original location
+// in the pending breakpoint.
+export function syncBreakpoint(
+ cx: Context,
+ sourceId: SourceId,
+ pendingBreakpoint: PendingBreakpoint
+) {
+ return async (thunkArgs: ThunkArgs) => {
+ const { getState, client, dispatch } = thunkArgs;
+ assertPendingBreakpoint(pendingBreakpoint);
+
+ const source = getSource(getState(), sourceId);
+
+ const generatedSourceId = isOriginalId(sourceId)
+ ? originalToGeneratedId(sourceId)
+ : sourceId;
+
+ const generatedSource = getSource(getState(), generatedSourceId);
+
+ if (!source || !generatedSource) {
+ return;
+ }
+
+ const { location, generatedLocation, astLocation } = pendingBreakpoint;
+ const sourceGeneratedLocation = createLocation({
+ ...generatedLocation,
+ sourceId: generatedSourceId,
+ });
+
+ if (
+ source == generatedSource &&
+ location.sourceUrl != generatedLocation.sourceUrl
+ ) {
+ // We are handling the generated source and the pending breakpoint has a
+ // source mapping. Supply a cancellation callback that will abort the
+ // breakpoint if the original source was synced to a different location,
+ // in which case the client breakpoint has been removed.
+ const breakpointLocation = makeBreakpointLocation(
+ getState(),
+ sourceGeneratedLocation
+ );
+ return dispatch(
+ addBreakpoint(
+ cx,
+ sourceGeneratedLocation,
+ pendingBreakpoint.options,
+ pendingBreakpoint.disabled,
+ () => !client.hasBreakpoint(breakpointLocation)
+ )
+ );
+ }
+
+ const previousLocation = { ...location, sourceId };
+
+ const newLocation = await findNewLocation(
+ cx,
+ astLocation,
+ previousLocation,
+ source,
+ thunkArgs
+ );
+
+ const newGeneratedLocation = await findBreakpointPosition(
+ cx,
+ thunkArgs,
+ newLocation
+ );
+
+ if (!newGeneratedLocation) {
+ // We couldn't find a new mapping for the breakpoint. If there is a source
+ // mapping, remove any breakpoints for the generated location, as if the
+ // breakpoint moved. If the old generated location still maps to an
+ // original location then we don't want to add a breakpoint for it.
+ if (location.sourceUrl != generatedLocation.sourceUrl) {
+ dispatch(
+ removeBreakpointAtGeneratedLocation(cx, sourceGeneratedLocation)
+ );
+ }
+ return;
+ }
+
+ const isSameLocation = comparePosition(
+ generatedLocation,
+ newGeneratedLocation
+ );
+
+ // If the new generated location has changed from that in the pending
+ // breakpoint, remove any breakpoint associated with the old generated
+ // location.
+ if (!isSameLocation) {
+ dispatch(
+ removeBreakpointAtGeneratedLocation(cx, sourceGeneratedLocation)
+ );
+ }
+
+ return dispatch(
+ addBreakpoint(
+ cx,
+ newLocation,
+ pendingBreakpoint.options,
+ pendingBreakpoint.disabled
+ )
+ );
+ };
+}
diff --git a/devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap b/devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap
new file mode 100644
index 0000000000..c55e71c546
--- /dev/null
+++ b/devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap
@@ -0,0 +1,131 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`breakpoints should add a breakpoint 1`] = `
+Array [
+ Object {
+ "breakpoints": Array [
+ Object {
+ "astLocation": Object {
+ "index": 0,
+ "name": undefined,
+ "offset": Object {
+ "column": 1,
+ "line": 2,
+ "sourceId": "a",
+ "sourceUrl": "http://localhost:8000/examples/a",
+ },
+ },
+ "disabled": false,
+ "generatedLocation": Object {
+ "column": 1,
+ "line": 2,
+ "sourceId": "a",
+ "sourceUrl": "http://localhost:8000/examples/a",
+ },
+ "id": "a:2:1",
+ "location": Object {
+ "column": 1,
+ "line": 2,
+ "sourceId": "a",
+ "sourceUrl": "http://localhost:8000/examples/a",
+ },
+ "options": Object {},
+ "originalText": "return a",
+ "text": "return a",
+ },
+ ],
+ "source": Object {
+ "extensionName": null,
+ "id": "a",
+ "isBlackBoxed": false,
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "relativeUrl": "/examples/a",
+ "url": "http://localhost:8000/examples/a",
+ },
+ },
+]
+`;
+
+exports[`breakpoints should not show a breakpoint that does not have text 1`] = `Array []`;
+
+exports[`breakpoints should remap breakpoints on pretty print 1`] = `
+Object {
+ "astLocation": Object {
+ "index": 0,
+ "name": "a",
+ "offset": Object {
+ "column": undefined,
+ "line": 0,
+ },
+ },
+ "disabled": false,
+ "generatedLocation": Object {
+ "column": 0,
+ "line": 1,
+ "sourceId": "a.js",
+ "sourceUrl": "http://localhost:8000/examples/a.js",
+ },
+ "id": "a.js/originalSource-d6d70368d5c252598541e693a7ad6c27:1:",
+ "location": Object {
+ "column": 0,
+ "line": 1,
+ "sourceId": "a.js/originalSource-d6d70368d5c252598541e693a7ad6c27",
+ "sourceUrl": "http://localhost:8000/examples/a.js:formatted",
+ },
+ "options": Object {},
+ "originalText": "function a() {",
+ "text": "function a() {",
+}
+`;
+
+exports[`breakpoints should show a disabled breakpoint that does not have text 1`] = `
+Array [
+ Object {
+ "breakpoints": Array [
+ Object {
+ "astLocation": Object {
+ "index": 0,
+ "name": undefined,
+ "offset": Object {
+ "column": 1,
+ "line": 5,
+ "sourceId": "a",
+ "sourceUrl": "http://localhost:8000/examples/a",
+ },
+ },
+ "disabled": true,
+ "generatedLocation": Object {
+ "column": 1,
+ "line": 5,
+ "sourceId": "a",
+ "sourceUrl": "http://localhost:8000/examples/a",
+ },
+ "id": "a:5:1",
+ "location": Object {
+ "column": 1,
+ "line": 5,
+ "sourceId": "a",
+ "sourceUrl": "http://localhost:8000/examples/a",
+ },
+ "options": Object {},
+ "originalText": "",
+ "text": "",
+ },
+ ],
+ "source": Object {
+ "extensionName": null,
+ "id": "a",
+ "isBlackBoxed": false,
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "relativeUrl": "/examples/a",
+ "url": "http://localhost:8000/examples/a",
+ },
+ },
+]
+`;
diff --git a/devtools/client/debugger/src/actions/breakpoints/tests/breakpointPositions.spec.js b/devtools/client/debugger/src/actions/breakpoints/tests/breakpointPositions.spec.js
new file mode 100644
index 0000000000..6798cf419b
--- /dev/null
+++ b/devtools/client/debugger/src/actions/breakpoints/tests/breakpointPositions.spec.js
@@ -0,0 +1,112 @@
+// @flow
+
+/* 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/>. */
+
+import {
+ actions,
+ selectors,
+ createStore,
+ makeSource,
+ waitForState,
+} from "../../../utils/test-head";
+import { createSource } from "../../tests/helpers/mockCommandClient";
+
+describe("breakpointPositions", () => {
+ it("fetches positions", async () => {
+ const fooContent = createSource("foo", "");
+
+ const store = createStore({
+ getSourceActorBreakpointPositions: async () => ({ "9": [1] }),
+ getSourceActorBreakableLines: async () => [],
+ sourceContents: async () => fooContent,
+ });
+
+ const { dispatch, getState, cx } = store;
+ const source = await dispatch(
+ actions.newGeneratedSource(makeSource("foo"))
+ );
+ await dispatch(actions.loadSourceById(cx, source.id));
+
+ dispatch(actions.setBreakpointPositions({ cx, sourceId: "foo", line: 9 }));
+
+ await waitForState(store, state =>
+ selectors.hasBreakpointPositions(state, "foo")
+ );
+
+ expect(
+ selectors.getBreakpointPositionsForSource(getState(), "foo")
+ ).toEqual({
+ [9]: [
+ {
+ location: {
+ line: 9,
+ column: 1,
+ sourceId: "foo",
+ sourceUrl: "http://localhost:8000/examples/foo",
+ },
+ generatedLocation: {
+ line: 9,
+ column: 1,
+ sourceId: "foo",
+ sourceUrl: "http://localhost:8000/examples/foo",
+ },
+ },
+ ],
+ });
+ });
+
+ it("doesn't re-fetch positions", async () => {
+ const fooContent = createSource("foo", "");
+
+ let resolve = _ => {};
+ let count = 0;
+ const store = createStore({
+ getSourceActorBreakpointPositions: () =>
+ new Promise(r => {
+ count++;
+ resolve = r;
+ }),
+ getSourceActorBreakableLines: async () => [],
+ sourceContents: async () => fooContent,
+ });
+
+ const { dispatch, getState, cx } = store;
+ const source = await dispatch(
+ actions.newGeneratedSource(makeSource("foo"))
+ );
+ await dispatch(actions.loadSourceById(cx, source.id));
+
+ dispatch(actions.setBreakpointPositions({ cx, sourceId: "foo", line: 9 }));
+ dispatch(actions.setBreakpointPositions({ cx, sourceId: "foo", line: 9 }));
+
+ resolve({ "9": [1] });
+ await waitForState(store, state =>
+ selectors.hasBreakpointPositions(state, "foo")
+ );
+
+ expect(
+ selectors.getBreakpointPositionsForSource(getState(), "foo")
+ ).toEqual({
+ [9]: [
+ {
+ location: {
+ line: 9,
+ column: 1,
+ sourceId: "foo",
+ sourceUrl: "http://localhost:8000/examples/foo",
+ },
+ generatedLocation: {
+ line: 9,
+ column: 1,
+ sourceId: "foo",
+ sourceUrl: "http://localhost:8000/examples/foo",
+ },
+ },
+ ],
+ });
+
+ expect(count).toEqual(1);
+ });
+});
diff --git a/devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js b/devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js
new file mode 100644
index 0000000000..4a1e68a0bd
--- /dev/null
+++ b/devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js
@@ -0,0 +1,487 @@
+/* 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/>. */
+
+// @flow
+
+import {
+ createStore,
+ selectors,
+ actions,
+ makeSource,
+ getTelemetryEvents,
+} from "../../../utils/test-head";
+
+import { mockCommandClient } from "../../tests/helpers/mockCommandClient";
+import { mockPendingBreakpoint } from "../../tests/helpers/breakpoints.js";
+import { makePendingLocationId } from "../../../utils/breakpoint";
+
+function mockClient(positionsResponse = {}) {
+ return {
+ ...mockCommandClient,
+ getSourceActorBreakpointPositions: async () => positionsResponse,
+ getSourceActorBreakableLines: async () => [],
+ };
+}
+
+describe("breakpoints", () => {
+ it("should add a breakpoint", async () => {
+ const { dispatch, getState, cx } = createStore(mockClient({ "2": [1] }));
+ const loc1 = {
+ sourceId: "a",
+ line: 2,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ };
+
+ const source = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ await dispatch(actions.loadSourceText({ cx, source }));
+ await dispatch(
+ actions.setSelectedLocation(cx, source, {
+ line: 1,
+ column: 1,
+ sourceId: source.id,
+ })
+ );
+
+ await dispatch(actions.addBreakpoint(cx, loc1));
+
+ expect(selectors.getBreakpointCount(getState())).toEqual(1);
+ const bp = selectors.getBreakpoint(getState(), loc1);
+ expect(bp && bp.location).toEqual(loc1);
+ expect(getTelemetryEvents("add_breakpoint")).toHaveLength(1);
+
+ const bpSources = selectors.getBreakpointSources(getState());
+ expect(bpSources).toMatchSnapshot();
+ });
+
+ it("should not show a breakpoint that does not have text", async () => {
+ const { dispatch, getState, cx } = createStore(mockClient({ "5": [1] }));
+ const loc1 = {
+ sourceId: "a",
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ };
+ const source = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ await dispatch(actions.loadSourceText({ cx, source }));
+ await dispatch(
+ actions.setSelectedLocation(cx, source, {
+ line: 1,
+ column: 1,
+ sourceId: source.id,
+ })
+ );
+
+ await dispatch(actions.addBreakpoint(cx, loc1));
+
+ expect(selectors.getBreakpointCount(getState())).toEqual(1);
+ const bp = selectors.getBreakpoint(getState(), loc1);
+ expect(bp && bp.location).toEqual(loc1);
+ expect(selectors.getBreakpointSources(getState())).toMatchSnapshot();
+ });
+
+ it("should show a disabled breakpoint that does not have text", async () => {
+ const { dispatch, getState, cx } = createStore(mockClient({ "5": [1] }));
+ const loc1 = {
+ sourceId: "a",
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ };
+ const source = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ await dispatch(actions.loadSourceText({ cx, source }));
+ await dispatch(
+ actions.setSelectedLocation(cx, source, {
+ line: 1,
+ column: 1,
+ sourceId: source.id,
+ })
+ );
+
+ await dispatch(actions.addBreakpoint(cx, loc1));
+ const breakpoint = selectors.getBreakpoint(getState(), loc1);
+ if (!breakpoint) {
+ throw new Error("no breakpoint");
+ }
+
+ await dispatch(actions.disableBreakpoint(cx, breakpoint));
+
+ expect(selectors.getBreakpointCount(getState())).toEqual(1);
+ const bp = selectors.getBreakpoint(getState(), loc1);
+ expect(bp && bp.location).toEqual(loc1);
+ expect(selectors.getBreakpointSources(getState())).toMatchSnapshot();
+ });
+
+ it("should not re-add a breakpoint", async () => {
+ const { dispatch, getState, cx } = createStore(mockClient({ "5": [1] }));
+ const loc1 = {
+ sourceId: "a",
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ };
+
+ const source = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ await dispatch(actions.loadSourceText({ cx, source }));
+ await dispatch(
+ actions.setSelectedLocation(cx, source, {
+ line: 1,
+ column: 1,
+ sourceId: source.id,
+ })
+ );
+
+ await dispatch(actions.addBreakpoint(cx, loc1));
+ expect(selectors.getBreakpointCount(getState())).toEqual(1);
+ const bp = selectors.getBreakpoint(getState(), loc1);
+ expect(bp && bp.location).toEqual(loc1);
+
+ await dispatch(actions.addBreakpoint(cx, loc1));
+ expect(selectors.getBreakpointCount(getState())).toEqual(1);
+ });
+
+ it("should remove a breakpoint", async () => {
+ const { dispatch, getState, cx } = createStore(
+ mockClient({ "5": [1], "6": [2] })
+ );
+
+ const loc1 = {
+ sourceId: "a",
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ };
+
+ const loc2 = {
+ sourceId: "b",
+ line: 6,
+ column: 2,
+ sourceUrl: "http://localhost:8000/examples/b",
+ };
+
+ const aSource = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ await dispatch(actions.loadSourceText({ cx, source: aSource }));
+
+ const bSource = await dispatch(actions.newGeneratedSource(makeSource("b")));
+ await dispatch(actions.loadSourceText({ cx, source: bSource }));
+
+ await dispatch(
+ actions.setSelectedLocation(cx, aSource, {
+ line: 1,
+ column: 1,
+ sourceId: aSource.id,
+ })
+ );
+
+ await dispatch(actions.addBreakpoint(cx, loc1));
+ await dispatch(actions.addBreakpoint(cx, loc2));
+
+ const bp = selectors.getBreakpoint(getState(), loc1);
+ if (!bp) {
+ throw new Error("no bp");
+ }
+ await dispatch(actions.removeBreakpoint(cx, bp));
+
+ expect(selectors.getBreakpointCount(getState())).toEqual(1);
+ });
+
+ it("should disable a breakpoint", async () => {
+ const { dispatch, getState, cx } = createStore(
+ mockClient({ "5": [1], "6": [2] })
+ );
+
+ const loc1 = {
+ sourceId: "a",
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ };
+
+ const loc2 = {
+ sourceId: "b",
+ line: 6,
+ column: 2,
+ sourceUrl: "http://localhost:8000/examples/b",
+ };
+
+ const aSource = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ await dispatch(actions.loadSourceText({ cx, source: aSource }));
+
+ const bSource = await dispatch(actions.newGeneratedSource(makeSource("b")));
+ await dispatch(actions.loadSourceText({ cx, source: bSource }));
+
+ await dispatch(actions.addBreakpoint(cx, loc1));
+ await dispatch(actions.addBreakpoint(cx, loc2));
+
+ const breakpoint = selectors.getBreakpoint(getState(), loc1);
+ if (!breakpoint) {
+ throw new Error("no breakpoint");
+ }
+
+ await dispatch(actions.disableBreakpoint(cx, breakpoint));
+
+ const bp = selectors.getBreakpoint(getState(), loc1);
+ expect(bp && bp.disabled).toBe(true);
+ });
+
+ it("should enable breakpoint", async () => {
+ const { dispatch, getState, cx } = createStore(
+ mockClient({ "5": [1], "6": [2] })
+ );
+ const loc = {
+ sourceId: "a",
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ };
+
+ const aSource = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ await dispatch(actions.loadSourceText({ cx, source: aSource }));
+
+ await dispatch(actions.addBreakpoint(cx, loc));
+ let bp = selectors.getBreakpoint(getState(), loc);
+ if (!bp) {
+ throw new Error("no breakpoint");
+ }
+
+ await dispatch(actions.disableBreakpoint(cx, bp));
+
+ bp = selectors.getBreakpoint(getState(), loc);
+ if (!bp) {
+ throw new Error("no breakpoint");
+ }
+
+ expect(bp && bp.disabled).toBe(true);
+
+ await dispatch(actions.enableBreakpoint(cx, bp));
+
+ bp = selectors.getBreakpoint(getState(), loc);
+ expect(bp && !bp.disabled).toBe(true);
+ });
+
+ it("should toggle all the breakpoints", async () => {
+ const { dispatch, getState, cx } = createStore(
+ mockClient({ "5": [1], "6": [2] })
+ );
+
+ const loc1 = {
+ sourceId: "a",
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ };
+
+ const loc2 = {
+ sourceId: "b",
+ line: 6,
+ column: 2,
+ sourceUrl: "http://localhost:8000/examples/b",
+ };
+
+ const aSource = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ await dispatch(actions.loadSourceText({ cx, source: aSource }));
+
+ const bSource = await dispatch(actions.newGeneratedSource(makeSource("b")));
+ await dispatch(actions.loadSourceText({ cx, source: bSource }));
+
+ await dispatch(actions.addBreakpoint(cx, loc1));
+ await dispatch(actions.addBreakpoint(cx, loc2));
+
+ await dispatch(actions.toggleAllBreakpoints(cx, true));
+
+ let bp1 = selectors.getBreakpoint(getState(), loc1);
+ let bp2 = selectors.getBreakpoint(getState(), loc2);
+
+ expect(bp1 && bp1.disabled).toBe(true);
+ expect(bp2 && bp2.disabled).toBe(true);
+
+ await dispatch(actions.toggleAllBreakpoints(cx, false));
+
+ bp1 = selectors.getBreakpoint(getState(), loc1);
+ bp2 = selectors.getBreakpoint(getState(), loc2);
+ expect(bp1 && bp1.disabled).toBe(false);
+ expect(bp2 && bp2.disabled).toBe(false);
+ });
+
+ it("should remove all the breakpoints", async () => {
+ const mockedPendingBreakpoint = mockPendingBreakpoint({ column: 2 });
+ const id = makePendingLocationId(mockedPendingBreakpoint.location);
+ const pendingBreakpoints = { [id]: mockedPendingBreakpoint };
+
+ const { dispatch, getState, cx } = createStore(
+ mockClient({ "5": [1], "6": [2] }),
+ { pendingBreakpoints }
+ );
+
+ const loc1 = {
+ sourceId: "a",
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ };
+
+ const loc2 = {
+ sourceId: "b",
+ line: 6,
+ column: 2,
+ sourceUrl: "http://localhost:8000/examples/b",
+ };
+
+ const aSource = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ await dispatch(actions.loadSourceText({ cx, source: aSource }));
+
+ const bSource = await dispatch(actions.newGeneratedSource(makeSource("b")));
+ await dispatch(actions.loadSourceText({ cx, source: bSource }));
+
+ await dispatch(actions.addBreakpoint(cx, loc1));
+ await dispatch(actions.addBreakpoint(cx, loc2));
+
+ await dispatch(actions.removeAllBreakpoints(cx));
+
+ const bps = selectors.getBreakpointsList(getState());
+ const pendingBps = selectors.getPendingBreakpointList(getState());
+
+ expect(bps).toHaveLength(0);
+ expect(pendingBps).toHaveLength(0);
+ });
+
+ it("should toggle a breakpoint at a location", async () => {
+ const loc = { sourceId: "foo1", line: 5, column: 1 };
+ const getBp = () => selectors.getBreakpoint(getState(), loc);
+
+ const { dispatch, getState, cx } = createStore(mockClient({ "5": [1] }));
+
+ const source = await dispatch(
+ actions.newGeneratedSource(makeSource("foo1"))
+ );
+ await dispatch(actions.loadSourceText({ cx, source }));
+
+ await dispatch(actions.selectLocation(cx, loc));
+
+ await dispatch(actions.toggleBreakpointAtLine(cx, 5));
+ const bp = getBp();
+ expect(bp && !bp.disabled).toBe(true);
+
+ await dispatch(actions.toggleBreakpointAtLine(cx, 5));
+ expect(getBp()).toBe(undefined);
+ });
+
+ it("should disable/enable a breakpoint at a location", async () => {
+ const location = { sourceId: "foo1", line: 5, column: 1 };
+ const getBp = () => selectors.getBreakpoint(getState(), location);
+
+ const { dispatch, getState, cx } = createStore(mockClient({ "5": [1] }));
+
+ const source = await dispatch(
+ actions.newGeneratedSource(makeSource("foo1"))
+ );
+ await dispatch(actions.loadSourceText({ cx, source }));
+
+ await dispatch(actions.selectLocation(cx, { sourceId: "foo1", line: 1 }));
+
+ await dispatch(actions.toggleBreakpointAtLine(cx, 5));
+ let bp = getBp();
+ expect(bp && !bp.disabled).toBe(true);
+ bp = getBp();
+ if (!bp) {
+ throw new Error("no bp");
+ }
+ await dispatch(actions.toggleDisabledBreakpoint(cx, bp));
+ bp = getBp();
+ expect(bp && bp.disabled).toBe(true);
+ });
+
+ it("should set the breakpoint condition", async () => {
+ const { dispatch, getState, cx } = createStore(mockClient({ "5": [1] }));
+
+ const loc = {
+ sourceId: "a",
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ };
+
+ const source = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ await dispatch(actions.loadSourceText({ cx, source }));
+
+ await dispatch(actions.addBreakpoint(cx, loc));
+
+ let bp = selectors.getBreakpoint(getState(), loc);
+ expect(bp && bp.options.condition).toBe(undefined);
+
+ await dispatch(
+ actions.setBreakpointOptions(cx, loc, {
+ condition: "const foo = 0",
+ getTextForLine: () => {},
+ })
+ );
+
+ bp = selectors.getBreakpoint(getState(), loc);
+ expect(bp && bp.options.condition).toBe("const foo = 0");
+ });
+
+ it("should set the condition and enable a breakpoint", async () => {
+ const { dispatch, getState, cx } = createStore(mockClient({ "5": [1] }));
+
+ const loc = {
+ sourceId: "a",
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ };
+
+ const source = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ await dispatch(actions.loadSourceText({ cx, source }));
+
+ await dispatch(actions.addBreakpoint(cx, loc));
+ let bp = selectors.getBreakpoint(getState(), loc);
+ if (!bp) {
+ throw new Error("no breakpoint");
+ }
+
+ await dispatch(actions.disableBreakpoint(cx, bp));
+
+ bp = selectors.getBreakpoint(getState(), loc);
+ expect(bp && bp.options.condition).toBe(undefined);
+
+ await dispatch(
+ actions.setBreakpointOptions(cx, loc, {
+ condition: "const foo = 0",
+ getTextForLine: () => {},
+ })
+ );
+ const newBreakpoint = selectors.getBreakpoint(getState(), loc);
+ expect(newBreakpoint && !newBreakpoint.disabled).toBe(true);
+ expect(newBreakpoint && newBreakpoint.options.condition).toBe(
+ "const foo = 0"
+ );
+ });
+
+ it("should remap breakpoints on pretty print", async () => {
+ const { dispatch, getState, cx } = createStore(mockClient({ "1": [0] }));
+
+ const loc = {
+ sourceId: "a.js",
+ line: 1,
+ column: 0,
+ sourceUrl: "http://localhost:8000/examples/a.js",
+ };
+
+ const source = await dispatch(
+ actions.newGeneratedSource(makeSource("a.js"))
+ );
+ await dispatch(actions.loadSourceText({ cx, source }));
+
+ await dispatch(actions.addBreakpoint(cx, loc));
+ await dispatch(actions.togglePrettyPrint(cx, "a.js"));
+
+ const breakpoint = selectors.getBreakpointsList(getState())[0];
+
+ expect(
+ breakpoint.location.sourceUrl &&
+ breakpoint.location.sourceUrl.includes("formatted")
+ ).toBe(true);
+ expect(breakpoint).toMatchSnapshot();
+ });
+});