diff options
Diffstat (limited to 'devtools/client/debugger/src/actions/breakpoints')
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(); + }); +}); |