From 43a97878ce14b72f0981164f87f2e35e14151312 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 11:22:09 +0200 Subject: Adding upstream version 110.0.1. Signed-off-by: Daniel Baumann --- .../src/actions/breakpoints/breakpointPositions.js | 259 +++++++++ .../debugger/src/actions/breakpoints/index.js | 419 ++++++++++++++ .../debugger/src/actions/breakpoints/modify.js | 368 ++++++++++++ .../debugger/src/actions/breakpoints/moz.build | 13 + .../src/actions/breakpoints/syncBreakpoint.js | 142 +++++ .../tests/__snapshots__/breakpoints.spec.js.snap | 120 ++++ .../actions/breakpoints/tests/breakpoints.spec.js | 634 +++++++++++++++++++++ 7 files changed, 1955 insertions(+) create mode 100644 devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js create mode 100644 devtools/client/debugger/src/actions/breakpoints/index.js create mode 100644 devtools/client/debugger/src/actions/breakpoints/modify.js create mode 100644 devtools/client/debugger/src/actions/breakpoints/moz.build create mode 100644 devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js create mode 100644 devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap create mode 100644 devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js (limited to 'devtools/client/debugger/src/actions/breakpoints') 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..9e787ffd82 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js @@ -0,0 +1,259 @@ +/* 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 . */ + +import { + isOriginalId, + isGeneratedId, + originalToGeneratedId, +} from "devtools/client/shared/source-map-loader/index"; + +import { + getSource, + getSourceFromId, + getBreakpointPositionsForSource, + getSourceActorsForSource, +} from "../../selectors"; + +import { makeBreakpointId } from "../../utils/breakpoint"; +import { memoizeableAction } from "../../utils/memoizableAction"; +import { fulfilled } from "../../utils/async-value"; + +async function mapLocations(generatedLocations, { sourceMapLoader }) { + if (!generatedLocations.length) { + return []; + } + + const originalLocations = await sourceMapLoader.getOriginalLocations( + generatedLocations + ); + + return originalLocations.map((location, index) => ({ + location, + generatedLocation: generatedLocations[index], + })); +} + +// Filter out positions, that are not in the original source Id +function filterBySource(positions, sourceId) { + if (!isOriginalId(sourceId)) { + return positions; + } + return positions.filter(position => position.location.sourceId == sourceId); +} + +/** + * Merge positions that refer to duplicated positions. + * Some sourcemaped positions might refer to the exact same source/line/column triple. + * + * @param {Array<{location, generatedLocation}>} positions: List of possible breakable positions + * @returns {Array<{location, generatedLocation}>} A new, filtered array. + */ +function filterByUniqLocation(positions) { + const handledBreakpointIds = new Set(); + return positions.filter(({ location }) => { + const breakpointId = makeBreakpointId(location); + if (handledBreakpointIds.has(breakpointId)) { + return false; + } + + handledBreakpointIds.add(breakpointId); + return true; + }); +} + +function convertToList(results, source) { + 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, 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, sourceId, line, thunkArgs) { + const { client, dispatch, getState, sourceMapLoader } = thunkArgs; + let generatedSource = getSource(getState(), sourceId); + if (!generatedSource) { + return; + } + + const results = {}; + if (isOriginalId(sourceId)) { + const ranges = await sourceMapLoader.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( + async actor => { + const positions = await client.getSourceActorBreakpointPositions( + actor, + { + start: { line, column: 0 }, + end: { line: line + 1, column: 0 }, + } + ); + return positions[line] || []; + } + ) + ); + + 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) { + const generatedSource = getSource( + state, + isOriginalId(sourceId) ? originalToGeneratedId(sourceId) : sourceId + ); + const actors = generatedSource + ? getSourceActorsForSource(state, generatedSource.id).map( + ({ actor }) => actor + ) + : []; + return [sourceId, ...actors].join(":"); +} + +/** + * This method will force retrieving the breakable positions for a given source, on a given line. + * If this data has already been computed, it will returned the cached data. + * + * For original sources, this will query the SourceMap worker. + * For generated sources, this will query the DevTools server and the related source actors. + * + * @param Object options + * Dictionary object with many arguments: + * @param String options.sourceId + * The source we want to fetch breakable positions + * @param Number options.line + * The line we want to know which columns are breakable. + * (note that this seems to be optional for original sources) + * @return Array + * The list of all breakable positions, each object of this array will be like this: + * { + * line: Number + * column: Number + * sourceId: String + * sourceUrl: String + * } + */ +export const setBreakpointPositions = 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..faf410509b --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/index.js @@ -0,0 +1,419 @@ +/* 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 . */ + +/** + * Redux actions for breakpoints + * @module actions/breakpoints + */ + +import { PROMISE } from "../utils/middleware/promise"; +import { asyncStore } from "../../utils/prefs"; +import { + getBreakpointsList, + getXHRBreakpoints, + getSelectedSource, + getBreakpointAtLocation, + getBreakpointsForSource, + getBreakpointsAtLine, +} from "../../selectors"; +import { createXHRBreakpoint } from "../../utils/breakpoint"; +import { + addBreakpoint, + removeBreakpoint, + enableBreakpoint, + disableBreakpoint, +} from "./modify"; + +import { isOriginalId } from "devtools/client/shared/source-map-loader/index"; +// this will need to be changed so that addCLientBreakpoint is removed + +export * from "./breakpointPositions"; +export * from "./modify"; +export * from "./syncBreakpoint"; + +export function addHiddenBreakpoint(cx, location) { + return ({ dispatch }) => { + return dispatch(addBreakpoint(cx, location, { hidden: true })); + }; +} + +/** + * Disable all breakpoints in a source + * + * @memberof actions/breakpoints + * @static + */ +export function disableBreakpointsInSource(cx, source) { + return async ({ dispatch, getState, client }) => { + 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, source) { + return async ({ dispatch, getState, client }) => { + 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, shouldDisableBreakpoints) { + return async ({ dispatch, getState, client }) => { + 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, shouldDisableBreakpoints, breakpoints) { + return async ({ dispatch }) => { + const promises = breakpoints.map(breakpoint => + shouldDisableBreakpoints + ? dispatch(disableBreakpoint(cx, breakpoint)) + : dispatch(enableBreakpoint(cx, breakpoint)) + ); + + await Promise.all(promises); + }; +} + +export function toggleBreakpointsAtLine(cx, shouldDisableBreakpoints, line) { + return async ({ dispatch, getState }) => { + const breakpoints = getBreakpointsAtLine(getState(), line); + return dispatch( + toggleBreakpoints(cx, shouldDisableBreakpoints, breakpoints) + ); + }; +} + +/** + * Removes all breakpoints + * + * @memberof actions/breakpoints + * @static + */ +export function removeAllBreakpoints(cx) { + return async ({ dispatch, getState }) => { + const breakpointList = getBreakpointsList(getState()); + + await Promise.all( + breakpointList.map(bp => dispatch(removeBreakpoint(cx, bp))) + ); + dispatch({ type: "CLEAR_BREAKPOINTS" }); + }; +} + +/** + * Removes breakpoints + * + * @memberof actions/breakpoints + * @static + */ +export function removeBreakpoints(cx, breakpoints) { + return async ({ dispatch }) => { + return Promise.all( + breakpoints.map(bp => dispatch(removeBreakpoint(cx, bp))) + ); + }; +} + +/** + * Removes all breakpoints in a source + * + * @memberof actions/breakpoints + * @static + */ +export function removeBreakpointsInSource(cx, source) { + return async ({ dispatch, getState, client }) => { + const breakpoints = getBreakpointsForSource(getState(), source.id); + for (const breakpoint of breakpoints) { + dispatch(removeBreakpoint(cx, breakpoint)); + } + }; +} + +/** + * Update the original location information of breakpoints. + +/* + * Update breakpoints for a source that just got pretty printed. + * This method maps the breakpoints currently set only against the + * non-pretty-printed (generated) source to the related pretty-printed + * (original) source by querying the SourceMap service. + * + * @param {Objeect} cx + * @param {String} sourceId - the generated source id + */ +export function updateBreakpointsForNewPrettyPrintedSource(cx, sourceId) { + return async ({ dispatch, getState, sourceMapLoader }) => { + if (isOriginalId(sourceId)) { + console.error("Can't update breakpoints on original sources"); + return; + } + const breakpoints = getBreakpointsForSource(getState(), sourceId); + // Remap the breakpoints with the original location information from + // the pretty-printed source. + const newBreakpoints = await Promise.all( + breakpoints.map(async breakpoint => { + const location = await sourceMapLoader.getOriginalLocation( + breakpoint.generatedLocation + ); + return { ...breakpoint, location }; + }) + ); + + // 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, line) { + return ({ dispatch, getState }) => { + const state = getState(); + const selectedSource = getSelectedSource(state); + + if (!selectedSource) { + return null; + } + + 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, + line, + shouldLog = false, + disabled = false +) { + return ({ dispatch, getState }) => { + const state = getState(); + const source = getSelectedSource(state); + + if (!source) { + return null; + } + 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, sourceId, line) { + return ({ dispatch, getState }) => { + const breakpointsAtLine = getBreakpointsForSource( + getState(), + sourceId, + line + ); + return dispatch(removeBreakpoints(cx, breakpointsAtLine)); + }; +} + +export function disableBreakpointsAtLine(cx, sourceId, line) { + return ({ dispatch, getState }) => { + const breakpointsAtLine = getBreakpointsForSource( + getState(), + sourceId, + line + ); + return dispatch(toggleBreakpoints(cx, true, breakpointsAtLine)); + }; +} + +export function enableBreakpointsAtLine(cx, sourceId, line) { + return ({ dispatch, getState }) => { + const breakpointsAtLine = getBreakpointsForSource( + getState(), + sourceId, + line + ); + return dispatch(toggleBreakpoints(cx, false, breakpointsAtLine)); + }; +} + +export function toggleDisabledBreakpoint(cx, breakpoint) { + return ({ dispatch, getState }) => { + if (!breakpoint.disabled) { + return dispatch(disableBreakpoint(cx, breakpoint)); + } + return dispatch(enableBreakpoint(cx, breakpoint)); + }; +} + +export function enableXHRBreakpoint(index, bp) { + return ({ dispatch, getState, client }) => { + 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, bp) { + return ({ dispatch, getState, client }) => { + 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, path, method) { + return ({ dispatch, getState, client }) => { + 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 }) => { + 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, method) { + return ({ dispatch, getState, client }) => { + const breakpoint = createXHRBreakpoint(path, method); + + return dispatch({ + type: "SET_XHR_BREAKPOINT", + breakpoint, + [PROMISE]: client.setXHRBreakpoint(path, method), + }); + }; +} + +export function removeAllXHRBreakpoints() { + return async ({ dispatch, getState, client }) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const promises = xhrBreakpoints.map(breakpoint => + client.removeXHRBreakpoint(breakpoint.path, breakpoint.method) + ); + await dispatch({ + type: "CLEAR_XHR_BREAKPOINTS", + [PROMISE]: Promise.all(promises), + }); + asyncStore.xhrBreakpoints = []; + }; +} + +export function removeXHRBreakpoint(index) { + return ({ dispatch, getState, client }) => { + 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..76f31c02b1 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/modify.js @@ -0,0 +1,368 @@ +/* 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 . */ + +import { createBreakpoint } from "../../client/firefox/create"; +import { + makeBreakpointLocation, + makeBreakpointId, +} from "../../utils/breakpoint"; +import { + getBreakpoint, + getBreakpointPositionsForLocation, + getFirstBreakpointPosition, + getLocationSource, + getSettledSourceTextContent, + 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 { validateNavigateContext } from "../../utils/context"; + +// 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 if 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 }, + breakpoint +) { + const breakpointLocation = makeBreakpointLocation( + getState(), + breakpoint.generatedLocation + ); + const shouldMapBreakpointExpressions = + isMapScopesEnabled(getState()) && + getLocationSource(getState(), breakpoint.location).isOriginal && + (breakpoint.options.logValue || breakpoint.options.condition); + + if (shouldMapBreakpointExpressions) { + breakpoint = await dispatch(updateBreakpointSourceMapping(cx, breakpoint)); + } + return client.setBreakpoint(breakpointLocation, breakpoint.options); +} + +function clientRemoveBreakpoint(client, state, generatedLocation) { + const breakpointLocation = makeBreakpointLocation(state, generatedLocation); + return client.removeBreakpoint(breakpointLocation); +} + +export function enableBreakpoint(cx, initialBreakpoint) { + return thunkArgs => { + const { dispatch, getState, client } = thunkArgs; + const breakpoint = getBreakpoint(getState(), initialBreakpoint.location); + if (!breakpoint || !breakpoint.disabled) { + return null; + } + + dispatch(setSkipPausing(false)); + return dispatch({ + type: "SET_BREAKPOINT", + cx, + breakpoint: createBreakpoint({ ...breakpoint, disabled: false }), + [PROMISE]: clientSetBreakpoint(client, cx, thunkArgs, breakpoint), + }); + }; +} + +export function addBreakpoint( + cx, + initialLocation, + options = {}, + disabled, + shouldCancel = () => false +) { + return async thunkArgs => { + const { dispatch, getState, client } = thunkArgs; + recordEvent("add_breakpoint"); + + const { column, line } = initialLocation; + const initialSource = getLocationSource(getState(), initialLocation); + + await dispatch( + setBreakpointPositions({ cx, sourceId: initialSource.id, line }) + ); + + const position = column + ? getBreakpointPositionsForLocation(getState(), initialLocation) + : getFirstBreakpointPosition(getState(), initialLocation); + + // No position is found if the `initialLocation` is on a non-breakable line or + // the line no longer exists. + if (!position) { + return null; + } + + const { location, generatedLocation } = position; + + const source = getLocationSource(getState(), location); + const generatedSource = getLocationSource(getState(), generatedLocation); + + if (!source || !generatedSource) { + return null; + } + + const originalContent = getSettledSourceTextContent(getState(), location); + const originalText = getTextAtPosition( + source.id, + originalContent, + location + ); + + const content = getSettledSourceTextContent(getState(), generatedLocation); + const text = getTextAtPosition( + generatedSource.id, + content, + generatedLocation + ); + + const id = makeBreakpointId(location); + const breakpoint = createBreakpoint({ + id, + thread: generatedSource.thread, + disabled, + options, + location, + generatedLocation, + text, + originalText, + }); + + if (shouldCancel()) { + return null; + } + + 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, initialBreakpoint) { + return ({ dispatch, getState, client }) => { + recordEvent("remove_breakpoint"); + + const breakpoint = getBreakpoint(getState(), initialBreakpoint.location); + if (!breakpoint) { + return null; + } + + dispatch(setSkipPausing(false)); + return dispatch({ + type: "REMOVE_BREAKPOINT", + cx, + breakpoint, + // 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, target) { + return ({ dispatch, getState, client }) => { + // remove breakpoint from the server + const onBreakpointRemoved = clientRemoveBreakpoint( + client, + getState(), + target + ); + // Remove any breakpoints matching the generated location. + const breakpoints = getBreakpointsList(getState()); + for (const breakpoint of breakpoints) { + const { generatedLocation } = breakpoint; + if ( + generatedLocation.sourceId == target.sourceId && + comparePosition(generatedLocation, target) + ) { + dispatch({ + type: "REMOVE_BREAKPOINT", + cx, + breakpoint, + [PROMISE]: onBreakpointRemoved, + }); + } + } + + // Remove any remaining pending breakpoints matching the generated location. + const pending = getPendingBreakpointList(getState()); + for (const breakpoint of pending) { + const { generatedLocation } = breakpoint; + if ( + generatedLocation.sourceUrl == target.sourceUrl && + comparePosition(generatedLocation, target) + ) { + dispatch({ + type: "REMOVE_PENDING_BREAKPOINT", + cx, + breakpoint, + }); + } + } + return onBreakpointRemoved; + }; +} + +/** + * Disable a single breakpoint + * + * @memberof actions/breakpoints + * @static + */ +export function disableBreakpoint(cx, initialBreakpoint) { + return ({ dispatch, getState, client }) => { + const breakpoint = getBreakpoint(getState(), initialBreakpoint.location); + if (!breakpoint || breakpoint.disabled) { + return null; + } + + dispatch(setSkipPausing(false)); + return dispatch({ + type: "SET_BREAKPOINT", + cx, + breakpoint: createBreakpoint({ ...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, location, options = {}) { + return 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 = createBreakpoint({ ...breakpoint, disabled: false, options }); + + return dispatch({ + type: "SET_BREAKPOINT", + cx, + breakpoint, + [PROMISE]: clientSetBreakpoint(client, cx, thunkArgs, breakpoint), + }); + }; +} + +async function updateExpression(parserWorker, mappings, originalExpression) { + const mapped = await parserWorker.mapExpression( + originalExpression, + mappings, + [], + false, + false + ); + if (!mapped) { + return originalExpression; + } + if (!originalExpression.trimEnd().endsWith(";")) { + return mapped.expression.replace(/;$/, ""); + } + return mapped.expression; +} + +function updateBreakpointSourceMapping(cx, breakpoint) { + return async ({ getState, dispatch, parserWorker }) => { + 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( + parserWorker, + mappings, + options.condition + ); + } + if (options.logValue) { + options.logValue = await updateExpression( + parserWorker, + 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..65910c4ef2 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/moz.build @@ -0,0 +1,13 @@ +# 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", + "syncBreakpoint.js", +) 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..495fbdd340 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js @@ -0,0 +1,142 @@ +/* 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 . */ + +import { setBreakpointPositions } from "./breakpointPositions"; +import { + assertPendingBreakpoint, + findPosition, + makeBreakpointLocation, +} from "../../utils/breakpoint"; + +import { comparePosition, createLocation } from "../../utils/location"; + +import { + originalToGeneratedId, + isOriginalId, +} from "devtools/client/shared/source-map-loader/index"; +import { getSource } from "../../selectors"; +import { addBreakpoint, removeBreakpointAtGeneratedLocation } from "."; + +async function findBreakpointPosition(cx, { getState, dispatch }, location) { + const { sourceId, line } = location; + const positions = await dispatch( + setBreakpointPositions({ cx, sourceId, line }) + ); + + const position = findPosition(positions, location); + return position; +} + +// 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, sourceId, pendingBreakpoint) { + return async 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 null; + } + + const { location, generatedLocation } = 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 originalLocation = createLocation({ + ...location, + sourceId, + }); + + const newPosition = await findBreakpointPosition( + cx, + thunkArgs, + originalLocation + ); + + const newGeneratedLocation = newPosition?.generatedLocation; + 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 null; + } + + 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, + newGeneratedLocation, + 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..c775caecbd --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap @@ -0,0 +1,120 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`breakpoints should add a breakpoint 1`] = ` +Array [ + Object { + "breakpoints": Array [ + Object { + "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", + "thread": undefined, + }, + ], + "filename": "a", + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "a", + "group": "localhost:8000", + "path": "/examples/a", + "search": "", + }, + "extensionName": null, + "id": "a", + "isExtension": false, + "isHTML": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "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 { + "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() {", + "thread": undefined, +} +`; + +exports[`breakpoints should show a disabled breakpoint that does not have text 1`] = ` +Array [ + Object { + "breakpoints": Array [ + Object { + "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": "", + "thread": undefined, + }, + ], + "filename": "a", + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "a", + "group": "localhost:8000", + "path": "/examples/a", + "search": "", + }, + "extensionName": null, + "id": "a", + "isExtension": false, + "isHTML": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "url": "http://localhost:8000/examples/a", + }, + }, +] +`; 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..b31e7df9c9 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js @@ -0,0 +1,634 @@ +/* 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 . */ + +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"; +import { asyncStore } from "../../../utils/prefs"; +const { + registerStoreObserver, +} = require("devtools/client/shared/redux/subscriber"); + +jest.mock("../../../utils/prefs", () => ({ + prefs: { + expressions: [], + }, + asyncStore: { + pendingBreakpoints: {}, + }, + features: { + inlinePreview: true, + }, +})); + +function mockClient(positionsResponse = {}) { + return { + ...mockCommandClient, + setSkipPausing: jest.fn(), + 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.selectLocation(cx, { + 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.selectLocation(cx, { + 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.selectLocation(cx, { + 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.selectLocation(cx, { + 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"))); + + const bSource = await dispatch(actions.newGeneratedSource(makeSource("b"))); + const bSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + bSource.id + ); + + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: bSourceActor, + }) + ); + + await dispatch( + actions.selectLocation(cx, { + 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"))); + const aSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + aSource.id + ); + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: aSourceActor, + }) + ); + + const bSource = await dispatch(actions.newGeneratedSource(makeSource("b"))); + const bSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + bSource.id + ); + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: bSourceActor, + }) + ); + + 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"))); + const aSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + aSource.id + ); + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: aSourceActor, + }) + ); + + 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"))); + const aSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + aSource.id + ); + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: aSourceActor, + }) + ); + + const bSource = await dispatch(actions.newGeneratedSource(makeSource("b"))); + const bSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + bSource.id + ); + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: bSourceActor, + }) + ); + + 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, + }; + + asyncStore.pendingBreakpoints = pendingBreakpoints; + + const store = createStore(mockClient({ "5": [1], "6": [2] }), { + pendingBreakpoints, + }); + + const { dispatch, getState, cx } = store; + + // This mocks the updatePrefs observer which listens & updates the asyncStore + // when the reducer state changes. See https://searchfox.org/mozilla-central/ + // rev/287583a4a605eee8cd2d41381ffaea7a93d7b987/devtools/client/debugger/ + // src/utils/bootstrap.js#114-142 + function mockUpdatePrefs(state, oldState) { + if ( + selectors.getPendingBreakpoints(oldState) && + selectors.getPendingBreakpoints(oldState) !== + selectors.getPendingBreakpoints(state) + ) { + asyncStore.pendingBreakpoints = selectors.getPendingBreakpoints(state); + } + } + + registerStoreObserver(store, mockUpdatePrefs); + + 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"))); + const aSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + aSource.id + ); + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: aSourceActor, + }) + ); + + const bSource = await dispatch(actions.newGeneratedSource(makeSource("b"))); + const bSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + bSource.id + ); + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: bSourceActor, + }) + ); + + expect(selectors.getBreakpointsList(getState())).toHaveLength(0); + expect(selectors.getPendingBreakpointList(getState())).toHaveLength(1); + expect(Object.keys(asyncStore.pendingBreakpoints)).toHaveLength(1); + + await dispatch(actions.addBreakpoint(cx, loc1)); + await dispatch(actions.addBreakpoint(cx, loc2)); + + // The breakpoint list contains all the live breakpoints while the pending-breakpoint + // list stores contains all the breakpoints persisted across toolbox reopenings. + expect(selectors.getBreakpointsList(getState())).toHaveLength(2); + expect(selectors.getPendingBreakpointList(getState())).toHaveLength(3); + expect(Object.keys(asyncStore.pendingBreakpoints)).toHaveLength(3); + + await dispatch(actions.removeAllBreakpoints(cx)); + + expect(selectors.getBreakpointsList(getState())).toHaveLength(0); + expect(selectors.getPendingBreakpointList(getState())).toHaveLength(0); + expect(Object.keys(asyncStore.pendingBreakpoints)).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] })); + + await dispatch(actions.newGeneratedSource(makeSource("foo1"))); + 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] })); + + await dispatch(actions.newGeneratedSource(makeSource("foo1"))); + 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"))); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + 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"))); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + 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")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + 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(); + }); + + it("should remove the pretty-printed breakpoint that was added", 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")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + await dispatch(actions.addBreakpoint(cx, loc)); + await dispatch(actions.togglePrettyPrint(cx, "a.js")); + + const breakpoint = selectors.getBreakpointsList(getState())[0]; + + await dispatch(actions.removeBreakpoint(cx, breakpoint)); + + const breakpointList = selectors.getPendingBreakpointList(getState()); + expect(breakpointList.length).toBe(0); + }); +}); -- cgit v1.2.3