diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-21 11:44:51 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-21 11:44:51 +0000 |
commit | 9e3c08db40b8916968b9f30096c7be3f00ce9647 (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /devtools/client/debugger/src/actions | |
parent | Initial commit. (diff) | |
download | thunderbird-9e3c08db40b8916968b9f30096c7be3f00ce9647.tar.xz thunderbird-9e3c08db40b8916968b9f30096c7be3f00ce9647.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/debugger/src/actions')
92 files changed, 9717 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/actions/README.md b/devtools/client/debugger/src/actions/README.md new file mode 100644 index 0000000000..d919247838 --- /dev/null +++ b/devtools/client/debugger/src/actions/README.md @@ -0,0 +1,26 @@ +## Actions + +### Best Practices + +#### Scheduling Async Actions + +There are several use-cases with async actions that involve scheduling: + +* we do one action and cancel subsequent actions +* we do one action and subsequent calls wait on the initial call +* we start an action and show a loading state + +If you want to wait on subsequent calls you need to store action promises. +[ex][req] + +If you just want to cancel subsequent calls, you can keep track of a pending +state in the store. [ex][state] + +The advantage of adding the pending state to the store is that we can use that +in the UI: + +* disable/hide the pretty print button +* show a progress ui + +[req]: https://github.com/firefox-devtools/debugger/blob/master/src/actions/sources/loadSourceText.js +[state]: https://github.com/firefox-devtools/debugger/blob/master/src/reducers/sources.js diff --git a/devtools/client/debugger/src/actions/ast/index.js b/devtools/client/debugger/src/actions/ast/index.js new file mode 100644 index 0000000000..ec2c1ae84c --- /dev/null +++ b/devtools/client/debugger/src/actions/ast/index.js @@ -0,0 +1,5 @@ +/* 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/>. */ + +export { setInScopeLines } from "./setInScopeLines"; diff --git a/devtools/client/debugger/src/actions/ast/moz.build b/devtools/client/debugger/src/actions/ast/moz.build new file mode 100644 index 0000000000..5b0152d2ad --- /dev/null +++ b/devtools/client/debugger/src/actions/ast/moz.build @@ -0,0 +1,11 @@ +# 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( + "index.js", + "setInScopeLines.js", +) diff --git a/devtools/client/debugger/src/actions/ast/setInScopeLines.js b/devtools/client/debugger/src/actions/ast/setInScopeLines.js new file mode 100644 index 0000000000..a17510a507 --- /dev/null +++ b/devtools/client/debugger/src/actions/ast/setInScopeLines.js @@ -0,0 +1,94 @@ +/* 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 { + hasInScopeLines, + getSourceTextContent, + getVisibleSelectedFrame, +} from "../../selectors"; + +import { getSourceLineCount } from "../../utils/source"; + +import { isFulfilled } from "../../utils/async-value"; + +function getOutOfScopeLines(outOfScopeLocations) { + if (!outOfScopeLocations) { + return null; + } + + const uniqueLines = new Set(); + for (const location of outOfScopeLocations) { + for (let i = location.start.line; i < location.end.line; i++) { + uniqueLines.add(i); + } + } + + return uniqueLines; +} + +async function getInScopeLines( + cx, + location, + { dispatch, getState, parserWorker } +) { + const sourceTextContent = getSourceTextContent(getState(), location); + + let locations = null; + if (location.line && parserWorker.isLocationSupported(location)) { + locations = await parserWorker.findOutOfScopeLocations(location); + } + + const linesOutOfScope = getOutOfScopeLines(locations); + const sourceNumLines = + !sourceTextContent || !isFulfilled(sourceTextContent) + ? 0 + : getSourceLineCount(sourceTextContent.value); + + const noLinesOutOfScope = + linesOutOfScope == null || linesOutOfScope.size == 0; + + // This operation can be very costly for large files so we sacrifice a bit of readability + // for performance sake. + // We initialize an array with a fixed size and we'll directly assign value for lines + // that are not out of scope. This is much faster than having an empty array and pushing + // into it. + const sourceLines = new Array(sourceNumLines); + for (let i = 0; i < sourceNumLines; i++) { + const line = i + 1; + if (noLinesOutOfScope || !linesOutOfScope.has(line)) { + sourceLines[i] = line; + } + } + + // Finally we need to remove any undefined values, i.e. the ones that were matching + // out of scope lines. + return sourceLines.filter(i => i != undefined); +} + +export function setInScopeLines(cx) { + return async thunkArgs => { + const { getState, dispatch } = thunkArgs; + const visibleFrame = getVisibleSelectedFrame(getState()); + + if (!visibleFrame) { + return; + } + + const { location } = visibleFrame; + const sourceTextContent = getSourceTextContent(getState(), location); + + if (hasInScopeLines(getState(), location) || !sourceTextContent) { + return; + } + + const lines = await getInScopeLines(cx, location, thunkArgs); + + dispatch({ + type: "IN_SCOPE_LINES", + cx, + location, + lines, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/ast/tests/__snapshots__/setInScopeLines.spec.js.snap b/devtools/client/debugger/src/actions/ast/tests/__snapshots__/setInScopeLines.spec.js.snap new file mode 100644 index 0000000000..1b9befc31b --- /dev/null +++ b/devtools/client/debugger/src/actions/ast/tests/__snapshots__/setInScopeLines.spec.js.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getInScopeLine with selected line 1`] = ` +Array [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 10, + 11, + 12, +] +`; diff --git a/devtools/client/debugger/src/actions/ast/tests/setInScopeLines.spec.js b/devtools/client/debugger/src/actions/ast/tests/setInScopeLines.spec.js new file mode 100644 index 0000000000..571dd84d6d --- /dev/null +++ b/devtools/client/debugger/src/actions/ast/tests/setInScopeLines.spec.js @@ -0,0 +1,79 @@ +/* eslint max-nested-callbacks: ["error", 6] */ +/* 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 readFixture from "../../tests/helpers/readFixture"; + +import { makeMockFrame, makeMockSource } from "../../../utils/test-mockup"; +import { + createStore, + selectors, + actions, + makeSource, + waitForState, +} from "../../../utils/test-head"; +import { createLocation } from "../../../utils/location"; + +const { getInScopeLines } = selectors; + +const sourceTexts = { + "scopes.js": readFixture("scopes.js"), +}; + +const mockCommandClient = { + sourceContents: async ({ source }) => ({ + source: sourceTexts[source], + contentType: "text/javascript", + }), + evaluateExpressions: async () => {}, + getFrameScopes: async () => {}, + getFrames: async () => [], + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], +}; + +describe("getInScopeLine", () => { + it("with selected line", async () => { + const client = { ...mockCommandClient }; + const store = createStore(client); + const { dispatch, getState } = store; + const source = makeMockSource("scopes.js", "scopes.js"); + const frame = makeMockFrame("scopes-4", source); + client.getFrames = async () => [frame]; + + const baseSource = await dispatch( + actions.newGeneratedSource(makeSource("scopes.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + baseSource.id + ); + + await dispatch( + actions.selectLocation( + selectors.getContext(getState()), + createLocation({ + source: baseSource, + sourceActor, + line: 5, + }) + ) + ); + + await dispatch( + actions.paused({ + thread: "FakeThread", + why: { type: "debuggerStatement" }, + frame, + }) + ); + await dispatch(actions.setInScopeLines(selectors.getContext(getState()))); + + await waitForState(store, state => getInScopeLines(state, frame.location)); + + const lines = getInScopeLines(getState(), frame.location); + + expect(lines).toMatchSnapshot(); + }); +}); 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..263b476364 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js @@ -0,0 +1,273 @@ +/* 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 { + 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"; +import { + debuggerToSourceMapLocation, + sourceMapToDebuggerLocation, + createLocation, +} from "../../utils/location"; + +async function mapLocations(generatedLocations, { getState, sourceMapLoader }) { + if (!generatedLocations.length) { + return []; + } + + const originalLocations = await sourceMapLoader.getOriginalLocations( + generatedLocations.map(debuggerToSourceMapLocation) + ); + return originalLocations.map((location, index) => ({ + // If location is null, this particular location doesn't map to any original source. + location: location + ? sourceMapToDebuggerLocation(getState(), location) + : generatedLocations[index], + 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 positions = []; + + for (const line in results) { + for (const column of results[line]) { + positions.push( + createLocation({ + line: Number(line), + column, + source, + sourceUrl: source.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, location, thunkArgs) { + const { client, dispatch, getState, sourceMapLoader } = thunkArgs; + const results = {}; + let generatedSource = location.source; + if (isOriginalId(location.sourceId)) { + const ranges = await sourceMapLoader.getGeneratedRangesForOriginal( + location.sourceId, + true + ); + const generatedSourceId = originalToGeneratedId(location.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(), generatedSourceId).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 { + const { line } = location; + if (typeof line !== "number") { + throw new Error("Line is required for generated sources"); + } + + const actorColumns = await Promise.all( + getSourceActorsForSource(getState(), location.sourceId).map( + async actor => { + const positions = await client.getSourceActorBreakpointPositions( + actor, + { + start: { line: 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, location.sourceId); + positions = filterByUniqLocation(positions); + positions = groupByLine(positions, location.sourceId, location.line); + + const source = getSource(getState(), location.sourceId); + // NOTE: it's possible that the source was removed during a navigation + 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<Object> + * 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: ({ location }, { getState }) => { + const positions = getBreakpointPositionsForSource( + getState(), + location.sourceId + ); + if (!positions) { + return null; + } + + if ( + isGeneratedId(location.sourceId) && + location.line && + !positions[location.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({ location }, { getState }) { + const key = generatedSourceActorKey(getState(), location.sourceId); + return isGeneratedId(location.sourceId) && location.line + ? `${key}-${location.line}` + : key; + }, + action: async ({ cx, location }, thunkArgs) => + _setBreakpointPositions(cx, location, 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..d188af05dc --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/index.js @@ -0,0 +1,426 @@ +/* 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/>. */ + +/** + * Redux actions for breakpoints + * @module actions/breakpoints + */ + +import { PROMISE } from "../utils/middleware/promise"; +import { asyncStore } from "../../utils/prefs"; +import { createLocation } from "../../utils/location"; +import { + getBreakpointsList, + getXHRBreakpoints, + getSelectedSource, + getBreakpointAtLocation, + getBreakpointsForSource, + getBreakpointsAtLine, +} from "../../selectors"; +import { createXHRBreakpoint } from "../../utils/breakpoint"; +import { + addBreakpoint, + removeBreakpoint, + enableBreakpoint, + disableBreakpoint, +} from "./modify"; +import { getOriginalLocation } from "../../utils/source-maps"; + +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 thunkArgs => { + const { dispatch, getState } = thunkArgs; + 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 getOriginalLocation( + breakpoint.generatedLocation, + thunkArgs + ); + 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, + createLocation({ + source: selectedSource, + 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 = createLocation({ + source, + 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..4576a61e27 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/modify.js @@ -0,0 +1,382 @@ +/* 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 { createBreakpoint } from "../../client/firefox/create"; +import { + makeBreakpointServerLocation, + makeBreakpointId, +} from "../../utils/breakpoint"; +import { + getBreakpoint, + getBreakpointPositionsForLocation, + getFirstBreakpointPosition, + getSettledSourceTextContent, + getBreakpointsList, + getPendingBreakpointList, + isMapScopesEnabled, + getBlackBoxRanges, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, +} 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, isLineBlackboxed } 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 breakpointServerLocation = makeBreakpointServerLocation( + getState(), + breakpoint.generatedLocation + ); + const shouldMapBreakpointExpressions = + isMapScopesEnabled(getState()) && + breakpoint.location.source.isOriginal && + (breakpoint.options.logValue || breakpoint.options.condition); + + if (shouldMapBreakpointExpressions) { + breakpoint = await dispatch(updateBreakpointSourceMapping(cx, breakpoint)); + } + return client.setBreakpoint(breakpointServerLocation, breakpoint.options); +} + +function clientRemoveBreakpoint(client, state, generatedLocation) { + const breakpointServerLocation = makeBreakpointServerLocation( + state, + generatedLocation + ); + return client.removeBreakpoint(breakpointServerLocation); +} + +export function enableBreakpoint(cx, initialBreakpoint) { + return thunkArgs => { + const { dispatch, getState, client } = thunkArgs; + const state = getState(); + const breakpoint = getBreakpoint(state, initialBreakpoint.location); + const blackboxedRanges = getBlackBoxRanges(state); + const isSourceOnIgnoreList = + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, breakpoint.location.source); + if ( + !breakpoint || + !breakpoint.disabled || + isLineBlackboxed( + blackboxedRanges[breakpoint.location.source.url], + breakpoint.location.line, + isSourceOnIgnoreList + ) + ) { + 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"); + + await dispatch( + setBreakpointPositions({ + cx, + location: initialLocation, + }) + ); + + const position = initialLocation.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; + + if (!location.source || !generatedLocation.source) { + return null; + } + + const originalContent = getSettledSourceTextContent(getState(), location); + const originalText = getTextAtPosition( + location.source.id, + originalContent, + location + ); + + const content = getSettledSourceTextContent(getState(), generatedLocation); + const text = getTextAtPosition( + generatedLocation.source.id, + content, + generatedLocation + ); + + const id = makeBreakpointId(location); + const breakpoint = createBreakpoint({ + id, + 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. + * + * @param {Object} target + * Location object where to remove breakpoints. + */ +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 pendingBreakpoint of pending) { + const { generatedLocation } = pendingBreakpoint; + if ( + generatedLocation.sourceUrl == target.sourceUrl && + comparePosition(generatedLocation, target) + ) { + dispatch({ + type: "REMOVE_PENDING_BREAKPOINT", + cx, + pendingBreakpoint, + }); + } + } + 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..b52c0ddfb1 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js @@ -0,0 +1,138 @@ +/* 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 { setBreakpointPositions } from "./breakpointPositions"; +import { + findPosition, + makeBreakpointServerLocation, +} 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 positions = await dispatch(setBreakpointPositions({ cx, location })); + + 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 syncPendingBreakpoint(cx, sourceId, pendingBreakpoint) { + return async thunkArgs => { + const { getState, client, dispatch } = thunkArgs; + + const source = getSource(getState(), sourceId); + + const generatedSourceId = isOriginalId(sourceId) + ? originalToGeneratedId(sourceId) + : sourceId; + + const generatedSource = getSource(getState(), generatedSourceId); + + if (!source || !generatedSource) { + return null; + } + + // /!\ Pending breakpoint locations come only with sourceUrl, line and column attributes. + // We have to map it to a specific source object and avoid trying to query its non-existent 'source' attribute. + const { location, generatedLocation } = pendingBreakpoint; + const isPendingBreakpointWithSourceMap = + location.sourceUrl != generatedLocation.sourceUrl; + const sourceGeneratedLocation = createLocation({ + ...generatedLocation, + source: generatedSource, + }); + + if (source == generatedSource && isPendingBreakpointWithSourceMap) { + // 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 breakpointServerLocation = makeBreakpointServerLocation( + getState(), + sourceGeneratedLocation + ); + return dispatch( + addBreakpoint( + cx, + sourceGeneratedLocation, + pendingBreakpoint.options, + pendingBreakpoint.disabled, + () => !client.hasBreakpoint(breakpointServerLocation) + ) + ); + } + + const originalLocation = createLocation({ + ...location, + source, + }); + + 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 (isPendingBreakpointWithSourceMap) { + 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..c18c3593d9 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap @@ -0,0 +1,173 @@ +// 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, + "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", + }, + "sourceActor": null, + "sourceActorId": undefined, + "sourceId": "a", + "sourceUrl": "http://localhost:8000/examples/a", + }, + "id": "a:2:1", + "location": Object { + "column": 1, + "line": 2, + "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", + }, + "sourceActor": null, + "sourceActorId": undefined, + "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 show a disabled breakpoint that does not have text 1`] = ` +Array [ + Object { + "breakpoints": Array [ + Object { + "disabled": true, + "generatedLocation": Object { + "column": 1, + "line": 5, + "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", + }, + "sourceActor": null, + "sourceActorId": undefined, + "sourceId": "a", + "sourceUrl": "http://localhost:8000/examples/a", + }, + "id": "a:5:1", + "location": Object { + "column": 1, + "line": 5, + "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", + }, + "sourceActor": null, + "sourceActorId": undefined, + "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..558d2400a8 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js @@ -0,0 +1,521 @@ +/* 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 { + createStore, + selectors, + actions, + makeSource, + getTelemetryEvents, +} from "../../../utils/test-head"; + +import { mockCommandClient } from "../../tests/helpers/mockCommandClient"; +import { createLocation } from "../../../utils/location"; + +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 source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + const loc1 = createLocation({ + source, + line: 2, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }); + await dispatch( + actions.selectLocation( + cx, + createLocation({ + source, + line: 1, + column: 1, + }) + ) + ); + + 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 source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + const loc1 = createLocation({ + source, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }); + await dispatch( + actions.selectLocation( + cx, + createLocation({ + source, + line: 1, + column: 1, + }) + ) + ); + + 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 source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + const loc1 = createLocation({ + source, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }); + await dispatch( + actions.selectLocation( + cx, + createLocation({ + source, + line: 1, + column: 1, + }) + ) + ); + + 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 source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + const loc1 = createLocation({ + source, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }); + await dispatch( + actions.selectLocation( + cx, + createLocation({ + source, + line: 1, + column: 1, + }) + ) + ); + + 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 aSource = await dispatch(actions.newGeneratedSource(makeSource("a"))); + + const bSource = await dispatch(actions.newGeneratedSource(makeSource("b"))); + + const loc1 = createLocation({ + source: aSource, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }); + + const loc2 = createLocation({ + source: bSource, + line: 6, + column: 2, + sourceUrl: "http://localhost:8000/examples/b", + }); + const bSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + bSource.id + ); + + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: bSourceActor, + }) + ); + + await dispatch( + actions.selectLocation( + cx, + createLocation({ + source: aSource, + line: 1, + column: 1, + }) + ) + ); + + 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 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, + }) + ); + + const loc1 = createLocation({ + source: aSource, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }); + + const loc2 = createLocation({ + source: bSource, + line: 6, + column: 2, + sourceUrl: "http://localhost:8000/examples/b", + }); + 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 aSource = await dispatch(actions.newGeneratedSource(makeSource("a"))); + const loc = createLocation({ + source: aSource, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/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 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, + }) + ); + + const loc1 = createLocation({ + source: aSource, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }); + + const loc2 = createLocation({ + source: bSource, + line: 6, + column: 2, + sourceUrl: "http://localhost:8000/examples/b", + }); + + 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 toggle a breakpoint at a location", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ 5: [1] })); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo1")) + ); + const loc = createLocation({ source, line: 5, column: 1 }); + const getBp = () => selectors.getBreakpoint(getState(), loc); + 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 { dispatch, getState, cx } = createStore(mockClient({ 5: [1] })); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo1")) + ); + const location = createLocation({ source, line: 5, column: 1 }); + const getBp = () => selectors.getBreakpoint(getState(), location); + await dispatch( + actions.selectLocation(cx, createLocation({ source, 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 source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + const loc = createLocation({ + source, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/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 source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + const loc = createLocation({ + source, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/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 remove the pretty-printed breakpoint that was added", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ 1: [0] })); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("a.js")) + ); + const loc = createLocation({ + source, + line: 1, + column: 0, + sourceUrl: "http://localhost:8000/examples/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); + }); +}); diff --git a/devtools/client/debugger/src/actions/event-listeners.js b/devtools/client/debugger/src/actions/event-listeners.js new file mode 100644 index 0000000000..9c59e930a7 --- /dev/null +++ b/devtools/client/debugger/src/actions/event-listeners.js @@ -0,0 +1,77 @@ +/* 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 { + getActiveEventListeners, + getEventListenerExpanded, + shouldLogEventBreakpoints, +} from "../selectors"; + +async function updateBreakpoints(dispatch, client, newEvents) { + await client.setEventListenerBreakpoints(newEvents); + dispatch({ type: "UPDATE_EVENT_LISTENERS", active: newEvents }); +} + +async function updateExpanded(dispatch, newExpanded) { + dispatch({ + type: "UPDATE_EVENT_LISTENER_EXPANDED", + expanded: newExpanded, + }); +} + +export function addEventListenerBreakpoints(eventsToAdd) { + return async ({ dispatch, client, getState }) => { + const activeListenerBreakpoints = await getActiveEventListeners(getState()); + + const newEvents = [ + ...new Set([...eventsToAdd, ...activeListenerBreakpoints]), + ]; + await updateBreakpoints(dispatch, client, newEvents); + }; +} + +export function removeEventListenerBreakpoints(eventsToRemove) { + return async ({ dispatch, client, getState }) => { + const activeListenerBreakpoints = await getActiveEventListeners(getState()); + + const newEvents = activeListenerBreakpoints.filter( + event => !eventsToRemove.includes(event) + ); + + await updateBreakpoints(dispatch, client, newEvents); + }; +} + +export function toggleEventLogging() { + return async ({ dispatch, getState, client }) => { + const logEventBreakpoints = !shouldLogEventBreakpoints(getState()); + await client.toggleEventLogging(logEventBreakpoints); + dispatch({ type: "TOGGLE_EVENT_LISTENERS", logEventBreakpoints }); + }; +} + +export function addEventListenerExpanded(category) { + return async ({ dispatch, getState }) => { + const expanded = await getEventListenerExpanded(getState()); + const newExpanded = [...new Set([...expanded, category])]; + await updateExpanded(dispatch, newExpanded); + }; +} + +export function removeEventListenerExpanded(category) { + return async ({ dispatch, getState }) => { + const expanded = await getEventListenerExpanded(getState()); + + const newExpanded = expanded.filter(expand => expand != category); + + updateExpanded(dispatch, newExpanded); + }; +} + +export function getEventListenerBreakpointTypes() { + return async ({ dispatch, client }) => { + const categories = await client.getEventListenerBreakpointTypes(); + dispatch({ type: "RECEIVE_EVENT_LISTENER_TYPES", categories }); + }; +} diff --git a/devtools/client/debugger/src/actions/exceptions.js b/devtools/client/debugger/src/actions/exceptions.js new file mode 100644 index 0000000000..f1746ec2bb --- /dev/null +++ b/devtools/client/debugger/src/actions/exceptions.js @@ -0,0 +1,30 @@ +/* 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/>. */ + +export function addExceptionFromResources(resources) { + return async function ({ dispatch }) { + for (const resource of resources) { + const { pageError } = resource; + if (!pageError.error) { + continue; + } + const { columnNumber, lineNumber, sourceId, errorMessage } = pageError; + const stacktrace = pageError.stacktrace || []; + + const exception = { + columnNumber, + lineNumber, + sourceActorId: sourceId, + errorMessage, + stacktrace, + threadActorId: resource.targetFront.targetForm.threadActor, + }; + + dispatch({ + type: "ADD_EXCEPTION", + exception, + }); + } + }; +} diff --git a/devtools/client/debugger/src/actions/expressions.js b/devtools/client/debugger/src/actions/expressions.js new file mode 100644 index 0000000000..e324038bfb --- /dev/null +++ b/devtools/client/debugger/src/actions/expressions.js @@ -0,0 +1,195 @@ +/* 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 { + getExpression, + getExpressions, + getSelectedFrame, + getSelectedFrameId, + getSelectedSource, + getSelectedScopeMappings, + getSelectedFrameBindings, + getCurrentThread, + getIsPaused, + isMapScopesEnabled, +} from "../selectors"; +import { PROMISE } from "./utils/middleware/promise"; +import { wrapExpression } from "../utils/expressions"; +import { features } from "../utils/prefs"; + +/** + * Add expression for debugger to watch + * + * @param {object} expression + * @param {number} expression.id + * @memberof actions/pause + * @static + */ +export function addExpression(cx, input) { + return async ({ dispatch, getState, parserWorker }) => { + if (!input) { + return null; + } + + const expressionError = await parserWorker.hasSyntaxError(input); + + const expression = getExpression(getState(), input); + if (expression) { + return dispatch(evaluateExpression(cx, expression)); + } + + dispatch({ type: "ADD_EXPRESSION", cx, input, expressionError }); + + const newExpression = getExpression(getState(), input); + if (newExpression) { + return dispatch(evaluateExpression(cx, newExpression)); + } + + return null; + }; +} + +export function autocomplete(cx, input, cursor) { + return async ({ dispatch, getState, client }) => { + if (!input) { + return; + } + const frameId = getSelectedFrameId(getState(), cx.thread); + const result = await client.autocomplete(input, cursor, frameId); + dispatch({ type: "AUTOCOMPLETE", cx, input, result }); + }; +} + +export function clearAutocomplete() { + return { type: "CLEAR_AUTOCOMPLETE" }; +} + +export function clearExpressionError() { + return { type: "CLEAR_EXPRESSION_ERROR" }; +} + +export function updateExpression(cx, input, expression) { + return async ({ dispatch, getState, parserWorker }) => { + if (!input) { + return; + } + + const expressionError = await parserWorker.hasSyntaxError(input); + dispatch({ + type: "UPDATE_EXPRESSION", + cx, + expression, + input: expressionError ? expression.input : input, + expressionError, + }); + + dispatch(evaluateExpressions(cx)); + }; +} + +/** + * + * @param {object} expression + * @param {number} expression.id + * @memberof actions/pause + * @static + */ +export function deleteExpression(expression) { + return ({ dispatch }) => { + dispatch({ + type: "DELETE_EXPRESSION", + input: expression.input, + }); + }; +} + +/** + * + * @memberof actions/pause + * @param {number} selectedFrameId + * @static + */ +export function evaluateExpressions(cx) { + return async function ({ dispatch, getState, client }) { + const expressions = getExpressions(getState()); + const inputs = expressions.map(({ input }) => input); + const frameId = getSelectedFrameId(getState(), cx.thread); + const results = await client.evaluateExpressions(inputs, { + frameId, + threadId: cx.thread, + }); + dispatch({ type: "EVALUATE_EXPRESSIONS", cx, inputs, results }); + }; +} + +function evaluateExpression(cx, expression) { + return async function ({ dispatch, getState, client }) { + if (!expression.input) { + console.warn("Expressions should not be empty"); + return null; + } + + let { input } = expression; + const frame = getSelectedFrame(getState(), cx.thread); + + if (frame) { + const selectedSource = getSelectedSource(getState()); + + if ( + selectedSource && + frame.location.source.isOriginal && + selectedSource.isOriginal + ) { + const mapResult = await dispatch(getMappedExpression(input)); + if (mapResult) { + input = mapResult.expression; + } + } + } + + const frameId = getSelectedFrameId(getState(), cx.thread); + + return dispatch({ + type: "EVALUATE_EXPRESSION", + cx, + thread: cx.thread, + input: expression.input, + [PROMISE]: client.evaluate(wrapExpression(input), { + frameId, + }), + }); + }; +} + +/** + * Gets information about original variable names from the source map + * and replaces all posible generated names. + */ +export function getMappedExpression(expression) { + return async function ({ dispatch, getState, parserWorker }) { + const thread = getCurrentThread(getState()); + const mappings = getSelectedScopeMappings(getState(), thread); + const bindings = getSelectedFrameBindings(getState(), thread); + + // We bail early if we do not need to map the expression. This is important + // because mapping an expression can be slow if the parserWorker + // worker is busy doing other work. + // + // 1. there are no mappings - we do not need to map original expressions + // 2. does not contain `await` - we do not need to map top level awaits + // 3. does not contain `=` - we do not need to map assignments + const shouldMapScopes = isMapScopesEnabled(getState()) && mappings; + if (!shouldMapScopes && !expression.match(/(await|=)/)) { + return null; + } + + return parserWorker.mapExpression( + expression, + mappings, + bindings || [], + features.mapExpressionBindings && getIsPaused(getState(), thread), + features.mapAwaitExpression + ); + }; +} diff --git a/devtools/client/debugger/src/actions/file-search.js b/devtools/client/debugger/src/actions/file-search.js new file mode 100644 index 0000000000..4ea2ea01bb --- /dev/null +++ b/devtools/client/debugger/src/actions/file-search.js @@ -0,0 +1,48 @@ +/* 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 { searchSourceForHighlight } from "../utils/editor"; + +import { getSelectedSourceTextContent, getSearchOptions } from "../selectors"; + +import { closeActiveSearch, clearHighlightLineRange } from "./ui"; + +export function doSearchForHighlight(query, editor, line, ch) { + return async ({ getState, dispatch }) => { + const sourceTextContent = getSelectedSourceTextContent(getState()); + if (!sourceTextContent) { + return; + } + + dispatch(searchContentsForHighlight(query, editor, line, ch)); + }; +} + +// Expose an action to the React component, so that it can call the searchWorker. +export function querySearchWorker(query, text, modifiers) { + return ({ searchWorker }) => { + return searchWorker.getMatches(query, text, modifiers); + }; +} + +export function searchContentsForHighlight(query, editor, line, ch) { + return async ({ getState, dispatch }) => { + const modifiers = getSearchOptions(getState(), "file-search"); + const sourceTextContent = getSelectedSourceTextContent(getState()); + + if (!query || !editor || !sourceTextContent || !modifiers) { + return; + } + + const ctx = { ed: editor, cm: editor.codeMirror }; + searchSourceForHighlight(ctx, false, query, true, modifiers, line, ch); + }; +} + +export function closeFileSearch(cx, editor) { + return ({ getState, dispatch }) => { + dispatch(closeActiveSearch()); + dispatch(clearHighlightLineRange()); + }; +} diff --git a/devtools/client/debugger/src/actions/index.js b/devtools/client/debugger/src/actions/index.js new file mode 100644 index 0000000000..ab6eec75f1 --- /dev/null +++ b/devtools/client/debugger/src/actions/index.js @@ -0,0 +1,48 @@ +/* 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 * as ast from "./ast"; +import * as breakpoints from "./breakpoints"; +import * as exceptions from "./exceptions"; +import * as expressions from "./expressions"; +import * as eventListeners from "./event-listeners"; +import * as pause from "./pause"; +import * as navigation from "./navigation"; +import * as ui from "./ui"; +import * as fileSearch from "./file-search"; +import * as projectTextSearch from "./project-text-search"; +import * as quickOpen from "./quick-open"; +import * as sourcesTree from "./sources-tree"; +import * as sources from "./sources"; +import * as sourcesActors from "./source-actors"; +import * as tabs from "./tabs"; +import * as threads from "./threads"; +import * as toolbox from "./toolbox"; +import * as preview from "./preview"; +import * as tracing from "./tracing"; + +import { objectInspector } from "devtools/client/shared/components/reps/index"; + +export default { + ...ast, + ...navigation, + ...breakpoints, + ...exceptions, + ...expressions, + ...eventListeners, + ...sources, + ...sourcesActors, + ...tabs, + ...pause, + ...ui, + ...fileSearch, + ...objectInspector.actions, + ...projectTextSearch, + ...quickOpen, + ...sourcesTree, + ...threads, + ...toolbox, + ...preview, + ...tracing, +}; diff --git a/devtools/client/debugger/src/actions/moz.build b/devtools/client/debugger/src/actions/moz.build new file mode 100644 index 0000000000..770fc61139 --- /dev/null +++ b/devtools/client/debugger/src/actions/moz.build @@ -0,0 +1,31 @@ +# 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 += [ + "ast", + "breakpoints", + "pause", + "sources", + "utils", +] + +CompiledModules( + "event-listeners.js", + "exceptions.js", + "expressions.js", + "file-search.js", + "index.js", + "navigation.js", + "preview.js", + "project-text-search.js", + "quick-open.js", + "source-actors.js", + "sources-tree.js", + "tabs.js", + "toolbox.js", + "tracing.js", + "threads.js", + "ui.js", +) diff --git a/devtools/client/debugger/src/actions/navigation.js b/devtools/client/debugger/src/actions/navigation.js new file mode 100644 index 0000000000..03d06a2baa --- /dev/null +++ b/devtools/client/debugger/src/actions/navigation.js @@ -0,0 +1,61 @@ +/* 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 { clearDocuments } from "../utils/editor"; +import sourceQueue from "../utils/source-queue"; + +import { clearWasmStates } from "../utils/wasm"; +import { getMainThread, getThreadContext } from "../selectors"; +import { evaluateExpressions } from "../actions/expressions"; + +/** + * Redux actions for the navigation state + * @module actions/navigation + */ + +/** + * @memberof actions/navigation + * @static + */ +export function willNavigate(event) { + return async function ({ + dispatch, + getState, + client, + sourceMapLoader, + parserWorker, + }) { + sourceQueue.clear(); + sourceMapLoader.clearSourceMaps(); + clearWasmStates(); + clearDocuments(); + parserWorker.clear(); + const thread = getMainThread(getState()); + + dispatch({ + type: "NAVIGATE", + mainThread: { ...thread, url: event.url }, + }); + }; +} + +/** + * @memberof actions/navigation + * @static + */ +export function navigated() { + return async function ({ getState, dispatch, panel }) { + try { + // Update the watched expressions once the page is fully loaded + const threadcx = getThreadContext(getState()); + await dispatch(evaluateExpressions(threadcx)); + } catch (e) { + // This may throw if we resume during the page load. + // browser_dbg-debugger-buttons.js highlights this, especially on MacOS or when ran many times + console.error("Failed to update expression on navigation", e); + } + + panel.emit("reloaded"); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/breakOnNext.js b/devtools/client/debugger/src/actions/pause/breakOnNext.js new file mode 100644 index 0000000000..02df827cb1 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/breakOnNext.js @@ -0,0 +1,18 @@ +/* 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/>. */ + +/** + * Debugger breakOnNext command. + * It's different from the comand action because we also want to + * highlight the pause icon. + * + * @memberof actions/pause + * @static + */ +export function breakOnNext(cx) { + return async ({ dispatch, getState, client }) => { + await client.breakOnNext(cx.thread); + return dispatch({ type: "BREAK_ON_NEXT", thread: cx.thread }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/commands.js b/devtools/client/debugger/src/actions/pause/commands.js new file mode 100644 index 0000000000..27478d6ad2 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/commands.js @@ -0,0 +1,157 @@ +/* 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 { + getSelectedFrame, + getThreadContext, + getCurrentThread, + getIsCurrentThreadPaused, +} from "../../selectors"; +import { PROMISE } from "../utils/middleware/promise"; +import { evaluateExpressions } from "../expressions"; +import { selectLocation } from "../sources"; +import { fetchScopes } from "./fetchScopes"; +import { fetchFrames } from "./fetchFrames"; +import { recordEvent } from "../../utils/telemetry"; +import assert from "../../utils/assert"; + +export function selectThread(cx, thread) { + return async ({ dispatch, getState, client }) => { + if (getCurrentThread(getState()) === thread) { + return; + } + + dispatch({ cx, type: "SELECT_THREAD", thread }); + + // Get a new context now that the current thread has changed. + const threadcx = getThreadContext(getState()); + // Note that this is a rethorical assertion as threadcx.thread is updated by SELECT_THREAD action + assert(threadcx.thread == thread, "Thread mismatch"); + + const serverRequests = []; + // Update the watched expressions as we may never have evaluated them against this thread + serverRequests.push(dispatch(evaluateExpressions(threadcx))); + + // If we were paused on the newly selected thread, ensure: + // - select the source where we are paused, + // - fetching the paused stackframes, + // - fetching the paused scope, so that variable preview are working on the selected source. + // (frames and scopes is supposed to be fetched on pause, + // but if two threads pause concurrently, it might be cancelled) + const frame = getSelectedFrame(getState(), thread); + if (frame) { + serverRequests.push(dispatch(selectLocation(threadcx, frame.location))); + serverRequests.push(dispatch(fetchFrames(threadcx))); + serverRequests.push(dispatch(fetchScopes(threadcx))); + } + + await Promise.all(serverRequests); + }; +} + +/** + * Debugger commands like stepOver, stepIn, stepUp + * + * @param string $0.type + * @memberof actions/pause + * @static + */ +export function command(type) { + return async ({ dispatch, getState, client }) => { + if (!type) { + return null; + } + // For now, all commands are by default against the currently selected thread + const thread = getCurrentThread(getState()); + + const frame = getSelectedFrame(getState(), thread); + + return dispatch({ + type: "COMMAND", + command: type, + thread, + [PROMISE]: client[type](thread, frame?.id), + }); + }; +} + +/** + * StepIn + * @memberof actions/pause + * @static + * @returns {Function} {@link command} + */ +export function stepIn() { + return ({ dispatch, getState }) => { + if (!getIsCurrentThreadPaused(getState())) { + return null; + } + return dispatch(command("stepIn")); + }; +} + +/** + * stepOver + * @memberof actions/pause + * @static + * @returns {Function} {@link command} + */ +export function stepOver() { + return ({ dispatch, getState }) => { + if (!getIsCurrentThreadPaused(getState())) { + return null; + } + return dispatch(command("stepOver")); + }; +} + +/** + * stepOut + * @memberof actions/pause + * @static + * @returns {Function} {@link command} + */ +export function stepOut() { + return ({ dispatch, getState }) => { + if (!getIsCurrentThreadPaused(getState())) { + return null; + } + return dispatch(command("stepOut")); + }; +} + +/** + * resume + * @memberof actions/pause + * @static + * @returns {Function} {@link command} + */ +export function resume() { + return ({ dispatch, getState }) => { + if (!getIsCurrentThreadPaused(getState())) { + return null; + } + recordEvent("continue"); + return dispatch(command("resume")); + }; +} + +/** + * restart frame + * @memberof actions/pause + * @static + */ +export function restart(cx, frame) { + return async ({ dispatch, getState, client }) => { + if (!getIsCurrentThreadPaused(getState())) { + return null; + } + return dispatch({ + type: "COMMAND", + command: "restart", + thread: cx.thread, + [PROMISE]: client.restart(cx.thread, frame.id), + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/continueToHere.js b/devtools/client/debugger/src/actions/pause/continueToHere.js new file mode 100644 index 0000000000..56aa117eab --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/continueToHere.js @@ -0,0 +1,62 @@ +/* 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 { + getSelectedSource, + getSelectedFrame, + getClosestBreakpointPosition, + getBreakpoint, +} from "../../selectors"; +import { createLocation } from "../../utils/location"; +import { addHiddenBreakpoint } from "../breakpoints"; +import { setBreakpointPositions } from "../breakpoints/breakpointPositions"; + +import { resume } from "./commands"; + +export function continueToHere(cx, location) { + return async function ({ dispatch, getState }) { + const { line, column } = location; + const selectedSource = getSelectedSource(getState()); + const selectedFrame = getSelectedFrame(getState(), cx.thread); + + if (!selectedFrame || !selectedSource) { + return; + } + + const debugLine = selectedFrame.location.line; + // If the user selects a line to continue to, + // it must be different than the currently paused line. + if (!column && debugLine == line) { + return; + } + + await dispatch(setBreakpointPositions({ cx, location })); + const position = getClosestBreakpointPosition(getState(), location); + + // If the user selects a location in the editor, + // there must be a place we can pause on that line. + if (column && !position) { + return; + } + + const pauseLocation = column && position ? position.location : location; + + // Set a hidden breakpoint if we do not already have a breakpoint + // at the closest position + if (!getBreakpoint(getState(), pauseLocation)) { + await dispatch( + addHiddenBreakpoint( + cx, + createLocation({ + source: selectedSource, + line: pauseLocation.line, + column: pauseLocation.column, + }) + ) + ); + } + + dispatch(resume(cx)); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/expandScopes.js b/devtools/client/debugger/src/actions/pause/expandScopes.js new file mode 100644 index 0000000000..fa431ee0b9 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/expandScopes.js @@ -0,0 +1,17 @@ +/* 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 { getScopeItemPath } from "../../utils/pause/scopes/utils"; + +export function setExpandedScope(cx, item, expanded) { + return function ({ dispatch, getState }) { + return dispatch({ + type: "SET_EXPANDED_SCOPE", + cx, + thread: cx.thread, + path: getScopeItemPath(item), + expanded, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/fetchFrames.js b/devtools/client/debugger/src/actions/pause/fetchFrames.js new file mode 100644 index 0000000000..42295ae026 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/fetchFrames.js @@ -0,0 +1,23 @@ +/* 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 { isValidThreadContext } from "../../utils/context"; + +export function fetchFrames(cx) { + return async function ({ dispatch, client, getState }) { + const { thread } = cx; + let frames; + try { + frames = await client.getFrames(thread); + } catch (e) { + // getFrames will fail if the thread has resumed. In this case the thread + // should no longer be valid and the frames we would have fetched would be + // discarded anyways. + if (isValidThreadContext(getState(), cx)) { + throw e; + } + } + dispatch({ type: "FETCHED_FRAMES", thread, frames, cx }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/fetchScopes.js b/devtools/client/debugger/src/actions/pause/fetchScopes.js new file mode 100644 index 0000000000..691b3ce006 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/fetchScopes.js @@ -0,0 +1,30 @@ +/* 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 { getSelectedFrame, getGeneratedFrameScope } from "../../selectors"; +import { mapScopes } from "./mapScopes"; +import { generateInlinePreview } from "./inlinePreview"; +import { PROMISE } from "../utils/middleware/promise"; + +export function fetchScopes(cx) { + return async function ({ dispatch, getState, client }) { + const frame = getSelectedFrame(getState(), cx.thread); + if (!frame || getGeneratedFrameScope(getState(), frame.id)) { + return; + } + + const scopes = dispatch({ + type: "ADD_SCOPES", + cx, + thread: cx.thread, + frame, + [PROMISE]: client.getFrameScopes(frame), + }); + + scopes.then(() => { + dispatch(generateInlinePreview(cx, frame)); + }); + await dispatch(mapScopes(cx, scopes, frame)); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/highlightCalls.js b/devtools/client/debugger/src/actions/pause/highlightCalls.js new file mode 100644 index 0000000000..aec82fe35b --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/highlightCalls.js @@ -0,0 +1,89 @@ +/* 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 { + getSymbols, + getSelectedFrame, + getCurrentThread, +} from "../../selectors"; + +// a is an ast location with start and end positions (line and column). +// b is a single position (line and column). +// This function tests to see if the b position +// falls within the range given in a. +function inHouseContainsPosition(a, b) { + const bColumn = b.column || 0; + const startsBefore = + a.start.line < b.line || + (a.start.line === b.line && a.start.column <= bColumn); + const endsAfter = + a.end.line > b.line || (a.end.line === b.line && a.end.column >= bColumn); + + return startsBefore && endsAfter; +} + +export function highlightCalls(cx) { + return async function ({ dispatch, getState, parserWorker }) { + if (!cx) { + return null; + } + + const frame = await getSelectedFrame( + getState(), + getCurrentThread(getState()) + ); + + if (!frame || !parserWorker.isLocationSupported(frame.location)) { + return null; + } + + const { thread } = cx; + + const originalAstScopes = await parserWorker.getScopes(frame.location); + if (!originalAstScopes) { + return null; + } + + const symbols = getSymbols(getState(), frame.location); + + if (!symbols) { + return null; + } + + if (!symbols.callExpressions) { + return null; + } + + const localAstScope = originalAstScopes[0]; + const allFunctionCalls = symbols.callExpressions; + + const highlightedCalls = allFunctionCalls.filter(function (call) { + const containsStart = inHouseContainsPosition( + localAstScope, + call.location.start + ); + const containsEnd = inHouseContainsPosition( + localAstScope, + call.location.end + ); + return containsStart && containsEnd; + }); + + return dispatch({ + type: "HIGHLIGHT_CALLS", + thread, + highlightedCalls, + }); + }; +} + +export function unhighlightCalls(cx) { + return async function ({ dispatch, getState }) { + const { thread } = cx; + return dispatch({ + type: "UNHIGHLIGHT_CALLS", + thread, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/index.js b/devtools/client/debugger/src/actions/pause/index.js new file mode 100644 index 0000000000..be31894019 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/index.js @@ -0,0 +1,33 @@ +/* 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/>. */ + +/** + * Redux actions for the pause state + * @module actions/pause + */ + +export { + selectThread, + stepIn, + stepOver, + stepOut, + resume, + restart, +} from "./commands"; +export { fetchFrames } from "./fetchFrames"; +export { fetchScopes } from "./fetchScopes"; +export { paused } from "./paused"; +export { resumed } from "./resumed"; +export { continueToHere } from "./continueToHere"; +export { breakOnNext } from "./breakOnNext"; +export { resetBreakpointsPaneState } from "./resetBreakpointsPaneState"; +export { mapFrames } from "./mapFrames"; +export { mapDisplayNames } from "./mapDisplayNames"; +export { pauseOnExceptions } from "./pauseOnExceptions"; +export { selectFrame } from "./selectFrame"; +export { toggleSkipPausing, setSkipPausing } from "./skipPausing"; +export { toggleMapScopes } from "./mapScopes"; +export { setExpandedScope } from "./expandScopes"; +export { generateInlinePreview } from "./inlinePreview"; +export { highlightCalls, unhighlightCalls } from "./highlightCalls"; diff --git a/devtools/client/debugger/src/actions/pause/inlinePreview.js b/devtools/client/debugger/src/actions/pause/inlinePreview.js new file mode 100644 index 0000000000..e3a4e614c0 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/inlinePreview.js @@ -0,0 +1,244 @@ +/* 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 { + getOriginalFrameScope, + getGeneratedFrameScope, + getInlinePreviews, + getSelectedLocation, +} from "../../selectors"; +import { features } from "../../utils/prefs"; +import { validateThreadContext } from "../../utils/context"; + +// We need to display all variables in the current functional scope so +// include all data for block scopes until the first functional scope +function getLocalScopeLevels(originalAstScopes) { + let levels = 0; + while ( + originalAstScopes[levels] && + originalAstScopes[levels].type === "block" + ) { + levels++; + } + return levels; +} + +export function generateInlinePreview(cx, frame) { + return async function ({ dispatch, getState, parserWorker, client }) { + if (!frame || !features.inlinePreview) { + return null; + } + + const { thread } = cx; + + // Avoid regenerating inline previews when we already have preview data + if (getInlinePreviews(getState(), thread, frame.id)) { + return null; + } + + const originalFrameScopes = getOriginalFrameScope( + getState(), + thread, + frame.location.sourceId, + frame.id + ); + + const generatedFrameScopes = getGeneratedFrameScope( + getState(), + thread, + frame.id + ); + + let scopes = originalFrameScopes?.scope || generatedFrameScopes?.scope; + + if (!scopes || !scopes.bindings) { + return null; + } + + // It's important to use selectedLocation, because we don't know + // if we'll be viewing the original or generated frame location + const selectedLocation = getSelectedLocation(getState()); + if (!selectedLocation) { + return null; + } + + if (!parserWorker.isLocationSupported(selectedLocation)) { + return null; + } + + const originalAstScopes = await parserWorker.getScopes(selectedLocation); + validateThreadContext(getState(), cx); + if (!originalAstScopes) { + return null; + } + + const allPreviews = []; + const pausedOnLine = selectedLocation.line; + const levels = getLocalScopeLevels(originalAstScopes); + + for ( + let curLevel = 0; + curLevel <= levels && scopes && scopes.bindings; + curLevel++ + ) { + const bindings = { ...scopes.bindings.variables }; + scopes.bindings.arguments.forEach(argument => { + Object.keys(argument).forEach(key => { + bindings[key] = argument[key]; + }); + }); + + const previewBindings = Object.keys(bindings).map(async name => { + // We want to show values of properties of objects only and not + // function calls on other data types like someArr.forEach etc.. + let properties = null; + const objectGrip = bindings[name].value; + if (objectGrip.actor && objectGrip.class === "Object") { + properties = await client.loadObjectProperties( + { + name, + path: name, + contents: { value: objectGrip }, + }, + cx.thread + ); + } + + const previewsFromBindings = getBindingValues( + originalAstScopes, + pausedOnLine, + name, + bindings[name].value, + curLevel, + properties + ); + + allPreviews.push(...previewsFromBindings); + }); + await Promise.all(previewBindings); + + scopes = scopes.parent; + } + + // Sort previews by line and column so they're displayed in the right order in the editor + allPreviews.sort((previewA, previewB) => { + if (previewA.line < previewB.line) { + return -1; + } + if (previewA.line > previewB.line) { + return 1; + } + // If we have the same line number + return previewA.column < previewB.column ? -1 : 1; + }); + + const previews = {}; + for (const preview of allPreviews) { + const { line } = preview; + if (!previews[line]) { + previews[line] = []; + } + previews[line].push(preview); + } + + return dispatch({ + type: "ADD_INLINE_PREVIEW", + thread, + frame, + previews, + }); + }; +} + +function getBindingValues( + originalAstScopes, + pausedOnLine, + name, + value, + curLevel, + properties +) { + const previews = []; + + const binding = originalAstScopes[curLevel]?.bindings[name]; + if (!binding) { + return previews; + } + + // Show a variable only once ( an object and it's child property are + // counted as different ) + const identifiers = new Set(); + + // We start from end as we want to show values besides variable + // located nearest to the breakpoint + for (let i = binding.refs.length - 1; i >= 0; i--) { + const ref = binding.refs[i]; + // Subtracting 1 from line as codemirror lines are 0 indexed + const line = ref.start.line - 1; + const column = ref.start.column; + // We don't want to render inline preview below the paused line + if (line >= pausedOnLine - 1) { + continue; + } + + const { displayName, displayValue } = getExpressionNameAndValue( + name, + value, + ref, + properties + ); + + // Variable with same name exists, display value of current or + // closest to the current scope's variable + if (identifiers.has(displayName)) { + continue; + } + identifiers.add(displayName); + + previews.push({ + line, + column, + name: displayName, + value: displayValue, + }); + } + return previews; +} + +function getExpressionNameAndValue( + name, + value, + // TODO: Add data type to ref + ref, + properties +) { + let displayName = name; + let displayValue = value; + + // Only variables of type Object will have properties + if (properties) { + let { meta } = ref; + // Presence of meta property means expression contains child property + // reference eg: objName.propName + while (meta) { + // Initially properties will be an array, after that it will be an object + if (displayValue === value) { + const property = properties.find(prop => prop.name === meta.property); + displayValue = property?.contents.value; + displayName += `.${meta.property}`; + } else if (displayValue?.preview?.ownProperties) { + const { ownProperties } = displayValue.preview; + Object.keys(ownProperties).forEach(prop => { + if (prop === meta.property) { + displayValue = ownProperties[prop].value; + displayName += `.${meta.property}`; + } + }); + } + meta = meta.parent; + } + } + + return { displayName, displayValue }; +} diff --git a/devtools/client/debugger/src/actions/pause/mapDisplayNames.js b/devtools/client/debugger/src/actions/pause/mapDisplayNames.js new file mode 100644 index 0000000000..a7abbc36bd --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/mapDisplayNames.js @@ -0,0 +1,49 @@ +/* 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 { getFrames, getSymbols } from "../../selectors"; + +import { findClosestFunction } from "../../utils/ast"; + +function mapDisplayName(frame, { getState }) { + if (frame.isOriginal) { + return frame; + } + + const symbols = getSymbols(getState(), frame.location); + + if (!symbols || !symbols.functions) { + return frame; + } + + const originalFunction = findClosestFunction(symbols, frame.location); + + if (!originalFunction) { + return frame; + } + + const originalDisplayName = originalFunction.name; + return { ...frame, originalDisplayName }; +} + +export function mapDisplayNames(cx) { + return function ({ dispatch, getState }) { + const frames = getFrames(getState(), cx.thread); + + if (!frames) { + return; + } + + const mappedFrames = frames.map(frame => + mapDisplayName(frame, { getState }) + ); + + dispatch({ + type: "MAP_FRAME_DISPLAY_NAMES", + cx, + thread: cx.thread, + frames: mappedFrames, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/mapFrames.js b/devtools/client/debugger/src/actions/pause/mapFrames.js new file mode 100644 index 0000000000..d677677505 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/mapFrames.js @@ -0,0 +1,157 @@ +/* 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 { + getFrames, + getBlackBoxRanges, + getSelectedFrame, +} from "../../selectors"; + +import { isFrameBlackBoxed } from "../../utils/source"; + +import assert from "../../utils/assert"; +import { getOriginalLocation } from "../../utils/source-maps"; +import { + debuggerToSourceMapLocation, + sourceMapToDebuggerLocation, +} from "../../utils/location"; +import { isGeneratedId } from "devtools/client/shared/source-map-loader/index"; + +function getSelectedFrameId(state, thread, frames) { + let selectedFrame = getSelectedFrame(state, thread); + const blackboxedRanges = getBlackBoxRanges(state); + + if (selectedFrame && !isFrameBlackBoxed(selectedFrame, blackboxedRanges)) { + return selectedFrame.id; + } + + selectedFrame = frames.find(frame => { + return !isFrameBlackBoxed(frame, blackboxedRanges); + }); + return selectedFrame?.id; +} + +async function updateFrameLocation(frame, thunkArgs) { + if (frame.isOriginal) { + return Promise.resolve(frame); + } + const location = await getOriginalLocation(frame.location, thunkArgs, true); + return { + ...frame, + location, + generatedLocation: frame.generatedLocation || frame.location, + }; +} + +function updateFrameLocations(frames, thunkArgs) { + if (!frames || !frames.length) { + return Promise.resolve(frames); + } + + return Promise.all( + frames.map(frame => updateFrameLocation(frame, thunkArgs)) + ); +} + +function isWasmOriginalSourceFrame(frame, getState) { + if (isGeneratedId(frame.location.sourceId)) { + return false; + } + + return Boolean(frame.generatedLocation?.source.isWasm); +} + +async function expandFrames(frames, { getState, sourceMapLoader }) { + const result = []; + for (let i = 0; i < frames.length; ++i) { + const frame = frames[i]; + if (frame.isOriginal || !isWasmOriginalSourceFrame(frame, getState)) { + result.push(frame); + continue; + } + const originalFrames = await sourceMapLoader.getOriginalStackFrames( + debuggerToSourceMapLocation(frame.generatedLocation) + ); + if (!originalFrames) { + result.push(frame); + continue; + } + + assert(!!originalFrames.length, "Expected at least one original frame"); + // First entry has not specific location -- use one from original frame. + originalFrames[0] = { + ...originalFrames[0], + location: frame.location, + }; + + originalFrames.forEach((originalFrame, j) => { + if (!originalFrame.location) { + return; + } + + // Keep outer most frame with true actor ID, and generate uniquie + // one for the nested frames. + const id = j == 0 ? frame.id : `${frame.id}-originalFrame${j}`; + result.push({ + id, + displayName: originalFrame.displayName, + location: sourceMapToDebuggerLocation( + getState(), + originalFrame.location + ), + index: frame.index, + source: null, + thread: frame.thread, + scope: frame.scope, + this: frame.this, + isOriginal: true, + // More fields that will be added by the mapDisplayNames and + // updateFrameLocation. + generatedLocation: frame.generatedLocation, + originalDisplayName: originalFrame.displayName, + originalVariables: originalFrame.variables, + asyncCause: frame.asyncCause, + state: frame.state, + }); + }); + } + return result; +} + +/** + * Map call stack frame locations and display names to originals. + * e.g. + * 1. When the debuggee pauses + * 2. When a source is pretty printed + * 3. When symbols are loaded + * @memberof actions/pause + * @static + */ +export function mapFrames(cx) { + return async function (thunkArgs) { + const { dispatch, getState } = thunkArgs; + const frames = getFrames(getState(), cx.thread); + if (!frames) { + return; + } + + let mappedFrames = await updateFrameLocations(frames, thunkArgs); + + mappedFrames = await expandFrames(mappedFrames, thunkArgs); + + const selectedFrameId = getSelectedFrameId( + getState(), + cx.thread, + mappedFrames + ); + + dispatch({ + type: "MAP_FRAMES", + cx, + thread: cx.thread, + frames: mappedFrames, + selectedFrameId, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/mapScopes.js b/devtools/client/debugger/src/actions/pause/mapScopes.js new file mode 100644 index 0000000000..2a352dc578 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/mapScopes.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/>. */ + +import { + getSelectedFrameId, + getSettledSourceTextContent, + isMapScopesEnabled, + getSelectedFrame, + getSelectedGeneratedScope, + getSelectedOriginalScope, + getThreadContext, + getFirstSourceActorForGeneratedSource, +} from "../../selectors"; +import { + loadOriginalSourceText, + loadGeneratedSourceText, +} from "../sources/loadSourceText"; +import { PROMISE } from "../utils/middleware/promise"; +import assert from "../../utils/assert"; + +import { log } from "../../utils/log"; +import { isGenerated } from "../../utils/source"; + +import { buildMappedScopes } from "../../utils/pause/mapScopes"; +import { isFulfilled } from "../../utils/async-value"; + +import { getMappedLocation } from "../../utils/source-maps"; + +const expressionRegex = /\bfp\(\)/g; + +export async function buildOriginalScopes( + frame, + client, + cx, + frameId, + generatedScopes +) { + if (!frame.originalVariables) { + throw new TypeError("(frame.originalVariables: XScopeVariables)"); + } + const originalVariables = frame.originalVariables; + const frameBase = originalVariables.frameBase || ""; + + const inputs = []; + for (let i = 0; i < originalVariables.vars.length; i++) { + const { expr } = originalVariables.vars[i]; + const expression = expr + ? expr.replace(expressionRegex, frameBase) + : "void 0"; + + inputs[i] = expression; + } + + const results = await client.evaluateExpressions(inputs, { + frameId, + }); + + const variables = {}; + for (let i = 0; i < originalVariables.vars.length; i++) { + const { name } = originalVariables.vars[i]; + variables[name] = { value: results[i].result }; + } + + const bindings = { + arguments: [], + variables, + }; + + const { actor } = await generatedScopes; + const scope = { + type: "function", + scopeKind: "", + actor, + bindings, + parent: null, + function: null, + block: null, + }; + return { + mappings: {}, + scope, + }; +} + +export function toggleMapScopes() { + return async function ({ dispatch, getState }) { + if (isMapScopesEnabled(getState())) { + dispatch({ type: "TOGGLE_MAP_SCOPES", mapScopes: false }); + return; + } + + dispatch({ type: "TOGGLE_MAP_SCOPES", mapScopes: true }); + + const cx = getThreadContext(getState()); + + if (getSelectedOriginalScope(getState(), cx.thread)) { + return; + } + + const scopes = getSelectedGeneratedScope(getState(), cx.thread); + const frame = getSelectedFrame(getState(), cx.thread); + if (!scopes || !frame) { + return; + } + + dispatch(mapScopes(cx, Promise.resolve(scopes.scope), frame)); + }; +} + +export function mapScopes(cx, scopes, frame) { + return async function (thunkArgs) { + const { dispatch, client, getState } = thunkArgs; + assert(cx.thread == frame.thread, "Thread mismatch"); + + await dispatch({ + type: "MAP_SCOPES", + cx, + thread: cx.thread, + frame, + [PROMISE]: (async function () { + if (frame.isOriginal && frame.originalVariables) { + const frameId = getSelectedFrameId(getState(), cx.thread); + return buildOriginalScopes(frame, client, cx, frameId, scopes); + } + + return dispatch(getMappedScopes(cx, scopes, frame)); + })(), + }); + }; +} + +export function getMappedScopes(cx, scopes, frame) { + return async function (thunkArgs) { + const { getState, dispatch } = thunkArgs; + const generatedSource = frame.generatedLocation.source; + + const source = frame.location.source; + + if ( + !isMapScopesEnabled(getState()) || + !source || + !generatedSource || + generatedSource.isWasm || + source.isPrettyPrinted || + isGenerated(source) + ) { + return null; + } + + // Load source text for the original source + await dispatch(loadOriginalSourceText({ cx, source })); + + const generatedSourceActor = getFirstSourceActorForGeneratedSource( + getState(), + generatedSource.id + ); + + // Also load source text for its corresponding generated source + await dispatch( + loadGeneratedSourceText({ + cx, + sourceActor: generatedSourceActor, + }) + ); + + try { + // load original source text content + const content = getSettledSourceTextContent(getState(), frame.location); + + return await buildMappedScopes( + source, + content && isFulfilled(content) + ? content.value + : { type: "text", value: "", contentType: undefined }, + frame, + await scopes, + thunkArgs + ); + } catch (e) { + log(e); + return null; + } + }; +} + +export function getMappedScopesForLocation(location) { + return async function (thunkArgs) { + const { dispatch, getState } = thunkArgs; + const cx = getThreadContext(getState()); + const mappedLocation = await getMappedLocation(location, thunkArgs); + return dispatch(getMappedScopes(cx, null, mappedLocation)); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/moz.build b/devtools/client/debugger/src/actions/pause/moz.build new file mode 100644 index 0000000000..54cf792166 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/moz.build @@ -0,0 +1,27 @@ +# 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( + "breakOnNext.js", + "commands.js", + "continueToHere.js", + "expandScopes.js", + "fetchFrames.js", + "fetchScopes.js", + "highlightCalls.js", + "index.js", + "inlinePreview.js", + "mapDisplayNames.js", + "mapFrames.js", + "mapScopes.js", + "paused.js", + "pauseOnExceptions.js", + "resetBreakpointsPaneState.js", + "resumed.js", + "selectFrame.js", + "skipPausing.js", +) diff --git a/devtools/client/debugger/src/actions/pause/pauseOnExceptions.js b/devtools/client/debugger/src/actions/pause/pauseOnExceptions.js new file mode 100644 index 0000000000..e7c04ded61 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/pauseOnExceptions.js @@ -0,0 +1,34 @@ +/* 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 { PROMISE } from "../utils/middleware/promise"; +import { recordEvent } from "../../utils/telemetry"; + +/** + * + * @memberof actions/pause + * @static + */ +export function pauseOnExceptions( + shouldPauseOnExceptions, + shouldPauseOnCaughtExceptions +) { + return ({ dispatch, getState, client }) => { + recordEvent("pause_on_exceptions", { + exceptions: shouldPauseOnExceptions, + // There's no "n" in the key below (#1463117) + ["caught_exceptio"]: shouldPauseOnCaughtExceptions, + }); + + return dispatch({ + type: "PAUSE_ON_EXCEPTIONS", + shouldPauseOnExceptions, + shouldPauseOnCaughtExceptions, + [PROMISE]: client.pauseOnExceptions( + shouldPauseOnExceptions, + shouldPauseOnCaughtExceptions + ), + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/paused.js b/devtools/client/debugger/src/actions/pause/paused.js new file mode 100644 index 0000000000..0e797035a5 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/paused.js @@ -0,0 +1,73 @@ +/* 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 { + getHiddenBreakpoint, + isEvaluatingExpression, + getSelectedFrame, + getThreadContext, +} from "../../selectors"; + +import { mapFrames, fetchFrames } from "."; +import { removeBreakpoint } from "../breakpoints"; +import { evaluateExpressions } from "../expressions"; +import { selectLocation } from "../sources"; +import assert from "../../utils/assert"; + +import { fetchScopes } from "./fetchScopes"; + +/** + * Debugger has just paused + * + * @param {object} pauseInfo + * @memberof actions/pause + * @static + */ +export function paused(pauseInfo) { + return async function ({ dispatch, getState }) { + const { thread, frame, why } = pauseInfo; + + dispatch({ type: "PAUSED", thread, why, frame }); + + // Get a context capturing the newly paused and selected thread. + const cx = getThreadContext(getState()); + // Note that this is a rethorical assertion as threadcx.thread is updated by PAUSED action + assert(cx.thread == thread, "Thread mismatch"); + + // When we use "continue to here" feature we register an "hidden" breakpoint + // that should be removed on the next paused, even if we didn't hit it and + // paused for any other reason. + const hiddenBreakpoint = getHiddenBreakpoint(getState()); + if (hiddenBreakpoint) { + dispatch(removeBreakpoint(cx, hiddenBreakpoint)); + } + + // The THREAD_STATE's "paused" resource only passes the top level stack frame, + // we dispatch the PAUSED action with it so that we can right away + // display it and update the UI to be paused. + // But we then fetch all the other frames: + await dispatch(fetchFrames(cx)); + // And map them to original source locations. + // Note that this will wait for all related original sources to be loaded in the reducers. + // So this step may pause for a little while. + await dispatch(mapFrames(cx)); + + // If we paused on a particular frame, automatically select the related source + // and highlight the paused line + const selectedFrame = getSelectedFrame(getState(), thread); + if (selectedFrame) { + await dispatch(selectLocation(cx, selectedFrame.location)); + } + + // Fetch the previews for variables visible in the currently selected paused stackframe + await dispatch(fetchScopes(cx)); + + // Run after fetching scoping data so that it may make use of the sourcemap + // expression mappings for local variables. + const atException = why.type == "exception"; + if (!atException || !isEvaluatingExpression(getState(), thread)) { + await dispatch(evaluateExpressions(cx)); + } + }; +} diff --git a/devtools/client/debugger/src/actions/pause/resetBreakpointsPaneState.js b/devtools/client/debugger/src/actions/pause/resetBreakpointsPaneState.js new file mode 100644 index 0000000000..a602c58896 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/resetBreakpointsPaneState.js @@ -0,0 +1,18 @@ +/* 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/>. */ + +/** + * Action for the breakpoints panel while paused. + * + * @memberof actions/pause + * @static + */ +export function resetBreakpointsPaneState(thread) { + return async ({ dispatch }) => { + dispatch({ + type: "RESET_BREAKPOINTS_PANE_STATE", + thread, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/resumed.js b/devtools/client/debugger/src/actions/pause/resumed.js new file mode 100644 index 0000000000..323e9f0ff8 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/resumed.js @@ -0,0 +1,28 @@ +/* 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 { isStepping, getPauseReason, getThreadContext } from "../../selectors"; +import { evaluateExpressions } from "../expressions"; +import { inDebuggerEval } from "../../utils/pause"; + +/** + * Debugger has just resumed + * + * @memberof actions/pause + * @static + */ +export function resumed(thread) { + return async ({ dispatch, client, getState }) => { + const why = getPauseReason(getState(), thread); + const wasPausedInEval = inDebuggerEval(why); + const wasStepping = isStepping(getState(), thread); + + dispatch({ type: "RESUME", thread, wasStepping }); + + const cx = getThreadContext(getState()); + if (!wasStepping && !wasPausedInEval && cx.thread == thread) { + await dispatch(evaluateExpressions(cx)); + } + }; +} diff --git a/devtools/client/debugger/src/actions/pause/selectFrame.js b/devtools/client/debugger/src/actions/pause/selectFrame.js new file mode 100644 index 0000000000..f97be42787 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/selectFrame.js @@ -0,0 +1,39 @@ +/* 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 { selectLocation } from "../sources"; +import { evaluateExpressions } from "../expressions"; +import { fetchScopes } from "./fetchScopes"; +import assert from "../../utils/assert"; + +/** + * @memberof actions/pause + * @static + */ +export function selectFrame(cx, frame) { + return async ({ dispatch, getState }) => { + assert(cx.thread == frame.thread, "Thread mismatch"); + + // Frames that aren't on-stack do not support evalling and may not + // have live inspectable scopes, so we do not allow selecting them. + if (frame.state !== "on-stack") { + dispatch(selectLocation(cx, frame.location)); + return; + } + + dispatch({ + type: "SELECT_FRAME", + cx, + thread: cx.thread, + frame, + }); + + // It's important that we wait for selectLocation to finish because + // we rely on the source being loaded and symbols fetched below. + await dispatch(selectLocation(cx, frame.location)); + + dispatch(evaluateExpressions(cx)); + dispatch(fetchScopes(cx)); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/skipPausing.js b/devtools/client/debugger/src/actions/pause/skipPausing.js new file mode 100644 index 0000000000..1ecdf33b76 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/skipPausing.js @@ -0,0 +1,33 @@ +/* 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 { getSkipPausing } from "../../selectors"; + +/** + * @memberof actions/pause + * @static + */ +export function toggleSkipPausing() { + return async ({ dispatch, client, getState }) => { + const skipPausing = !getSkipPausing(getState()); + await client.setSkipPausing(skipPausing); + dispatch({ type: "TOGGLE_SKIP_PAUSING", skipPausing }); + }; +} + +/** + * @memberof actions/pause + * @static + */ +export function setSkipPausing(skipPausing) { + return async ({ dispatch, client, getState }) => { + const currentlySkipping = getSkipPausing(getState()); + if (currentlySkipping === skipPausing) { + return; + } + + await client.setSkipPausing(skipPausing); + dispatch({ type: "TOGGLE_SKIP_PAUSING", skipPausing }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/tests/__snapshots__/pauseOnExceptions.spec.js.snap b/devtools/client/debugger/src/actions/pause/tests/__snapshots__/pauseOnExceptions.spec.js.snap new file mode 100644 index 0000000000..55b8d3e724 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/tests/__snapshots__/pauseOnExceptions.spec.js.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`pauseOnExceptions should track telemetry for pauseOnException changes 1`] = ` +Array [ + Object { + "caught_exceptio": false, + "exceptions": true, + }, +] +`; diff --git a/devtools/client/debugger/src/actions/pause/tests/pause.spec.js b/devtools/client/debugger/src/actions/pause/tests/pause.spec.js new file mode 100644 index 0000000000..3a562ccfdd --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/tests/pause.spec.js @@ -0,0 +1,413 @@ +/* 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, + createSourceObject, + waitForState, + makeSource, + makeOriginalSource, + makeFrame, +} from "../../../utils/test-head"; + +import { makeWhyNormal } from "../../../utils/test-mockup"; +import { createLocation } from "../../../utils/location"; + +const { isStepping } = selectors; + +let stepInResolve = null; +const mockCommandClient = { + stepIn: () => + new Promise(_resolve => { + stepInResolve = _resolve; + }), + stepOver: () => new Promise(_resolve => _resolve), + evaluate: async () => {}, + evaluateExpressions: async () => [], + resume: async () => {}, + getFrameScopes: async frame => frame.scope, + getFrames: async () => [], + setBreakpoint: () => new Promise(_resolve => {}), + sourceContents: ({ source }) => { + return new Promise((resolve, reject) => { + switch (source) { + case "foo1": + return resolve({ + source: "function foo1() {\n return 5;\n}", + contentType: "text/javascript", + }); + case "await": + return resolve({ + source: "async function aWait() {\n await foo(); return 5;\n}", + contentType: "text/javascript", + }); + + case "foo": + return resolve({ + source: "function foo() {\n return -5;\n}", + contentType: "text/javascript", + }); + case "foo-original": + return resolve({ + source: "\n\nfunction fooOriginal() {\n return -5;\n}", + contentType: "text/javascript", + }); + case "foo-wasm": + return resolve({ + source: { binary: new ArrayBuffer(0) }, + contentType: "application/wasm", + }); + case "foo-wasm/originalSource": + return resolve({ + source: "fn fooBar() {}\nfn barZoo() { fooBar() }", + contentType: "text/rust", + }); + } + + return resolve(); + }); + }, + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], + actorID: "threadActorID", +}; + +const mockFrameId = "1"; + +function createPauseInfo( + frameLocation = createLocation({ + source: createSourceObject("foo1"), + line: 2, + }), + frameOpts = {} +) { + const frames = [ + makeFrame( + { id: mockFrameId, sourceId: frameLocation.sourceId }, + { + location: frameLocation, + generatedLocation: frameLocation, + ...frameOpts, + } + ), + ]; + return { + thread: "FakeThread", + frame: frames[0], + frames, + loadedObjects: [], + why: makeWhyNormal(), + }; +} + +describe("pause", () => { + describe("stepping", () => { + it("should set and clear the command", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + const mockPauseInfo = createPauseInfo(); + + await dispatch(actions.newGeneratedSource(makeSource("foo1"))); + await dispatch(actions.paused(mockPauseInfo)); + const cx = selectors.getThreadContext(getState()); + const stepped = dispatch(actions.stepIn(cx)); + expect(isStepping(getState(), "FakeThread")).toBeTruthy(); + if (!stepInResolve) { + throw new Error("no stepInResolve"); + } + await stepInResolve(); + await stepped; + expect(isStepping(getState(), "FakeThread")).toBeFalsy(); + }); + + it("should only step when paused", async () => { + const client = { stepIn: jest.fn() }; + const { dispatch, cx } = createStore(client); + + dispatch(actions.stepIn(cx)); + expect(client.stepIn.mock.calls).toHaveLength(0); + }); + + it("should step when paused", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + const mockPauseInfo = createPauseInfo(); + + await dispatch(actions.newGeneratedSource(makeSource("foo1"))); + await dispatch(actions.paused(mockPauseInfo)); + const cx = selectors.getThreadContext(getState()); + dispatch(actions.stepIn(cx)); + expect(isStepping(getState(), "FakeThread")).toBeTruthy(); + }); + + it("getting frame scopes with bindings", async () => { + const client = { ...mockCommandClient }; + const store = createStore(client, {}); + const { dispatch, getState } = store; + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + const generatedLocation = createLocation({ + source, + line: 1, + column: 0, + sourceActor: selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ), + }); + const mockPauseInfo = createPauseInfo(generatedLocation, { + scope: { + bindings: { + variables: { b: { value: {} } }, + arguments: [{ a: { value: {} } }], + }, + }, + }); + + const { frames } = mockPauseInfo; + client.getFrames = async () => frames; + await dispatch(actions.newOriginalSources([makeOriginalSource(source)])); + + await dispatch(actions.paused(mockPauseInfo)); + expect(selectors.getFrames(getState(), "FakeThread")).toEqual([ + { + id: mockFrameId, + generatedLocation, + location: generatedLocation, + originalDisplayName: "foo", + scope: { + bindings: { + arguments: [{ a: { value: {} } }], + variables: { b: { value: {} } }, + }, + }, + thread: "FakeThread", + }, + ]); + + expect(selectors.getFrameScopes(getState(), "FakeThread")).toEqual({ + generated: { + 1: { + pending: false, + scope: { + bindings: { + arguments: [{ a: { value: {} } }], + variables: { b: { value: {} } }, + }, + }, + }, + }, + mappings: { 1: undefined }, + original: { 1: { pending: false, scope: undefined } }, + }); + + expect( + selectors.getSelectedFrameBindings(getState(), "FakeThread") + ).toEqual(["b", "a"]); + }); + + it("maps frame locations and names to original source", async () => { + const sourceMapLoaderMock = { + getOriginalLocation: () => Promise.resolve(originalLocation), + getOriginalLocations: async items => items, + getOriginalSourceText: async () => ({ + text: "\n\nfunction fooOriginal() {\n return -5;\n}", + contentType: "text/javascript", + }), + getGeneratedLocation: async location => location, + }; + + const client = { ...mockCommandClient }; + const store = createStore(client, {}, sourceMapLoaderMock); + const { dispatch, getState } = store; + + const originalSource = await dispatch( + actions.newGeneratedSource(makeSource("foo-original")) + ); + + const originalLocation = createLocation({ + source: originalSource, + line: 3, + column: 0, + sourceActor: selectors.getFirstSourceActorForGeneratedSource( + getState(), + originalSource.id + ), + }); + + const generatedSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + const generatedLocation = createLocation({ + source: generatedSource, + line: 1, + column: 0, + sourceActor: selectors.getFirstSourceActorForGeneratedSource( + getState(), + generatedSource.id + ), + }); + const mockPauseInfo = createPauseInfo(generatedLocation); + + const { frames } = mockPauseInfo; + client.getFrames = async () => frames; + + await dispatch(actions.paused(mockPauseInfo)); + expect(selectors.getFrames(getState(), "FakeThread")).toEqual([ + { + id: mockFrameId, + generatedLocation, + location: originalLocation, + originalDisplayName: "fooOriginal", + scope: { bindings: { arguments: [], variables: {} } }, + thread: "FakeThread", + }, + ]); + }); + + it("maps frame to original frames", async () => { + const sourceMapLoaderMock = { + getOriginalStackFrames: loc => Promise.resolve(originStackFrames), + getOriginalLocation: () => Promise.resolve(originalLocation), + getOriginalLocations: async items => items, + getOriginalSourceText: async () => ({ + text: "fn fooBar() {}\nfn barZoo() { fooBar() }", + contentType: "text/rust", + }), + getGeneratedRangesForOriginal: async () => [], + }; + + const client = { ...mockCommandClient }; + const store = createStore(client, {}, sourceMapLoaderMock); + const { dispatch, getState } = store; + + const generatedSource = await dispatch( + actions.newGeneratedSource( + makeSource("foo-wasm", { introductionType: "wasm" }) + ) + ); + + const generatedLocation = createLocation({ + source: generatedSource, + line: 1, + column: 0, + sourceActor: selectors.getFirstSourceActorForGeneratedSource( + getState(), + generatedSource.id + ), + }); + const mockPauseInfo = createPauseInfo(generatedLocation); + const { frames } = mockPauseInfo; + client.getFrames = async () => frames; + + const [originalSource] = await dispatch( + actions.newOriginalSources([makeOriginalSource(generatedSource)]) + ); + + const originalLocation = createLocation({ + source: originalSource, + line: 1, + column: 1, + sourceActor: selectors.getFirstSourceActorForGeneratedSource( + getState(), + originalSource.id + ), + }); + const originalLocation2 = createLocation({ + source: originalSource, + line: 2, + column: 14, + sourceActor: selectors.getFirstSourceActorForGeneratedSource( + getState(), + originalSource.id + ), + }); + + const originStackFrames = [ + { + displayName: "fooBar", + thread: "FakeThread", + }, + { + displayName: "barZoo", + location: originalLocation2, + thread: "FakeThread", + }, + ]; + + await dispatch(actions.paused(mockPauseInfo)); + expect(selectors.getFrames(getState(), "FakeThread")).toEqual([ + { + asyncCause: undefined, + displayName: "fooBar", + generatedLocation, + id: "1", + index: undefined, + isOriginal: true, + location: originalLocation, + originalDisplayName: "fooBar", + originalVariables: undefined, + scope: { bindings: { arguments: [], variables: {} } }, + source: null, + state: undefined, + this: undefined, + thread: "FakeThread", + }, + { + asyncCause: undefined, + displayName: "barZoo", + generatedLocation, + id: "1-originalFrame1", + index: undefined, + isOriginal: true, + location: originalLocation2, + originalDisplayName: "barZoo", + originalVariables: undefined, + scope: { bindings: { arguments: [], variables: {} } }, + source: null, + state: undefined, + this: undefined, + thread: "FakeThread", + }, + ]); + }); + }); + + describe("resumed", () => { + it("should not evaluate expression while stepping", async () => { + const client = { ...mockCommandClient, evaluateExpressions: jest.fn() }; + const { dispatch, getState } = createStore(client); + const mockPauseInfo = createPauseInfo(); + + await dispatch(actions.newGeneratedSource(makeSource("foo1"))); + await dispatch(actions.paused(mockPauseInfo)); + + const cx = selectors.getThreadContext(getState()); + dispatch(actions.stepIn(cx)); + await dispatch(actions.resumed(mockCommandClient.actorID)); + expect(client.evaluateExpressions.mock.calls).toHaveLength(1); + }); + + it("resuming - will re-evaluate watch expressions", async () => { + const client = { ...mockCommandClient, evaluateExpressions: jest.fn() }; + const store = createStore(client); + const { dispatch, getState, cx } = store; + const mockPauseInfo = createPauseInfo(); + + await dispatch(actions.newGeneratedSource(makeSource("foo1"))); + await dispatch(actions.newGeneratedSource(makeSource("foo"))); + await dispatch(actions.addExpression(cx, "foo")); + await waitForState(store, state => selectors.getExpression(state, "foo")); + + client.evaluateExpressions.mockReturnValue(Promise.resolve(["YAY"])); + await dispatch(actions.paused(mockPauseInfo)); + + await dispatch(actions.resumed(mockCommandClient.actorID)); + const expression = selectors.getExpression(getState(), "foo"); + expect(expression && expression.value).toEqual("YAY"); + }); + }); +}); diff --git a/devtools/client/debugger/src/actions/pause/tests/pauseOnExceptions.spec.js b/devtools/client/debugger/src/actions/pause/tests/pauseOnExceptions.spec.js new file mode 100644 index 0000000000..bc8d000697 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/tests/pauseOnExceptions.spec.js @@ -0,0 +1,24 @@ +/* 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, + createStore, + getTelemetryEvents, +} from "../../../utils/test-head"; + +import { + getShouldPauseOnExceptions, + getShouldPauseOnCaughtExceptions, +} from "../../../selectors/pause"; + +describe("pauseOnExceptions", () => { + it("should track telemetry for pauseOnException changes", async () => { + const { dispatch, getState } = createStore({ pauseOnExceptions: () => {} }); + dispatch(actions.pauseOnExceptions(true, false)); + expect(getTelemetryEvents("pause_on_exceptions")).toMatchSnapshot(); + expect(getShouldPauseOnExceptions(getState())).toBe(true); + expect(getShouldPauseOnCaughtExceptions(getState())).toBe(false); + }); +}); diff --git a/devtools/client/debugger/src/actions/pause/tests/skipPausing.spec.js b/devtools/client/debugger/src/actions/pause/tests/skipPausing.spec.js new file mode 100644 index 0000000000..83006c3089 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/tests/skipPausing.spec.js @@ -0,0 +1,18 @@ +/* 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 } from "../../../utils/test-head"; + +describe("sources - pretty print", () => { + it("returns a pretty source for a minified file", async () => { + const client = { setSkipPausing: jest.fn() }; + const { dispatch, getState } = createStore(client); + + await dispatch(actions.toggleSkipPausing()); + expect(selectors.getSkipPausing(getState())).toBe(true); + + await dispatch(actions.toggleSkipPausing()); + expect(selectors.getSkipPausing(getState())).toBe(false); + }); +}); diff --git a/devtools/client/debugger/src/actions/preview.js b/devtools/client/debugger/src/actions/preview.js new file mode 100644 index 0000000000..992737e2d1 --- /dev/null +++ b/devtools/client/debugger/src/actions/preview.js @@ -0,0 +1,211 @@ +/* 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 { isConsole } from "../utils/preview"; +import { findBestMatchExpression } from "../utils/ast"; +import { getGrip, getFront } from "../utils/evaluation-result"; +import { getExpressionFromCoords } from "../utils/editor/get-expression"; +import { isNodeTest } from "../utils/environment"; + +import { + getPreview, + isLineInScope, + isSelectedFrameVisible, + getSelectedSource, + getSelectedLocation, + getSelectedFrame, + getSymbols, + getCurrentThread, + getPreviewCount, + getSelectedException, +} from "../selectors"; + +import { getMappedExpression } from "./expressions"; + +function findExpressionMatch(state, codeMirror, tokenPos) { + const location = getSelectedLocation(state); + if (!location) { + return null; + } + + const symbols = getSymbols(state, location); + + let match; + if (!symbols) { + match = getExpressionFromCoords(codeMirror, tokenPos); + } else { + match = findBestMatchExpression(symbols, tokenPos); + } + return match; +} + +export function updatePreview(cx, target, tokenPos, codeMirror) { + return ({ dispatch, getState }) => { + const cursorPos = target.getBoundingClientRect(); + + if ( + !isSelectedFrameVisible(getState()) || + !isLineInScope(getState(), tokenPos.line) + ) { + return; + } + + const match = findExpressionMatch(getState(), codeMirror, tokenPos); + if (!match) { + return; + } + + const { expression, location } = match; + + if (isConsole(expression)) { + return; + } + + dispatch(setPreview(cx, expression, location, tokenPos, cursorPos, target)); + }; +} + +export function setPreview( + cx, + expression, + location, + tokenPos, + cursorPos, + target +) { + return async ({ dispatch, getState, client }) => { + dispatch({ type: "START_PREVIEW" }); + const previewCount = getPreviewCount(getState()); + if (getPreview(getState())) { + dispatch(clearPreview(cx)); + } + + const source = getSelectedSource(getState()); + if (!source) { + return; + } + + const thread = getCurrentThread(getState()); + const selectedFrame = getSelectedFrame(getState(), thread); + + if (location && source.isOriginal) { + const mapResult = await dispatch(getMappedExpression(expression)); + if (mapResult) { + expression = mapResult.expression; + } + } + + if (!selectedFrame) { + return; + } + + const { result } = await client.evaluate(expression, { + frameId: selectedFrame.id, + }); + + const resultGrip = getGrip(result); + + // Error case occurs for a token that follows an errored evaluation + // https://github.com/firefox-devtools/debugger/pull/8056 + // Accommodating for null allows us to show preview for falsy values + // line "", false, null, Nan, and more + if (resultGrip === null) { + return; + } + + // Handle cases where the result is invisible to the debugger + // and not possible to preview. Bug 1548256 + if ( + resultGrip && + resultGrip.class && + typeof resultGrip.class === "string" && + resultGrip.class.includes("InvisibleToDebugger") + ) { + return; + } + + const root = { + path: expression, + contents: { + value: resultGrip, + front: getFront(result), + }, + }; + const properties = await client.loadObjectProperties(root, thread); + + // The first time a popup is rendered, the mouse should be hovered + // on the token. If it happens to be hovered on whitespace, it should + // not render anything + if (!target.matches(":hover") && !isNodeTest()) { + return; + } + + // Don't finish dispatching if another setPreview was started + if (previewCount != getPreviewCount(getState())) { + return; + } + + dispatch({ + type: "SET_PREVIEW", + cx, + value: { + expression, + resultGrip, + properties, + root, + location, + tokenPos, + cursorPos, + target, + }, + }); + }; +} + +export function clearPreview(cx) { + return ({ dispatch, getState, client }) => { + const currentSelection = getPreview(getState()); + if (!currentSelection) { + return null; + } + + return dispatch({ + type: "CLEAR_PREVIEW", + cx, + }); + }; +} + +export function setExceptionPreview(cx, target, tokenPos, codeMirror) { + return async ({ dispatch, getState }) => { + const cursorPos = target.getBoundingClientRect(); + + const match = findExpressionMatch(getState(), codeMirror, tokenPos); + if (!match) { + return; + } + + const tokenColumnStart = match.location.start.column + 1; + const exception = getSelectedException( + getState(), + tokenPos.line, + tokenColumnStart + ); + if (!exception) { + return; + } + + dispatch({ + type: "SET_PREVIEW", + cx, + value: { + exception, + location: match.location, + tokenPos, + cursorPos, + target, + }, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/project-text-search.js b/devtools/client/debugger/src/actions/project-text-search.js new file mode 100644 index 0000000000..26ea0df107 --- /dev/null +++ b/devtools/client/debugger/src/actions/project-text-search.js @@ -0,0 +1,171 @@ +/* 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/>. */ + +/** + * Redux actions for the search state + * @module actions/search + */ + +import { isFulfilled } from "../utils/async-value"; +import { + getFirstSourceActorForGeneratedSource, + getSourceList, + getSettledSourceTextContent, + isSourceBlackBoxed, + getSearchOptions, +} from "../selectors"; +import { createLocation } from "../utils/location"; +import { matchesGlobPatterns } from "../utils/source"; +import { loadSourceText } from "./sources/loadSourceText"; +import { + getProjectSearchOperation, + getProjectSearchStatus, +} from "../selectors/project-text-search"; +import { statusType } from "../reducers/project-text-search"; +import { searchKeys } from "../constants"; + +export function addSearchQuery(cx, query) { + return { type: "ADD_QUERY", cx, query }; +} + +export function addOngoingSearch(cx, ongoingSearch) { + return { type: "ADD_ONGOING_SEARCH", cx, ongoingSearch }; +} + +export function addSearchResult(cx, location, matches) { + return { + type: "ADD_SEARCH_RESULT", + cx, + location, + matches, + }; +} + +export function clearSearchResults(cx) { + return { type: "CLEAR_SEARCH_RESULTS", cx }; +} + +export function clearSearch(cx) { + return { type: "CLEAR_SEARCH", cx }; +} + +export function updateSearchStatus(cx, status) { + return { type: "UPDATE_STATUS", cx, status }; +} + +export function closeProjectSearch(cx) { + return ({ dispatch, getState }) => { + dispatch(stopOngoingSearch(cx)); + dispatch({ type: "CLOSE_PROJECT_SEARCH" }); + }; +} + +export function stopOngoingSearch(cx) { + return ({ dispatch, getState }) => { + const state = getState(); + const ongoingSearch = getProjectSearchOperation(state); + const status = getProjectSearchStatus(state); + if (ongoingSearch && status !== statusType.done) { + ongoingSearch.cancel(); + dispatch(updateSearchStatus(cx, statusType.cancelled)); + } + }; +} + +export function searchSources(cx, query) { + let cancelled = false; + + const search = async ({ dispatch, getState }) => { + dispatch(stopOngoingSearch(cx)); + await dispatch(addOngoingSearch(cx, search)); + await dispatch(clearSearchResults(cx)); + await dispatch(addSearchQuery(cx, query)); + dispatch(updateSearchStatus(cx, statusType.fetching)); + const searchOptions = getSearchOptions( + getState(), + searchKeys.PROJECT_SEARCH + ); + const validSources = getSourceList(getState()).filter( + source => + !isSourceBlackBoxed(getState(), source) && + !matchesGlobPatterns(source, searchOptions.excludePatterns) + ); + // Sort original entries first so that search results are more useful. + // Deprioritize third-party scripts, so their results show last. + validSources.sort((a, b) => { + function isThirdParty(source) { + return ( + source?.url && + (source.url.includes("node_modules") || + source.url.includes("bower_components")) + ); + } + + if (a.isOriginal && !isThirdParty(a)) { + return -1; + } + + if (b.isOriginal && !isThirdParty(b)) { + return 1; + } + + if (!isThirdParty(a) && isThirdParty(b)) { + return -1; + } + if (isThirdParty(a) && !isThirdParty(b)) { + return 1; + } + return 0; + }); + + for (const source of validSources) { + if (cancelled) { + return; + } + + const sourceActor = getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + await dispatch(loadSourceText(cx, source, sourceActor)); + await dispatch(searchSource(cx, source, sourceActor, query)); + } + dispatch(updateSearchStatus(cx, statusType.done)); + }; + + search.cancel = () => { + cancelled = true; + }; + + return search; +} + +export function searchSource(cx, source, sourceActor, query) { + return async ({ dispatch, getState, searchWorker }) => { + if (!source) { + return; + } + const state = getState(); + const location = createLocation({ + source, + sourceActor, + }); + + const options = getSearchOptions(state, searchKeys.PROJECT_SEARCH); + const content = getSettledSourceTextContent(state, location); + let matches = []; + + if (content && isFulfilled(content) && content.value.type === "text") { + matches = await searchWorker.findSourceMatches( + content.value, + query, + options + ); + } + if (!matches.length) { + return; + } + dispatch(addSearchResult(cx, location, matches)); + }; +} diff --git a/devtools/client/debugger/src/actions/quick-open.js b/devtools/client/debugger/src/actions/quick-open.js new file mode 100644 index 0000000000..e5f5352292 --- /dev/null +++ b/devtools/client/debugger/src/actions/quick-open.js @@ -0,0 +1,21 @@ +/* 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/>. */ + +export function setQuickOpenQuery(query) { + return { + type: "SET_QUICK_OPEN_QUERY", + query, + }; +} + +export function openQuickOpen(query) { + if (query != null) { + return { type: "OPEN_QUICK_OPEN", query }; + } + return { type: "OPEN_QUICK_OPEN" }; +} + +export function closeQuickOpen() { + return { type: "CLOSE_QUICK_OPEN" }; +} diff --git a/devtools/client/debugger/src/actions/source-actors.js b/devtools/client/debugger/src/actions/source-actors.js new file mode 100644 index 0000000000..9782e493b3 --- /dev/null +++ b/devtools/client/debugger/src/actions/source-actors.js @@ -0,0 +1,12 @@ +/* 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/>. */ + +export function insertSourceActors(sourceActors) { + return function ({ dispatch }) { + dispatch({ + type: "INSERT_SOURCE_ACTORS", + sourceActors, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/sources-tree.js b/devtools/client/debugger/src/actions/sources-tree.js new file mode 100644 index 0000000000..ae750a3df7 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources-tree.js @@ -0,0 +1,11 @@ +/* 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/>. */ + +export function setExpandedState(expanded) { + return { type: "SET_EXPANDED_STATE", expanded }; +} + +export function focusItem(item) { + return { type: "SET_FOCUSED_SOURCE_ITEM", item }; +} diff --git a/devtools/client/debugger/src/actions/sources/blackbox.js b/devtools/client/debugger/src/actions/sources/blackbox.js new file mode 100644 index 0000000000..6821a0e140 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/blackbox.js @@ -0,0 +1,223 @@ +/* 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/>. */ + +/** + * Redux actions for the sources state + * @module actions/sources + */ + +import { + isOriginalId, + originalToGeneratedId, +} from "devtools/client/shared/source-map-loader/index"; +import { recordEvent } from "../../utils/telemetry"; +import { toggleBreakpoints } from "../breakpoints"; +import { + getSourceActorsForSource, + isSourceBlackBoxed, + getBlackBoxRanges, + getBreakpointsForSource, +} from "../../selectors"; + +export async function blackboxSourceActorsForSource( + thunkArgs, + source, + shouldBlackBox, + ranges = [] +) { + const { getState, client, sourceMapLoader } = thunkArgs; + let sourceId = source.id; + // If the source is the original, then get the source id of its generated file + // and the range for where the original is represented in the generated file + // (which might be a bundle including other files). + if (isOriginalId(source.id)) { + sourceId = originalToGeneratedId(source.id); + const range = await sourceMapLoader.getFileGeneratedRange(source.id); + ranges = []; + if (range) { + ranges.push(range); + // TODO bug 1752108: Investigate blackboxing lines in original files, + // there is likely to be issues as the whole genrated file + // representing the original file will always be blackboxed. + console.warn( + "The might be unxpected issues when ignoring lines in an original file. " + + "The whole original source is being blackboxed." + ); + } else { + throw new Error( + `Unable to retrieve generated ranges for original source ${source.url}` + ); + } + } + + for (const actor of getSourceActorsForSource(getState(), sourceId)) { + await client.blackBox(actor, shouldBlackBox, ranges); + } +} + +/** + * Toggle blackboxing for the whole source or for specific lines in a source + * + * @param {Object} cx + * @param {Object} source - The source to be blackboxed/unblackboxed. + * @param {Boolean} [shouldBlackBox] - Specifies if the source should be blackboxed (true + * or unblackboxed (false). When this is not provided + * option is decided based on the blackboxed state + * of the source. + * @param {Array} [ranges] - List of line/column offsets to blackbox, these + * are provided only when blackboxing lines. + * The range structure: + * const range = { + * start: { line: 1, column: 5 }, + * end: { line: 3, column: 4 }, + * } + */ +export function toggleBlackBox(cx, source, shouldBlackBox, ranges = []) { + return async thunkArgs => { + const { dispatch, getState } = thunkArgs; + + shouldBlackBox = + typeof shouldBlackBox == "boolean" + ? shouldBlackBox + : !isSourceBlackBoxed(getState(), source); + + await blackboxSourceActorsForSource( + thunkArgs, + source, + shouldBlackBox, + ranges + ); + + if (shouldBlackBox) { + recordEvent("blackbox"); + // If ranges is an empty array, it would mean we are blackboxing the whole + // source. To do that lets reset the content to an empty array. + if (!ranges.length) { + dispatch({ type: "BLACKBOX_WHOLE_SOURCES", sources: [source] }); + await toggleBreakpointsInBlackboxedSources({ + thunkArgs, + cx, + shouldDisable: true, + sources: [source], + }); + } else { + const currentRanges = getBlackBoxRanges(getState())[source.url] || []; + ranges = ranges.filter(newRange => { + // To avoid adding duplicate ranges make sure + // no range already exists with same start and end lines. + const duplicate = currentRanges.findIndex( + r => + r.start.line == newRange.start.line && + r.end.line == newRange.end.line + ); + return duplicate == -1; + }); + dispatch({ type: "BLACKBOX_SOURCE_RANGES", source, ranges }); + await toggleBreakpointsInRangesForBlackboxedSource({ + thunkArgs, + cx, + shouldDisable: true, + source, + ranges, + }); + } + } else { + // if there are no ranges to blackbox, then we are unblackboxing + // the whole source + // eslint-disable-next-line no-lonely-if + if (!ranges.length) { + dispatch({ type: "UNBLACKBOX_WHOLE_SOURCES", sources: [source] }); + toggleBreakpointsInBlackboxedSources({ + thunkArgs, + cx, + shouldDisable: false, + sources: [source], + }); + } else { + dispatch({ type: "UNBLACKBOX_SOURCE_RANGES", source, ranges }); + const blackboxRanges = getBlackBoxRanges(getState()); + if (!blackboxRanges[source.url].length) { + dispatch({ type: "UNBLACKBOX_WHOLE_SOURCES", sources: [source] }); + } + await toggleBreakpointsInRangesForBlackboxedSource({ + thunkArgs, + cx, + shouldDisable: false, + source, + ranges, + }); + } + } + }; +} + +async function toggleBreakpointsInRangesForBlackboxedSource({ + thunkArgs, + cx, + shouldDisable, + source, + ranges, +}) { + const { dispatch, getState } = thunkArgs; + for (const range of ranges) { + const breakpoints = getBreakpointsForSource(getState(), source.id, range); + await dispatch(toggleBreakpoints(cx, shouldDisable, breakpoints)); + } +} + +async function toggleBreakpointsInBlackboxedSources({ + thunkArgs, + cx, + shouldDisable, + sources, +}) { + const { dispatch, getState } = thunkArgs; + for (const source of sources) { + const breakpoints = getBreakpointsForSource(getState(), source.id); + await dispatch(toggleBreakpoints(cx, shouldDisable, breakpoints)); + } +} + +/* + * Blackboxes a group of sources together + * + * @param {Object} cx + * @param {Array} sourcesToBlackBox - The list of sources to blackbox + * @param {Boolean} shouldBlackbox - Specifies if the sources should blackboxed (true) + * or unblackboxed (false). + */ +export function blackBoxSources(cx, sourcesToBlackBox, shouldBlackBox) { + return async thunkArgs => { + const { dispatch, getState } = thunkArgs; + + const sources = sourcesToBlackBox.filter( + source => isSourceBlackBoxed(getState(), source) !== shouldBlackBox + ); + + if (!sources.length) { + return; + } + + for (const source of sources) { + await blackboxSourceActorsForSource(thunkArgs, source, shouldBlackBox); + } + + if (shouldBlackBox) { + recordEvent("blackbox"); + } + + dispatch({ + type: shouldBlackBox + ? "BLACKBOX_WHOLE_SOURCES" + : "UNBLACKBOX_WHOLE_SOURCES", + sources, + }); + await toggleBreakpointsInBlackboxedSources({ + thunkArgs, + cx, + shouldDisable: shouldBlackBox, + sources, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/sources/breakableLines.js b/devtools/client/debugger/src/actions/sources/breakableLines.js new file mode 100644 index 0000000000..d028d480c0 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/breakableLines.js @@ -0,0 +1,73 @@ +/* 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 { isOriginalId } from "devtools/client/shared/source-map-loader/index"; +import { + getBreakableLines, + getSourceActorBreakableLines, +} from "../../selectors"; +import { setBreakpointPositions } from "../breakpoints/breakpointPositions"; + +function calculateBreakableLines(positions) { + const lines = []; + for (const line in positions) { + if (positions[line].length) { + lines.push(Number(line)); + } + } + + return lines; +} + +/** + * Ensure that breakable lines for a given source are fetched. + * + * @param Object cx + * @param Object location + */ +export function setBreakableLines(cx, location) { + return async ({ getState, dispatch, client }) => { + let breakableLines; + if (isOriginalId(location.source.id)) { + const positions = await dispatch( + setBreakpointPositions({ cx, location }) + ); + breakableLines = calculateBreakableLines(positions); + + const existingBreakableLines = getBreakableLines( + getState(), + location.source.id + ); + if (existingBreakableLines) { + breakableLines = [ + ...new Set([...existingBreakableLines, ...breakableLines]), + ]; + } + + dispatch({ + type: "SET_ORIGINAL_BREAKABLE_LINES", + cx, + sourceId: location.source.id, + breakableLines, + }); + } else { + // Ignore re-fetching the breakable lines for source actor we already fetched + breakableLines = getSourceActorBreakableLines( + getState(), + location.sourceActor.id + ); + if (breakableLines) { + return; + } + breakableLines = await client.getSourceActorBreakableLines( + location.sourceActor + ); + dispatch({ + type: "SET_SOURCE_ACTOR_BREAKABLE_LINES", + sourceActorId: location.sourceActor.id, + breakableLines, + }); + } + }; +} diff --git a/devtools/client/debugger/src/actions/sources/index.js b/devtools/client/debugger/src/actions/sources/index.js new file mode 100644 index 0000000000..813f50262b --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/index.js @@ -0,0 +1,42 @@ +/* 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/>. */ + +export * from "./blackbox"; +export * from "./breakableLines"; +export * from "./loadSourceText"; +export * from "./newSources"; +export * from "./prettyPrint"; +export * from "./select"; +export { setSymbols } from "./symbols"; + +export function setOverrideSource(cx, source, path) { + return ({ client, dispatch }) => { + if (!source || !source.url) { + return; + } + const { url } = source; + client.setOverride(url, path); + dispatch({ + type: "SET_OVERRIDE", + cx, + url, + path, + }); + }; +} + +export function removeOverrideSource(cx, source) { + return ({ client, dispatch }) => { + if (!source || !source.url) { + return; + } + const { url } = source; + client.removeOverride(url); + dispatch({ + type: "REMOVE_OVERRIDE", + cx, + url, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/sources/loadSourceText.js b/devtools/client/debugger/src/actions/sources/loadSourceText.js new file mode 100644 index 0000000000..8210b07a97 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/loadSourceText.js @@ -0,0 +1,256 @@ +/* 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 { PROMISE } from "../utils/middleware/promise"; +import { + getSourceTextContent, + getSettledSourceTextContent, + getGeneratedSource, + getSourcesEpoch, + getBreakpointsForSource, + getSourceActorsForSource, + getFirstSourceActorForGeneratedSource, +} from "../../selectors"; +import { addBreakpoint } from "../breakpoints"; + +import { prettyPrintSource } from "./prettyPrint"; +import { isFulfilled, fulfilled } from "../../utils/async-value"; + +import { isPretty } from "../../utils/source"; +import { createLocation } from "../../utils/location"; +import { memoizeableAction } from "../../utils/memoizableAction"; + +async function loadGeneratedSource(sourceActor, { client }) { + // If no source actor can be found then the text for the + // source cannot be loaded. + if (!sourceActor) { + throw new Error("Source actor is null or not defined"); + } + + let response; + try { + response = await client.sourceContents(sourceActor); + } catch (e) { + throw new Error(`sourceContents failed: ${e}`); + } + + return { + text: response.source, + contentType: response.contentType || "text/javascript", + }; +} + +async function loadOriginalSource( + source, + { getState, client, sourceMapLoader, prettyPrintWorker } +) { + if (isPretty(source)) { + const generatedSource = getGeneratedSource(getState(), source); + if (!generatedSource) { + throw new Error("Unable to find minified original."); + } + + const content = getSettledSourceTextContent( + getState(), + createLocation({ + source: generatedSource, + }) + ); + + return prettyPrintSource( + sourceMapLoader, + prettyPrintWorker, + generatedSource, + content, + getSourceActorsForSource(getState(), generatedSource.id) + ); + } + + const result = await sourceMapLoader.getOriginalSourceText(source.id); + if (!result) { + // The way we currently try to load and select a pending + // selected location, it is possible that we will try to fetch the + // original source text right after the source map has been cleared + // after a navigation event. + throw new Error("Original source text unavailable"); + } + return result; +} + +async function loadGeneratedSourceTextPromise(cx, sourceActor, thunkArgs) { + const { dispatch, getState } = thunkArgs; + const epoch = getSourcesEpoch(getState()); + + await dispatch({ + type: "LOAD_GENERATED_SOURCE_TEXT", + sourceActorId: sourceActor.actor, + epoch, + [PROMISE]: loadGeneratedSource(sourceActor, thunkArgs), + }); + + await onSourceTextContentAvailable( + cx, + sourceActor.sourceObject, + sourceActor, + thunkArgs + ); +} + +async function loadOriginalSourceTextPromise(cx, source, thunkArgs) { + const { dispatch, getState } = thunkArgs; + const epoch = getSourcesEpoch(getState()); + await dispatch({ + type: "LOAD_ORIGINAL_SOURCE_TEXT", + sourceId: source.id, + epoch, + [PROMISE]: loadOriginalSource(source, thunkArgs), + }); + + await onSourceTextContentAvailable(cx, source, null, thunkArgs); +} + +/** + * Function called everytime a new original or generated source gets its text content + * fetched from the server and registered in the reducer. + * + * @param {Object} cx + * @param {Object} source + * @param {Object} sourceActor (optional) + * If this is a generated source, we expect a precise source actor. + * @param {Object} thunkArgs + */ +async function onSourceTextContentAvailable( + cx, + source, + sourceActor, + { dispatch, getState, parserWorker } +) { + const location = createLocation({ + source, + sourceActor, + }); + const content = getSettledSourceTextContent(getState(), location); + if (!content) { + return; + } + + if (parserWorker.isLocationSupported(location)) { + parserWorker.setSource( + source.id, + isFulfilled(content) + ? content.value + : { type: "text", value: "", contentType: undefined } + ); + } + + // Update the text in any breakpoints for this source by re-adding them. + const breakpoints = getBreakpointsForSource(getState(), source.id); + for (const breakpoint of breakpoints) { + await dispatch( + addBreakpoint( + cx, + breakpoint.location, + breakpoint.options, + breakpoint.disabled + ) + ); + } +} + +/** + * Loads the source text for the generated source based of the source actor + * @param {Object} sourceActor + * There can be more than one source actor per source + * so the source actor needs to be specified. This is + * required for generated sources but will be null for + * original/pretty printed sources. + */ +export const loadGeneratedSourceText = memoizeableAction( + "loadGeneratedSourceText", + { + getValue: ({ sourceActor }, { getState }) => { + if (!sourceActor) { + return null; + } + + const sourceTextContent = getSourceTextContent( + getState(), + createLocation({ + source: sourceActor.sourceObject, + sourceActor, + }) + ); + + if (!sourceTextContent || sourceTextContent.state === "pending") { + return sourceTextContent; + } + + // This currently swallows source-load-failure since we return fulfilled + // here when content.state === "rejected". In an ideal world we should + // propagate that error upward. + return fulfilled(sourceTextContent); + }, + createKey: ({ sourceActor }, { getState }) => { + const epoch = getSourcesEpoch(getState()); + return `${epoch}:${sourceActor.actor}`; + }, + action: ({ cx, sourceActor }, thunkArgs) => + loadGeneratedSourceTextPromise(cx, sourceActor, thunkArgs), + } +); + +/** + * Loads the source text for an original source and source actor + * @param {Object} source + * The original source to load the source text + */ +export const loadOriginalSourceText = memoizeableAction( + "loadOriginalSourceText", + { + getValue: ({ source }, { getState }) => { + if (!source) { + return null; + } + + const sourceTextContent = getSourceTextContent( + getState(), + createLocation({ + source, + }) + ); + if (!sourceTextContent || sourceTextContent.state === "pending") { + return sourceTextContent; + } + + // This currently swallows source-load-failure since we return fulfilled + // here when content.state === "rejected". In an ideal world we should + // propagate that error upward. + return fulfilled(sourceTextContent); + }, + createKey: ({ source }, { getState }) => { + const epoch = getSourcesEpoch(getState()); + return `${epoch}:${source.id}`; + }, + action: ({ cx, source }, thunkArgs) => + loadOriginalSourceTextPromise(cx, source, thunkArgs), + } +); + +export function loadSourceText(cx, source, sourceActor) { + return async ({ dispatch, getState }) => { + if (!source) { + return null; + } + if (source.isOriginal) { + return dispatch(loadOriginalSourceText({ cx, source })); + } + if (!sourceActor) { + sourceActor = getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + } + return dispatch(loadGeneratedSourceText({ cx, sourceActor })); + }; +} diff --git a/devtools/client/debugger/src/actions/sources/moz.build b/devtools/client/debugger/src/actions/sources/moz.build new file mode 100644 index 0000000000..9972e9f09b --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/moz.build @@ -0,0 +1,17 @@ +# 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( + "blackbox.js", + "breakableLines.js", + "index.js", + "loadSourceText.js", + "newSources.js", + "prettyPrint.js", + "select.js", + "symbols.js", +) diff --git a/devtools/client/debugger/src/actions/sources/newSources.js b/devtools/client/debugger/src/actions/sources/newSources.js new file mode 100644 index 0000000000..1e95c6d79d --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/newSources.js @@ -0,0 +1,367 @@ +/* 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/>. */ + +/** + * Redux actions for the sources state + * @module actions/sources + */ +import { PROMISE } from "../utils/middleware/promise"; +import { insertSourceActors } from "../../actions/source-actors"; +import { + makeSourceId, + createGeneratedSource, + createSourceMapOriginalSource, + createSourceActor, +} from "../../client/firefox/create"; +import { toggleBlackBox } from "./blackbox"; +import { syncPendingBreakpoint } from "../breakpoints"; +import { loadSourceText } from "./loadSourceText"; +import { togglePrettyPrint } from "./prettyPrint"; +import { toggleSourceMapIgnoreList } from "../ui"; +import { selectLocation, setBreakableLines } from "../sources"; + +import { getRawSourceURL, isPrettyURL } from "../../utils/source"; +import { createLocation } from "../../utils/location"; +import { + getBlackBoxRanges, + getSource, + getSourceFromId, + hasSourceActor, + getSourceByActorId, + getPendingSelectedLocation, + getPendingBreakpointsForSource, + getContext, +} from "../../selectors"; + +import { prefs } from "../../utils/prefs"; +import sourceQueue from "../../utils/source-queue"; +import { validateNavigateContext, ContextError } from "../../utils/context"; + +function loadSourceMaps(cx, sources) { + return async function ({ dispatch }) { + try { + const sourceList = await Promise.all( + sources.map(async sourceActor => { + const originalSourcesInfo = await dispatch( + loadSourceMap(cx, sourceActor) + ); + originalSourcesInfo.forEach( + sourcesInfo => (sourcesInfo.sourceActor = sourceActor) + ); + sourceQueue.queueOriginalSources(originalSourcesInfo); + return originalSourcesInfo; + }) + ); + + await sourceQueue.flush(); + return sourceList.flat(); + } catch (error) { + if (!(error instanceof ContextError)) { + throw error; + } + } + return []; + }; +} + +/** + * @memberof actions/sources + * @static + */ +function loadSourceMap(cx, sourceActor) { + return async function ({ dispatch, getState, sourceMapLoader }) { + if (!prefs.clientSourceMapsEnabled || !sourceActor.sourceMapURL) { + return []; + } + + let data = null; + try { + // Ignore sourceMapURL on scripts that are part of HTML files, since + // we currently treat sourcemaps as Source-wide, not SourceActor-specific. + const source = getSourceByActorId(getState(), sourceActor.id); + if (source) { + data = await sourceMapLoader.getOriginalURLs({ + // Using source ID here is historical and eventually we'll want to + // switch to all of this being per-source-actor. + id: source.id, + url: sourceActor.url || "", + sourceMapBaseURL: sourceActor.sourceMapBaseURL || "", + sourceMapURL: sourceActor.sourceMapURL || "", + isWasm: sourceActor.introductionType === "wasm", + }); + dispatch({ + type: "ADD_SOURCEMAP_IGNORE_LIST_SOURCES", + [PROMISE]: sourceMapLoader.getSourceMapIgnoreList(source.id), + }); + } + } catch (e) { + console.error(e); + } + + if (!data || !data.length) { + // If this source doesn't have a sourcemap or there are no original files + // existing, enable it for pretty printing + dispatch({ + type: "CLEAR_SOURCE_ACTOR_MAP_URL", + cx, + sourceActorId: sourceActor.id, + }); + return []; + } + + validateNavigateContext(getState(), cx); + return data; + }; +} + +// If a request has been made to show this source, go ahead and +// select it. +function checkSelectedSource(cx, sourceId) { + return async ({ dispatch, getState }) => { + const state = getState(); + const pendingLocation = getPendingSelectedLocation(state); + + if (!pendingLocation || !pendingLocation.url) { + return; + } + + const source = getSource(state, sourceId); + + if (!source || !source.url) { + return; + } + + const pendingUrl = pendingLocation.url; + const rawPendingUrl = getRawSourceURL(pendingUrl); + + if (rawPendingUrl === source.url) { + if (isPrettyURL(pendingUrl)) { + const prettySource = await dispatch(togglePrettyPrint(cx, source.id)); + dispatch(checkPendingBreakpoints(cx, prettySource, null)); + return; + } + + await dispatch( + selectLocation( + cx, + createLocation({ + source, + line: + typeof pendingLocation.line === "number" + ? pendingLocation.line + : 0, + column: pendingLocation.column, + }) + ) + ); + } + }; +} + +function checkPendingBreakpoints(cx, source, sourceActor) { + return async ({ dispatch, getState }) => { + const pendingBreakpoints = getPendingBreakpointsForSource( + getState(), + source + ); + + if (pendingBreakpoints.length === 0) { + return; + } + + // load the source text if there is a pending breakpoint for it + await dispatch(loadSourceText(cx, source, sourceActor)); + await dispatch( + setBreakableLines(cx, createLocation({ source, sourceActor })) + ); + + await Promise.all( + pendingBreakpoints.map(pendingBp => { + return dispatch(syncPendingBreakpoint(cx, source.id, pendingBp)); + }) + ); + }; +} + +function restoreBlackBoxedSources(cx, sources) { + return async ({ dispatch, getState }) => { + const currentRanges = getBlackBoxRanges(getState()); + + if (!Object.keys(currentRanges).length) { + return; + } + + for (const source of sources) { + const ranges = currentRanges[source.url]; + if (ranges) { + // If the ranges is an empty then the whole source was blackboxed. + await dispatch(toggleBlackBox(cx, source, true, ranges)); + } + } + + if (prefs.sourceMapIgnoreListEnabled) { + await dispatch(toggleSourceMapIgnoreList(cx, true)); + } + }; +} + +export function newOriginalSources(originalSourcesInfo) { + return async ({ dispatch, getState }) => { + const state = getState(); + const seen = new Set(); + + const actors = []; + const actorsSources = {}; + + for (const { id, url, sourceActor } of originalSourcesInfo) { + if (seen.has(id) || getSource(state, id)) { + continue; + } + seen.add(id); + + if (!actorsSources[sourceActor.actor]) { + actors.push(sourceActor); + actorsSources[sourceActor.actor] = []; + } + + actorsSources[sourceActor.actor].push( + createSourceMapOriginalSource(id, url) + ); + } + + const cx = getContext(state); + + // Add the original sources per the generated source actors that + // they are primarily from. + actors.forEach(sourceActor => { + dispatch({ + type: "ADD_ORIGINAL_SOURCES", + cx, + originalSources: actorsSources[sourceActor.actor], + generatedSourceActor: sourceActor, + }); + }); + + // Accumulate the sources back into one list + const actorsSourcesValues = Object.values(actorsSources); + let sources = []; + if (actorsSourcesValues.length) { + sources = actorsSourcesValues.reduce((acc, sourceList) => + acc.concat(sourceList) + ); + } + + await dispatch(checkNewSources(cx, sources)); + + for (const source of sources) { + dispatch(checkPendingBreakpoints(cx, source, null)); + } + + return sources; + }; +} + +// Wrapper around newGeneratedSources, only used by tests +export function newGeneratedSource(sourceInfo) { + return async ({ dispatch }) => { + const sources = await dispatch(newGeneratedSources([sourceInfo])); + return sources[0]; + }; +} + +export function newGeneratedSources(sourceResources) { + return async ({ dispatch, getState, client }) => { + if (!sourceResources.length) { + return []; + } + + const resultIds = []; + const newSourcesObj = {}; + const newSourceActors = []; + + for (const sourceResource of sourceResources) { + // By the time we process the sources, the related target + // might already have been destroyed. It means that the sources + // are also about to be destroyed, so ignore them. + // (This is covered by browser_toolbox_backward_forward_navigation.js) + if (sourceResource.targetFront.isDestroyed()) { + continue; + } + const id = makeSourceId(sourceResource); + + if (!getSource(getState(), id) && !newSourcesObj[id]) { + newSourcesObj[id] = createGeneratedSource(sourceResource); + } + + const actorId = sourceResource.actor; + + // We are sometimes notified about a new source multiple times if we + // request a new source list and also get a source event from the server. + if (!hasSourceActor(getState(), actorId)) { + newSourceActors.push( + createSourceActor( + sourceResource, + getSource(getState(), id) || newSourcesObj[id] + ) + ); + } + + resultIds.push(id); + } + + const newSources = Object.values(newSourcesObj); + + const cx = getContext(getState()); + dispatch(addSources(cx, newSources)); + dispatch(insertSourceActors(newSourceActors)); + + await dispatch(checkNewSources(cx, newSources)); + + (async () => { + await dispatch(loadSourceMaps(cx, newSourceActors)); + + // We would like to sync breakpoints after we are done + // loading source maps as sometimes generated and original + // files share the same paths. + for (const sourceActor of newSourceActors) { + // For HTML pages, we fetch all new incoming inline script, + // which will be related to one dedicated source actor. + // Whereas, for regular sources, if we have many source actors, + // this is for the same URL. And code expecting to have breakable lines + // will request breakable lines for that particular source actor. + if (sourceActor.sourceObject.isHTML) { + await dispatch( + setBreakableLines( + cx, + createLocation({ source: sourceActor.sourceObject, sourceActor }) + ) + ); + } + dispatch( + checkPendingBreakpoints(cx, sourceActor.sourceObject, sourceActor) + ); + } + })(); + + return resultIds.map(id => getSourceFromId(getState(), id)); + }; +} + +function addSources(cx, sources) { + return ({ dispatch, getState }) => { + dispatch({ type: "ADD_SOURCES", cx, sources }); + }; +} + +function checkNewSources(cx, sources) { + return async ({ dispatch, getState }) => { + for (const source of sources) { + dispatch(checkSelectedSource(cx, source.id)); + } + + await dispatch(restoreBlackBoxedSources(cx, sources)); + + return sources; + }; +} diff --git a/devtools/client/debugger/src/actions/sources/prettyPrint.js b/devtools/client/debugger/src/actions/sources/prettyPrint.js new file mode 100644 index 0000000000..66e3f4129b --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/prettyPrint.js @@ -0,0 +1,339 @@ +/* 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 { + generatedToOriginalId, + originalToGeneratedId, +} from "devtools/client/shared/source-map-loader/index"; + +import assert from "../../utils/assert"; +import { recordEvent } from "../../utils/telemetry"; +import { updateBreakpointsForNewPrettyPrintedSource } from "../breakpoints"; +import { createLocation } from "../../utils/location"; + +import { + getPrettySourceURL, + isGenerated, + isJavaScript, +} from "../../utils/source"; +import { isFulfilled } from "../../utils/async-value"; +import { getOriginalLocation } from "../../utils/source-maps"; +import { prefs } from "../../utils/prefs"; +import { + loadGeneratedSourceText, + loadOriginalSourceText, +} from "./loadSourceText"; +import { mapFrames } from "../pause"; +import { selectSpecificLocation } from "../sources"; +import { createPrettyPrintOriginalSource } from "../../client/firefox/create"; + +import { + getSource, + getFirstSourceActorForGeneratedSource, + getSourceByURL, + getSelectedLocation, + getThreadContext, +} from "../../selectors"; + +import { selectSource } from "./select"; + +import DevToolsUtils from "devtools/shared/DevToolsUtils"; + +const LINE_BREAK_REGEX = /\r\n?|\n|\u2028|\u2029/g; +function matchAllLineBreaks(str) { + return Array.from(str.matchAll(LINE_BREAK_REGEX)); +} + +function getPrettyOriginalSourceURL(generatedSource) { + return getPrettySourceURL(generatedSource.url || generatedSource.id); +} + +export async function prettyPrintSource( + sourceMapLoader, + prettyPrintWorker, + generatedSource, + content, + actors +) { + if (!content || !isFulfilled(content)) { + throw new Error("Cannot pretty-print a file that has not loaded"); + } + + const contentValue = content.value; + if ( + (!isJavaScript(generatedSource, contentValue) && !generatedSource.isHTML) || + contentValue.type !== "text" + ) { + throw new Error( + `Can't prettify ${contentValue.contentType} files, only HTML and Javascript.` + ); + } + + const url = getPrettyOriginalSourceURL(generatedSource); + + let prettyPrintWorkerResult; + if (generatedSource.isHTML) { + prettyPrintWorkerResult = await prettyPrintHtmlFile({ + prettyPrintWorker, + generatedSource, + content, + actors, + }); + } else { + prettyPrintWorkerResult = await prettyPrintWorker.prettyPrint({ + sourceText: contentValue.value, + indent: " ".repeat(prefs.indentSize), + url, + }); + } + + // The source map URL service used by other devtools listens to changes to + // sources based on their actor IDs, so apply the sourceMap there too. + const generatedSourceIds = [ + generatedSource.id, + ...actors.map(item => item.actor), + ]; + await sourceMapLoader.setSourceMapForGeneratedSources( + generatedSourceIds, + prettyPrintWorkerResult.sourceMap + ); + + return { + text: prettyPrintWorkerResult.code, + contentType: contentValue.contentType, + }; +} + +/** + * Pretty print inline script inside an HTML file + * + * @param {Object} options + * @param {PrettyPrintDispatcher} options.prettyPrintWorker: The prettyPrint worker + * @param {Object} options.generatedSource: The HTML source we want to pretty print + * @param {Object} options.content + * @param {Array} options.actors: An array of the HTML file inline script sources data + * + * @returns Promise<Object> A promise that resolves with an object of the following shape: + * - {String} code: The prettified HTML text + * - {Object} sourceMap: The sourceMap object + */ +async function prettyPrintHtmlFile({ + prettyPrintWorker, + generatedSource, + content, + actors, +}) { + const url = getPrettyOriginalSourceURL(generatedSource); + const contentValue = content.value; + const htmlFileText = contentValue.value; + const prettyPrintWorkerResult = { code: htmlFileText }; + + const allLineBreaks = matchAllLineBreaks(htmlFileText); + let lineCountDelta = 0; + + // Sort inline script actors so they are in the same order as in the html document. + actors.sort((a, b) => { + if (a.sourceStartLine === b.sourceStartLine) { + return a.sourceStartColumn > b.sourceStartColumn; + } + return a.sourceStartLine > b.sourceStartLine; + }); + + const prettyPrintTaskId = generatedSource.id; + + // We don't want to replace part of the HTML document in the loop since it would require + // to account for modified lines for each iteration. + // Instead, we'll put each sections to replace in this array, where elements will be + // objects of the following shape: + // {Integer} startIndex: The start index in htmlFileText of the section we want to replace + // {Integer} endIndex: The end index in htmlFileText of the section we want to replace + // {String} prettyText: The pretty text we'll replace the original section with + // Once we iterated over all the inline scripts, we'll do the replacements (on the html + // file text) in reverse order, so we don't need have to care about the modified lines + // for each iteration. + const replacements = []; + + const seenLocations = new Set(); + + for (const sourceInfo of actors) { + // We can get duplicate source actors representing the same inline script which will + // cause trouble in the pretty printing here. This should be fixed on the server (see + // Bug 1824979), but in the meantime let's not handle the same location twice so the + // pretty printing is not impacted. + const location = `${sourceInfo.sourceStartLine}:${sourceInfo.sourceStartColumn}`; + if (!sourceInfo.sourceLength || seenLocations.has(location)) { + continue; + } + seenLocations.add(location); + // Here we want to get the index of the last line break before the script tag. + // In allLineBreaks, this would be the item at (script tag line - 1) + // Since sourceInfo.sourceStartLine is 1-based, we need to get the item at (sourceStartLine - 2) + const indexAfterPreviousLineBreakInHtml = + sourceInfo.sourceStartLine > 1 + ? allLineBreaks[sourceInfo.sourceStartLine - 2].index + 1 + : 0; + const startIndex = + indexAfterPreviousLineBreakInHtml + sourceInfo.sourceStartColumn; + const endIndex = startIndex + sourceInfo.sourceLength; + const scriptText = htmlFileText.substring(startIndex, endIndex); + DevToolsUtils.assert( + scriptText.length == sourceInfo.sourceLength, + "script text has expected length" + ); + + // Here we're going to pretty print each inline script content. + // Since we want to have a sourceMap that we'll apply to the whole HTML file, + // we'll only collect the sourceMap once we handled all inline scripts. + // `taskId` allows us to signal to the worker that all those calls are part of the + // same bigger file, and we'll use it later to get the sourceMap. + const prettyText = await prettyPrintWorker.prettyPrintInlineScript({ + taskId: prettyPrintTaskId, + sourceText: scriptText, + indent: " ".repeat(prefs.indentSize), + url, + originalStartLine: sourceInfo.sourceStartLine, + originalStartColumn: sourceInfo.sourceStartColumn, + // The generated line will be impacted by the previous inline scripts that were + // pretty printed, which is why we offset with lineCountDelta + generatedStartLine: sourceInfo.sourceStartLine + lineCountDelta, + generatedStartColumn: sourceInfo.sourceStartColumn, + lineCountDelta, + }); + + // We need to keep track of the line added/removed in order to properly offset + // the mapping of the pretty-print text + lineCountDelta += + matchAllLineBreaks(prettyText).length - + matchAllLineBreaks(scriptText).length; + + replacements.push({ + startIndex, + endIndex, + prettyText, + }); + } + + // `getSourceMap` allow us to collect the computed source map resulting of the calls + // to `prettyPrint` with the same taskId. + prettyPrintWorkerResult.sourceMap = await prettyPrintWorker.getSourceMap( + prettyPrintTaskId + ); + + // Sort replacement in reverse order so we can replace code in the HTML file more easily + replacements.sort((a, b) => a.startIndex < b.startIndex); + for (const { startIndex, endIndex, prettyText } of replacements) { + prettyPrintWorkerResult.code = + prettyPrintWorkerResult.code.substring(0, startIndex) + + prettyText + + prettyPrintWorkerResult.code.substring(endIndex); + } + + return prettyPrintWorkerResult; +} + +function createPrettySource(cx, source) { + return async ({ dispatch, sourceMapLoader, getState }) => { + const url = getPrettyOriginalSourceURL(source); + const id = generatedToOriginalId(source.id, url); + const prettySource = createPrettyPrintOriginalSource(id, url); + + dispatch({ + type: "ADD_ORIGINAL_SOURCES", + cx, + originalSources: [prettySource], + }); + return prettySource; + }; +} + +function selectPrettyLocation(cx, prettySource) { + return async thunkArgs => { + const { dispatch, getState } = thunkArgs; + let location = getSelectedLocation(getState()); + + // If we were selecting a particular line in the minified/generated source, + // try to select the matching line in the prettified/original source. + if ( + location && + location.line >= 1 && + location.sourceId == originalToGeneratedId(prettySource.id) + ) { + location = await getOriginalLocation(location, thunkArgs); + + return dispatch( + selectSpecificLocation( + cx, + createLocation({ ...location, source: prettySource }) + ) + ); + } + + return dispatch(selectSource(cx, prettySource)); + }; +} + +/** + * Toggle the pretty printing of a source's text. + * Nothing will happen for non-javascript files. + * + * @param Object cx + * @param String sourceId + * The source ID for the minified/generated source object. + * @returns Promise + * A promise that resolves to the Pretty print/original source object. + */ +export function togglePrettyPrint(cx, sourceId) { + return async ({ dispatch, getState }) => { + const source = getSource(getState(), sourceId); + if (!source) { + return {}; + } + + if (!source.isPrettyPrinted) { + recordEvent("pretty_print"); + } + + assert( + isGenerated(source), + "Pretty-printing only allowed on generated sources" + ); + + const sourceActor = getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(loadGeneratedSourceText({ cx, sourceActor })); + + const url = getPrettySourceURL(source.url); + const prettySource = getSourceByURL(getState(), url); + + if (prettySource) { + return dispatch(selectPrettyLocation(cx, prettySource)); + } + + const newPrettySource = await dispatch(createPrettySource(cx, source)); + + // Force loading the pretty source/original text. + // This will end up calling prettyPrintSource() of this module, and + // more importantly, will populate the sourceMapLoader, which is used by selectPrettyLocation. + await dispatch(loadOriginalSourceText({ cx, source: newPrettySource })); + // Select the pretty/original source based on the location we may + // have had against the minified/generated source. + // This uses source map to map locations. + // Also note that selecting a location force many things: + // * opening tabs + // * fetching symbols/inline scope + // * fetching breakable lines + await dispatch(selectPrettyLocation(cx, newPrettySource)); + + const threadcx = getThreadContext(getState()); + // Update frames to the new pretty/original source (in case we were paused) + await dispatch(mapFrames(threadcx)); + // Update breakpoints locations to the new pretty/original source + await dispatch(updateBreakpointsForNewPrettyPrintedSource(cx, sourceId)); + + return newPrettySource; + }; +} diff --git a/devtools/client/debugger/src/actions/sources/select.js b/devtools/client/debugger/src/actions/sources/select.js new file mode 100644 index 0000000000..c4443432a0 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/select.js @@ -0,0 +1,264 @@ +/* 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/>. */ + +/** + * Redux actions for the sources state + * @module actions/sources + */ + +import { isOriginalId } from "devtools/client/shared/source-map-loader/index"; + +import { setSymbols } from "./symbols"; +import { setInScopeLines } from "../ast"; +import { togglePrettyPrint } from "./prettyPrint"; +import { addTab, closeTab } from "../tabs"; +import { loadSourceText } from "./loadSourceText"; +import { mapDisplayNames } from "../pause"; +import { setBreakableLines } from "."; + +import { prefs } from "../../utils/prefs"; +import { isMinified } from "../../utils/source"; +import { createLocation } from "../../utils/location"; +import { getRelatedMapLocation } from "../../utils/source-maps"; + +import { + getSource, + getFirstSourceActorForGeneratedSource, + getSourceByURL, + getPrettySource, + getSelectedLocation, + getShouldSelectOriginalLocation, + canPrettyPrintSource, + getIsCurrentThreadPaused, + getSourceTextContent, + tabExists, +} from "../../selectors"; + +// This is only used by jest tests (and within this module) +export const setSelectedLocation = ( + cx, + location, + shouldSelectOriginalLocation +) => ({ + type: "SET_SELECTED_LOCATION", + cx, + location, + shouldSelectOriginalLocation, +}); + +// This is only used by jest tests (and within this module) +export const setPendingSelectedLocation = (cx, url, options) => ({ + type: "SET_PENDING_SELECTED_LOCATION", + cx, + url, + line: options?.line, + column: options?.column, +}); + +// This is only used by jest tests (and within this module) +export const clearSelectedLocation = cx => ({ + type: "CLEAR_SELECTED_LOCATION", + cx, +}); + +/** + * Deterministically select a source that has a given URL. This will + * work regardless of the connection status or if the source exists + * yet. + * + * This exists mostly for external things to interact with the + * debugger. + */ +export function selectSourceURL(cx, url, options) { + return async ({ dispatch, getState }) => { + const source = getSourceByURL(getState(), url); + if (!source) { + return dispatch(setPendingSelectedLocation(cx, url, options)); + } + + const location = createLocation({ ...options, source }); + return dispatch(selectLocation(cx, location)); + }; +} + +/** + * Wrapper around selectLocation, which creates the location object for us. + * Note that it ignores the currently selected source and will select + * the precise generated/original source passed as argument. + * + * @param {Object} cx + * @param {String} source + * The precise source to select. + * @param {String} sourceActor + * The specific source actor of the source to + * select the source text. This is optional. + */ +export function selectSource(cx, source, sourceActor) { + return async ({ dispatch }) => { + // `createLocation` requires a source object, but we may use selectSource to close the last tab, + // where source will be null and the location will be an empty object. + const location = source ? createLocation({ source, sourceActor }) : {}; + + return dispatch(selectSpecificLocation(cx, location)); + }; +} + +/** + * Select a new location. + * This will automatically select the source in the source tree (if visible) + * and open the source (a new tab and the source editor) + * as well as highlight a precise line in the editor. + * + * Note that by default, this may map your passed location to the original + * or generated location based on the selected source state. (see keepContext) + * + * @param {Object} cx + * @param {Object} location + * @param {Object} options + * @param {boolean} options.keepContext + * If false, this will ignore the currently selected source + * and select the generated or original location, even if we + * were currently selecting the other source type. + */ +export function selectLocation(cx, location, { keepContext = true } = {}) { + return async thunkArgs => { + const { dispatch, getState, client } = thunkArgs; + + if (!client) { + // No connection, do nothing. This happens when the debugger is + // shut down too fast and it tries to display a default source. + return; + } + + let source = location.source; + + if (!source) { + // If there is no source we deselect the current selected source + dispatch(clearSelectedLocation(cx)); + return; + } + + // Preserve the current source map context (original / generated) + // when navigating to a new location. + // i.e. if keepContext isn't manually overriden to false, + // we will convert the source we want to select to either + // original/generated in order to match the currently selected one. + // If the currently selected source is original, we will + // automatically map `location` to refer to the original source, + // even if that used to refer only to the generated source. + let shouldSelectOriginalLocation = getShouldSelectOriginalLocation( + getState() + ); + if (keepContext) { + if (shouldSelectOriginalLocation != isOriginalId(location.sourceId)) { + // getRelatedMapLocation will convert to the related generated/original location. + // i.e if the original location is passed, the related generated location will be returned and vice versa. + location = await getRelatedMapLocation(location, thunkArgs); + // Note that getRelatedMapLocation may return the exact same location. + // For example, if the source-map is half broken, it may return a generated location + // while we were selecting original locations. So we may be seeing bundles intermittently + // when stepping through broken source maps. And we will see original sources when stepping + // through functional original sources. + + source = location.source; + } + } else { + shouldSelectOriginalLocation = isOriginalId(location.sourceId); + } + + let sourceActor = location.sourceActor; + if (!sourceActor) { + sourceActor = getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + location = createLocation({ ...location, sourceActor }); + } + + if (!tabExists(getState(), source.id)) { + dispatch(addTab(source, sourceActor)); + } + + dispatch(setSelectedLocation(cx, location, shouldSelectOriginalLocation)); + + await dispatch(loadSourceText(cx, source, sourceActor)); + + await dispatch(setBreakableLines(cx, location)); + + const loadedSource = getSource(getState(), source.id); + + if (!loadedSource) { + // If there was a navigation while we were loading the loadedSource + return; + } + + const sourceTextContent = getSourceTextContent(getState(), location); + + if ( + keepContext && + prefs.autoPrettyPrint && + !getPrettySource(getState(), loadedSource.id) && + canPrettyPrintSource(getState(), location) && + isMinified(source, sourceTextContent) + ) { + await dispatch(togglePrettyPrint(cx, loadedSource.id)); + dispatch(closeTab(cx, loadedSource)); + } + + await dispatch(setSymbols({ cx, location })); + dispatch(setInScopeLines(cx)); + + if (getIsCurrentThreadPaused(getState())) { + await dispatch(mapDisplayNames(cx)); + } + }; +} + +/** + * Select a location while ignoring the currently selected source. + * This will select the generated location even if the currently + * select source is an original source. And the other way around. + * + * @param {Object} cx + * @param {Object} location + * The location to select, object which includes enough + * information to specify a precise source, line and column. + */ +export function selectSpecificLocation(cx, location) { + return selectLocation(cx, location, { keepContext: false }); +} + +/** + * Select the "mapped location". + * + * If the passed location is on a generated source, select the + * related location in the original source. + * If the passed location is on an original source, select the + * related location in the generated source. + */ +export function jumpToMappedLocation(cx, location) { + return async function (thunkArgs) { + const { client, dispatch } = thunkArgs; + if (!client) { + return null; + } + + // Map to either an original or a generated source location + const pairedLocation = await getRelatedMapLocation(location, thunkArgs); + + return dispatch(selectSpecificLocation(cx, pairedLocation)); + }; +} + +// This is only used by tests +export function jumpToMappedSelectedLocation(cx) { + return async function ({ dispatch, getState }) { + const location = getSelectedLocation(getState()); + if (!location) { + return; + } + + await dispatch(jumpToMappedLocation(cx, location)); + }; +} diff --git a/devtools/client/debugger/src/actions/sources/symbols.js b/devtools/client/debugger/src/actions/sources/symbols.js new file mode 100644 index 0000000000..5a1fb1f967 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/symbols.js @@ -0,0 +1,44 @@ +/* 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 { getSymbols } from "../../selectors"; + +import { PROMISE } from "../utils/middleware/promise"; +import { loadSourceText } from "./loadSourceText"; + +import { memoizeableAction } from "../../utils/memoizableAction"; +import { fulfilled } from "../../utils/async-value"; + +async function doSetSymbols( + cx, + location, + { dispatch, getState, parserWorker } +) { + await dispatch(loadSourceText(cx, location.source, location.sourceActor)); + + await dispatch({ + type: "SET_SYMBOLS", + cx, + location, + [PROMISE]: parserWorker.getSymbols(location.sourceId), + }); +} + +export const setSymbols = memoizeableAction("setSymbols", { + getValue: ({ location }, { getState, parserWorker }) => { + if (!parserWorker.isLocationSupported(location)) { + return fulfilled(null); + } + + const symbols = getSymbols(getState(), location); + if (!symbols) { + return null; + } + + return fulfilled(symbols); + }, + createKey: ({ location }) => location.sourceId, + action: ({ cx, location }, thunkArgs) => + doSetSymbols(cx, location, thunkArgs), +}); diff --git a/devtools/client/debugger/src/actions/sources/tests/blackbox.spec.js b/devtools/client/debugger/src/actions/sources/tests/blackbox.spec.js new file mode 100644 index 0000000000..2ff8420b23 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/blackbox.spec.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/>. */ + +import { + actions, + selectors, + createStore, + makeSource, +} from "../../../utils/test-head"; + +import { initialSourceBlackBoxState } from "../../../reducers/source-blackbox"; + +describe("blackbox", () => { + it("should blackbox and unblackbox a source based on the current state of the source ", async () => { + const store = createStore({ + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }); + const { dispatch, getState, cx } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + await dispatch(actions.toggleBlackBox(cx, fooSource)); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + let blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual([]); + + await dispatch(actions.toggleBlackBox(cx, fooSource)); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + }); + + it("should blackbox and unblackbox a source when explicilty specified", async () => { + const store = createStore({ + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }); + const { dispatch, getState, cx } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + + // check the state before trying to blackbox + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + let blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + + // should blackbox the whole source + await dispatch(actions.toggleBlackBox(cx, fooSource, true, [])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual([]); + + // should unblackbox the whole source + await dispatch(actions.toggleBlackBox(cx, fooSource, false, [])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + }); + + it("should blackbox and unblackbox lines in a source", async () => { + const store = createStore({ + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }); + const { dispatch, getState, cx } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + + const range1 = { + start: { line: 10, column: 3 }, + end: { line: 15, column: 4 }, + }; + + const range2 = { + start: { line: 5, column: 3 }, + end: { line: 7, column: 6 }, + }; + + await dispatch(actions.toggleBlackBox(cx, fooSource, true, [range1])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + let blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual([range1]); + + // add new blackbox lines in the second range + await dispatch(actions.toggleBlackBox(cx, fooSource, true, [range2])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + // ranges are stored asc order + expect(blackboxRanges[fooSource.url]).toEqual([range2, range1]); + + // un-blackbox lines in the first range + await dispatch(actions.toggleBlackBox(cx, fooSource, false, [range1])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual([range2]); + + // un-blackbox lines in the second range + await dispatch(actions.toggleBlackBox(cx, fooSource, false, [range2])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + }); + + it("should undo blackboxed lines when whole source unblackboxed", async () => { + const store = createStore({ + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }); + const { dispatch, getState, cx } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + + const range1 = { + start: { line: 1, column: 5 }, + end: { line: 3, column: 4 }, + }; + + const range2 = { + start: { line: 5, column: 3 }, + end: { line: 7, column: 6 }, + }; + + await dispatch( + actions.toggleBlackBox(cx, fooSource, true, [range1, range2]) + ); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + let blackboxRanges = selectors.getBlackBoxRanges(getState()); + // The ranges are ordered in based on the lines & cols in ascending + expect(blackboxRanges[fooSource.url]).toEqual([range2, range1]); + + // un-blackbox the whole source + await dispatch(actions.toggleBlackBox(cx, fooSource)); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + }); + + it("should restore the blackboxed state correctly debugger load", async () => { + const mockAsyncStoreBlackBoxedRanges = { + "http://localhost:8000/examples/foo": [ + { + start: { line: 1, column: 5 }, + end: { line: 3, column: 4 }, + }, + ], + }; + + function loadInitialState() { + const blackboxedRanges = mockAsyncStoreBlackBoxedRanges; + return { + sourceBlackBox: initialSourceBlackBoxState({ blackboxedRanges }), + }; + } + const store = createStore( + { + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }, + loadInitialState() + ); + const { dispatch, getState } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + const blackboxRanges = selectors.getBlackBoxRanges(getState()); + const mockFooSourceRange = mockAsyncStoreBlackBoxedRanges[fooSource.url]; + expect(blackboxRanges[fooSource.url]).toEqual(mockFooSourceRange); + }); + + it("should unblackbox lines after blackboxed state has been restored", async () => { + const mockAsyncStoreBlackBoxedRanges = { + "http://localhost:8000/examples/foo": [ + { + start: { line: 1, column: 5 }, + end: { line: 3, column: 4 }, + }, + ], + }; + + function loadInitialState() { + const blackboxedRanges = mockAsyncStoreBlackBoxedRanges; + return { + sourceBlackBox: initialSourceBlackBoxState({ blackboxedRanges }), + }; + } + const store = createStore( + { + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }, + loadInitialState() + ); + const { dispatch, getState, cx } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + let blackboxRanges = selectors.getBlackBoxRanges(getState()); + const mockFooSourceRange = mockAsyncStoreBlackBoxedRanges[fooSource.url]; + expect(blackboxRanges[fooSource.url]).toEqual(mockFooSourceRange); + + //unblackbox the blackboxed line + await dispatch( + actions.toggleBlackBox(cx, fooSource, false, mockFooSourceRange) + ); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + }); +}); diff --git a/devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js b/devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js new file mode 100644 index 0000000000..f81fc856dd --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js @@ -0,0 +1,363 @@ +/* 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, + watchForState, + createStore, + makeOriginalSource, + makeSource, +} from "../../../utils/test-head"; +import { + createSource, + mockCommandClient, +} from "../../tests/helpers/mockCommandClient"; +import { getBreakpointsList } from "../../../selectors"; +import { isFulfilled, isRejected } from "../../../utils/async-value"; +import { createLocation } from "../../../utils/location"; + +describe("loadGeneratedSourceText", () => { + it("should load source text", async () => { + const store = createStore(mockCommandClient); + const { dispatch, getState, cx } = store; + + const foo1Source = await dispatch( + actions.newGeneratedSource(makeSource("foo1")) + ); + const foo1SourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + foo1Source.id + ); + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: foo1SourceActor, + }) + ); + + const foo1Content = selectors.getSettledSourceTextContent( + getState(), + createLocation({ + source: foo1Source, + sourceActor: foo1SourceActor, + }) + ); + + expect( + foo1Content && + isFulfilled(foo1Content) && + foo1Content.value.type === "text" + ? foo1Content.value.value.indexOf("return foo1") + : -1 + ).not.toBe(-1); + + const foo2Source = await dispatch( + actions.newGeneratedSource(makeSource("foo2")) + ); + const foo2SourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + foo2Source.id + ); + + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: foo2SourceActor, + }) + ); + + const foo2Content = selectors.getSettledSourceTextContent( + getState(), + createLocation({ + source: foo2Source, + sourceActor: foo2SourceActor, + }) + ); + + expect( + foo2Content && + isFulfilled(foo2Content) && + foo2Content.value.type === "text" + ? foo2Content.value.value.indexOf("return foo2") + : -1 + ).not.toBe(-1); + }); + + it("should update breakpoint text when a source loads", async () => { + const fooOrigContent = createSource("fooOrig", "var fooOrig = 42;"); + const fooGenContent = createSource("fooGen", "var fooGen = 42;"); + + const store = createStore( + { + ...mockCommandClient, + sourceContents: async () => fooGenContent, + getSourceActorBreakpointPositions: async () => ({ 1: [0] }), + getSourceActorBreakableLines: async () => [], + }, + {}, + { + getGeneratedRangesForOriginal: async () => [ + { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } }, + ], + getOriginalLocations: async items => + items.map(item => ({ + ...item, + sourceId: + item.sourceId === fooGenSource1.id + ? fooOrigSources1[0].id + : fooOrigSources2[0].id, + })), + getOriginalSourceText: async s => ({ + text: fooOrigContent.source, + contentType: fooOrigContent.contentType, + }), + } + ); + const { cx, dispatch, getState } = store; + + const fooGenSource1 = await dispatch( + actions.newGeneratedSource(makeSource("fooGen1")) + ); + + const fooOrigSources1 = await dispatch( + actions.newOriginalSources([makeOriginalSource(fooGenSource1)]) + ); + const fooGenSource2 = await dispatch( + actions.newGeneratedSource(makeSource("fooGen2")) + ); + + const fooOrigSources2 = await dispatch( + actions.newOriginalSources([makeOriginalSource(fooGenSource2)]) + ); + + await dispatch( + actions.loadOriginalSourceText({ + cx, + source: fooOrigSources1[0], + }) + ); + + await dispatch( + actions.addBreakpoint( + cx, + createLocation({ + source: fooOrigSources1[0], + line: 1, + column: 0, + }), + {} + ) + ); + + const breakpoint1 = getBreakpointsList(getState())[0]; + expect(breakpoint1.text).toBe(""); + expect(breakpoint1.originalText).toBe("var fooOrig = 42;"); + + const fooGenSource1SourceActor = + selectors.getFirstSourceActorForGeneratedSource( + getState(), + fooGenSource1.id + ); + + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: fooGenSource1SourceActor, + }) + ); + + const breakpoint2 = getBreakpointsList(getState())[0]; + expect(breakpoint2.text).toBe("var fooGen = 42;"); + expect(breakpoint2.originalText).toBe("var fooOrig = 42;"); + + const fooGenSource2SourceActor = + selectors.getFirstSourceActorForGeneratedSource( + getState(), + fooGenSource2.id + ); + + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: fooGenSource2SourceActor, + }) + ); + + await dispatch( + actions.addBreakpoint( + cx, + createLocation({ + source: fooGenSource2, + line: 1, + column: 0, + }), + {} + ) + ); + + const breakpoint3 = getBreakpointsList(getState())[1]; + expect(breakpoint3.text).toBe("var fooGen = 42;"); + expect(breakpoint3.originalText).toBe(""); + + await dispatch( + actions.loadOriginalSourceText({ + cx, + source: fooOrigSources2[0], + }) + ); + + const breakpoint4 = getBreakpointsList(getState())[1]; + expect(breakpoint4.text).toBe("var fooGen = 42;"); + expect(breakpoint4.originalText).toBe("var fooOrig = 42;"); + }); + + it("loads two sources w/ one request", async () => { + let resolve; + let count = 0; + const { dispatch, getState, cx } = createStore({ + sourceContents: () => + new Promise(r => { + count++; + resolve = r; + }), + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], + }); + const id = "foo"; + + const source = await dispatch(actions.newGeneratedSource(makeSource(id))); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + const loading = dispatch( + actions.loadGeneratedSourceText({ cx, sourceActor }) + ); + + if (!resolve) { + throw new Error("no resolve"); + } + resolve({ source: "yay", contentType: "text/javascript" }); + await loading; + expect(count).toEqual(1); + + const content = selectors.getSettledSourceTextContent( + getState(), + createLocation({ + source, + sourceActor, + }) + ); + expect( + content && + isFulfilled(content) && + content.value.type === "text" && + content.value.value + ).toEqual("yay"); + }); + + it("doesn't re-load loaded sources", async () => { + let resolve; + let count = 0; + const { dispatch, getState, cx } = createStore({ + sourceContents: () => + new Promise(r => { + count++; + resolve = r; + }), + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], + }); + const id = "foo"; + + const source = await dispatch(actions.newGeneratedSource(makeSource(id))); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + const loading = dispatch( + actions.loadGeneratedSourceText({ cx, sourceActor }) + ); + + if (!resolve) { + throw new Error("no resolve"); + } + resolve({ source: "yay", contentType: "text/javascript" }); + await loading; + + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + expect(count).toEqual(1); + + const content = selectors.getSettledSourceTextContent( + getState(), + createLocation({ + source, + sourceActor, + }) + ); + expect( + content && + isFulfilled(content) && + content.value.type === "text" && + content.value.value + ).toEqual("yay"); + }); + + it("should indicate a loading source", async () => { + const store = createStore(mockCommandClient); + const { dispatch, cx, getState } = store; + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo2")) + ); + + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + const wasLoading = watchForState(store, state => { + return !selectors.getSettledSourceTextContent( + state, + createLocation({ + source, + sourceActor, + }) + ); + }); + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + expect(wasLoading()).toBe(true); + }); + + it("should indicate an errored source text", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("bad-id")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + const content = selectors.getSettledSourceTextContent( + getState(), + createLocation({ + source, + sourceActor, + }) + ); + expect( + content && isRejected(content) && typeof content.value === "string" + ? content.value.indexOf("sourceContents failed") + : -1 + ).not.toBe(-1); + }); +}); diff --git a/devtools/client/debugger/src/actions/sources/tests/newSources.spec.js b/devtools/client/debugger/src/actions/sources/tests/newSources.spec.js new file mode 100644 index 0000000000..730c5b32eb --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/newSources.spec.js @@ -0,0 +1,172 @@ +/* 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, + makeSourceURL, + makeOriginalSource, + waitForState, +} from "../../../utils/test-head"; +const { getSource, getSourceCount, getSelectedSource, getSourceByURL } = + selectors; +import sourceQueue from "../../../utils/source-queue"; +import { generatedToOriginalId } from "devtools/client/shared/source-map-loader/index"; + +import { mockCommandClient } from "../../tests/helpers/mockCommandClient"; + +describe("sources - new sources", () => { + it("should add sources to state", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + await dispatch(actions.newGeneratedSource(makeSource("base.js"))); + await dispatch(actions.newGeneratedSource(makeSource("jquery.js"))); + + expect(getSourceCount(getState())).toEqual(2); + const base = getSource(getState(), "base.js"); + const jquery = getSource(getState(), "jquery.js"); + expect(base && base.id).toEqual("base.js"); + expect(jquery && jquery.id).toEqual("jquery.js"); + }); + + it("should not add multiple identical generated sources", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + + const generated = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + + await dispatch(actions.newOriginalSources([makeOriginalSource(generated)])); + await dispatch(actions.newOriginalSources([makeOriginalSource(generated)])); + + expect(getSourceCount(getState())).toEqual(2); + }); + + it("should not add multiple identical original sources", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + + await dispatch(actions.newGeneratedSource(makeSource("base.js"))); + await dispatch(actions.newGeneratedSource(makeSource("base.js"))); + + expect(getSourceCount(getState())).toEqual(1); + }); + + it("should automatically select a pending source", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + const baseSourceURL = makeSourceURL("base.js"); + await dispatch(actions.selectSourceURL(cx, baseSourceURL)); + + expect(getSelectedSource(getState())).toBe(undefined); + const baseSource = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + + const selected = getSelectedSource(getState()); + expect(selected && selected.url).toBe(baseSource.url); + }); + + it("should add original sources", async () => { + const { dispatch, getState } = createStore( + mockCommandClient, + {}, + { + getOriginalURLs: async source => [ + { + id: generatedToOriginalId(source.id, "magic.js"), + url: "magic.js", + }, + ], + getOriginalLocations: async items => items, + getOriginalLocation: location => location, + } + ); + + await dispatch( + actions.newGeneratedSource( + makeSource("base.js", { sourceMapURL: "base.js.map" }) + ) + ); + const magic = getSourceByURL(getState(), "magic.js"); + expect(magic && magic.url).toEqual("magic.js"); + }); + + // eslint-disable-next-line + it("should not attempt to fetch original sources if it's missing a source map url", async () => { + const getOriginalURLs = jest.fn(); + const { dispatch } = createStore( + mockCommandClient, + {}, + { + getOriginalURLs, + getOriginalLocations: async items => items, + getOriginalLocation: location => location, + } + ); + + await dispatch(actions.newGeneratedSource(makeSource("base.js"))); + expect(getOriginalURLs).not.toHaveBeenCalled(); + }); + + // eslint-disable-next-line + it("should process new sources immediately, without waiting for source maps to be fetched first", async () => { + const { dispatch, getState } = createStore( + mockCommandClient, + {}, + { + getOriginalURLs: async () => new Promise(_ => {}), + getOriginalLocations: async items => items, + getOriginalLocation: location => location, + } + ); + await dispatch( + actions.newGeneratedSource( + makeSource("base.js", { sourceMapURL: "base.js.map" }) + ) + ); + expect(getSourceCount(getState())).toEqual(1); + const base = getSource(getState(), "base.js"); + expect(base && base.id).toEqual("base.js"); + }); + + // eslint-disable-next-line + it("shouldn't let one slow loading source map delay all the other source maps", async () => { + const dbg = createStore( + mockCommandClient, + {}, + { + getOriginalURLs: async source => { + if (source.id == "foo.js") { + // simulate a hang loading foo.js.map + return new Promise(_ => {}); + } + const url = source.id.replace(".js", ".cljs"); + return [ + { + id: generatedToOriginalId(source.id, url), + url, + }, + ]; + }, + getOriginalLocations: async items => items, + getGeneratedLocation: location => location, + } + ); + const { dispatch, getState } = dbg; + await dispatch( + actions.newGeneratedSources([ + makeSource("foo.js", { sourceMapURL: "foo.js.map" }), + makeSource("bar.js", { sourceMapURL: "bar.js.map" }), + makeSource("bazz.js", { sourceMapURL: "bazz.js.map" }), + ]) + ); + await sourceQueue.flush(); + await waitForState(dbg, state => getSourceCount(state) == 5); + expect(getSourceCount(getState())).toEqual(5); + const barCljs = getSourceByURL(getState(), "bar.cljs"); + expect(barCljs && barCljs.url).toEqual("bar.cljs"); + const bazzCljs = getSourceByURL(getState(), "bazz.cljs"); + expect(bazzCljs && bazzCljs.url).toEqual("bazz.cljs"); + }); +}); diff --git a/devtools/client/debugger/src/actions/sources/tests/select.spec.js b/devtools/client/debugger/src/actions/sources/tests/select.spec.js new file mode 100644 index 0000000000..3fcf24f2b7 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/select.spec.js @@ -0,0 +1,288 @@ +/* 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, + createSourceObject, + makeFrame, + makeSource, + makeSourceURL, + waitForState, + makeOriginalSource, +} from "../../../utils/test-head"; +import { + getSource, + getSourceCount, + getSelectedSource, + getSourceTabs, + getSelectedLocation, + getSymbols, +} from "../../../selectors/"; +import { createLocation } from "../../../utils/location"; + +import { mockCommandClient } from "../../tests/helpers/mockCommandClient"; + +process.on("unhandledRejection", (reason, p) => {}); + +function initialLocation(sourceId) { + return createLocation({ source: createSourceObject(sourceId), line: 1 }); +} + +describe("sources", () => { + it("should select a source", async () => { + // Note that we pass an empty client in because the action checks + // if it exists. + const store = createStore(mockCommandClient); + const { dispatch, getState } = store; + + const frame = makeFrame({ id: "1", sourceId: "foo1" }); + + const baseSource = await dispatch( + actions.newGeneratedSource(makeSource("foo1")) + ); + await dispatch( + actions.paused({ + thread: "FakeThread", + why: { type: "debuggerStatement" }, + frame, + frames: [frame], + }) + ); + + const cx = selectors.getThreadContext(getState()); + await dispatch( + actions.selectLocation( + cx, + createLocation({ source: baseSource, line: 1, column: 5 }) + ) + ); + + const selectedSource = getSelectedSource(getState()); + if (!selectedSource) { + throw new Error("bad selectedSource"); + } + expect(selectedSource.id).toEqual("foo1"); + + const source = getSource(getState(), selectedSource.id); + if (!source) { + throw new Error("bad source"); + } + expect(source.id).toEqual("foo1"); + }); + + it("should select next tab on tab closed if no previous tab", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + await dispatch(actions.newGeneratedSource(makeSource("bar.js"))); + await dispatch(actions.newGeneratedSource(makeSource("baz.js"))); + + // 3rd tab + await dispatch(actions.selectLocation(cx, initialLocation("foo.js"))); + + // 2nd tab + await dispatch(actions.selectLocation(cx, initialLocation("bar.js"))); + + // 1st tab + await dispatch(actions.selectLocation(cx, initialLocation("baz.js"))); + + // 3rd tab is reselected + await dispatch(actions.selectLocation(cx, initialLocation("foo.js"))); + + // closes the 1st tab, which should have no previous tab + await dispatch(actions.closeTab(cx, fooSource)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe("bar.js"); + expect(getSourceTabs(getState())).toHaveLength(2); + }); + + it("should open a tab for the source", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + await dispatch(actions.newGeneratedSource(makeSource("foo.js"))); + await dispatch(actions.selectLocation(cx, initialLocation("foo.js"))); + + const tabs = getSourceTabs(getState()); + expect(tabs).toHaveLength(1); + expect(tabs[0].url).toEqual("http://localhost:8000/examples/foo.js"); + }); + + it("should select previous tab on tab closed", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + await dispatch(actions.newGeneratedSource(makeSource("foo.js"))); + await dispatch(actions.newGeneratedSource(makeSource("bar.js"))); + + const bazSource = await dispatch( + actions.newGeneratedSource(makeSource("baz.js")) + ); + + await dispatch(actions.selectLocation(cx, initialLocation("foo.js"))); + await dispatch(actions.selectLocation(cx, initialLocation("bar.js"))); + await dispatch(actions.selectLocation(cx, initialLocation("baz.js"))); + await dispatch(actions.closeTab(cx, bazSource)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe("bar.js"); + expect(getSourceTabs(getState())).toHaveLength(2); + }); + + it("should keep the selected source when other tab closed", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + await dispatch(actions.newGeneratedSource(makeSource("foo.js"))); + await dispatch(actions.newGeneratedSource(makeSource("bar.js"))); + const bazSource = await dispatch( + actions.newGeneratedSource(makeSource("baz.js")) + ); + + // 3rd tab + await dispatch(actions.selectLocation(cx, initialLocation("foo.js"))); + + // 2nd tab + await dispatch(actions.selectLocation(cx, initialLocation("bar.js"))); + + // 1st tab + await dispatch(actions.selectLocation(cx, initialLocation("baz.js"))); + + // 3rd tab is reselected + await dispatch(actions.selectLocation(cx, initialLocation("foo.js"))); + await dispatch(actions.closeTab(cx, bazSource)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe("foo.js"); + expect(getSourceTabs(getState())).toHaveLength(2); + }); + + it("should not select new sources that lack a URL", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + + await dispatch( + actions.newGeneratedSource({ + ...makeSource("foo"), + url: "", + }) + ); + + expect(getSourceCount(getState())).toEqual(1); + const selectedLocation = getSelectedLocation(getState()); + expect(selectedLocation).toEqual(undefined); + }); + + it("sets and clears selected location correctly", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + const source = await dispatch( + actions.newGeneratedSource(makeSource("testSource")) + ); + const location = createLocation({ source }); + + // set value + dispatch(actions.setSelectedLocation(cx, location)); + expect(getSelectedLocation(getState())).toEqual({ + sourceId: source.id, + ...location, + }); + + // clear value + dispatch(actions.clearSelectedLocation(cx)); + expect(getSelectedLocation(getState())).toEqual(null); + }); + + it("sets and clears pending selected location correctly", () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + const url = "testURL"; + const options = { line: "testLine", column: "testColumn" }; + + // set value + dispatch(actions.setPendingSelectedLocation(cx, url, options)); + const setResult = getState().sources.pendingSelectedLocation; + expect(setResult).toEqual({ + url, + line: options.line, + column: options.column, + }); + + // clear value + dispatch(actions.clearSelectedLocation(cx)); + const clearResult = getState().sources.pendingSelectedLocation; + expect(clearResult).toEqual({ url: "" }); + }); + + it("should keep the generated the viewing context", async () => { + const store = createStore(mockCommandClient); + const { dispatch, getState, cx } = store; + const baseSource = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + baseSource.id + ); + + const location = createLocation({ + source: baseSource, + line: 1, + sourceActor, + }); + await dispatch(actions.selectLocation(cx, location)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe(baseSource.id); + await waitForState(store, state => getSymbols(state, location)); + }); + + it("should change the original the viewing context", async () => { + const { dispatch, getState, cx } = createStore( + mockCommandClient, + {}, + { + getOriginalLocation: async location => ({ ...location, line: 12 }), + getOriginalLocations: async items => items, + getGeneratedRangesForOriginal: async () => [], + getOriginalSourceText: async () => ({ text: "" }), + } + ); + + const baseGenSource = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + + const baseSources = await dispatch( + actions.newOriginalSources([makeOriginalSource(baseGenSource)]) + ); + await dispatch(actions.selectSource(cx, baseSources[0])); + + await dispatch( + actions.selectSpecificLocation( + cx, + createLocation({ + source: baseSources[0], + line: 1, + }) + ) + ); + + const selected = getSelectedLocation(getState()); + expect(selected && selected.line).toBe(1); + }); + + describe("selectSourceURL", () => { + it("should automatically select a pending source", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + const baseSourceURL = makeSourceURL("base.js"); + await dispatch(actions.selectSourceURL(cx, baseSourceURL)); + + expect(getSelectedSource(getState())).toBe(undefined); + const baseSource = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + + const selected = getSelectedSource(getState()); + expect(selected && selected.url).toBe(baseSource.url); + }); + }); +}); diff --git a/devtools/client/debugger/src/actions/tabs.js b/devtools/client/debugger/src/actions/tabs.js new file mode 100644 index 0000000000..1b3c0d3f43 --- /dev/null +++ b/devtools/client/debugger/src/actions/tabs.js @@ -0,0 +1,76 @@ +/* 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/>. */ + +/** + * Redux actions for the editor tabs + * @module actions/tabs + */ + +import { removeDocument } from "../utils/editor"; +import { selectSource } from "./sources"; + +import { + getSourceByURL, + getSourceTabs, + getNewSelectedSource, +} from "../selectors"; + +export function addTab(source, sourceActor) { + return { + type: "ADD_TAB", + source, + sourceActor, + }; +} + +export function moveTab(url, tabIndex) { + return { + type: "MOVE_TAB", + url, + tabIndex, + }; +} + +export function moveTabBySourceId(sourceId, tabIndex) { + return { + type: "MOVE_TAB_BY_SOURCE_ID", + sourceId, + tabIndex, + }; +} + +/** + * @memberof actions/tabs + * @static + */ +export function closeTab(cx, source, reason = "click") { + return ({ dispatch, getState, client }) => { + removeDocument(source.id); + + const tabs = getSourceTabs(getState()); + dispatch({ type: "CLOSE_TAB", source }); + + const newSource = getNewSelectedSource(getState(), tabs); + dispatch(selectSource(cx, newSource)); + }; +} + +/** + * @memberof actions/tabs + * @static + */ +export function closeTabs(cx, urls) { + return ({ dispatch, getState, client }) => { + const sources = urls + .map(url => getSourceByURL(getState(), url)) + .filter(Boolean); + + const tabs = getSourceTabs(getState()); + sources.map(source => removeDocument(source.id)); + dispatch({ type: "CLOSE_TABS", sources }); + + const source = getNewSelectedSource(getState(), tabs); + dispatch(selectSource(cx, source)); + }; +} diff --git a/devtools/client/debugger/src/actions/tests/__snapshots__/expressions.spec.js.snap b/devtools/client/debugger/src/actions/tests/__snapshots__/expressions.spec.js.snap new file mode 100644 index 0000000000..f27eb26f50 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/__snapshots__/expressions.spec.js.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`expressions should get the autocomplete matches for the input 1`] = ` +Array [ + "toLocaleString", + "toSource", + "toString", + "toolbar", + "top", +] +`; diff --git a/devtools/client/debugger/src/actions/tests/__snapshots__/pending-breakpoints.spec.js.snap b/devtools/client/debugger/src/actions/tests/__snapshots__/pending-breakpoints.spec.js.snap new file mode 100644 index 0000000000..741e45d6d8 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/__snapshots__/pending-breakpoints.spec.js.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`initializing when pending breakpoints exist in prefs syncs pending breakpoints 1`] = ` +Object { + "http://localhost:8000/examples/bar.js:5:2": Object { + "astLocation": Object { + "index": 0, + "name": undefined, + "offset": Object { + "line": 5, + }, + }, + "disabled": false, + "generatedLocation": Object { + "column": 2, + "line": 5, + "source": Object { + "id": "", + "url": "http://localhost:8000/examples/bar.js", + }, + "sourceActor": null, + "sourceActorId": undefined, + "sourceId": "", + "sourceUrl": "http://localhost:8000/examples/bar.js", + }, + "location": Object { + "column": 2, + "line": 5, + "source": Object { + "id": "", + "url": "http://localhost:8000/examples/bar.js", + }, + "sourceActor": null, + "sourceActorId": undefined, + "sourceId": "", + "sourceUrl": "http://localhost:8000/examples/bar.js", + }, + "options": Object { + "condition": null, + "hidden": false, + }, + }, +} +`; diff --git a/devtools/client/debugger/src/actions/tests/__snapshots__/preview.spec.js.snap b/devtools/client/debugger/src/actions/tests/__snapshots__/preview.spec.js.snap new file mode 100644 index 0000000000..026bfe4a89 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/__snapshots__/preview.spec.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`preview should generate previews 1`] = `null`; diff --git a/devtools/client/debugger/src/actions/tests/expressions.spec.js b/devtools/client/debugger/src/actions/tests/expressions.spec.js new file mode 100644 index 0000000000..48b06ebd1a --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/expressions.spec.js @@ -0,0 +1,184 @@ +/* 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, +} from "../../utils/test-head"; + +import { makeMockFrame } from "../../utils/test-mockup"; + +const mockThreadFront = { + evaluate: (script, { frameId }) => + new Promise((resolve, reject) => { + if (!frameId) { + resolve("bla"); + } else { + resolve("boo"); + } + }), + evaluateExpressions: (inputs, { frameId }) => + Promise.all( + inputs.map( + input => + new Promise((resolve, reject) => { + if (!frameId) { + resolve("bla"); + } else { + resolve("boo"); + } + }) + ) + ), + getFrameScopes: async () => {}, + getFrames: async () => [], + sourceContents: () => ({ source: "", contentType: "text/javascript" }), + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], + autocomplete: () => { + return new Promise(resolve => { + resolve({ + from: "foo", + matches: ["toLocaleString", "toSource", "toString", "toolbar", "top"], + matchProp: "to", + }); + }); + }, +}; + +describe("expressions", () => { + it("should add an expression", async () => { + const { dispatch, getState, cx } = createStore(mockThreadFront); + + await dispatch(actions.addExpression(cx, "foo")); + expect(selectors.getExpressions(getState())).toHaveLength(1); + }); + + it("should not add empty expressions", () => { + const { dispatch, getState, cx } = createStore(mockThreadFront); + + dispatch(actions.addExpression(cx, undefined)); + dispatch(actions.addExpression(cx, "")); + expect(selectors.getExpressions(getState())).toHaveLength(0); + }); + + it("should not add invalid expressions", async () => { + const { dispatch, getState, cx } = createStore(mockThreadFront); + await dispatch(actions.addExpression(cx, "foo#")); + const state = getState(); + expect(selectors.getExpressions(state)).toHaveLength(0); + expect(selectors.getExpressionError(state)).toBe(true); + }); + + it("should update an expression", async () => { + const { dispatch, getState, cx } = createStore(mockThreadFront); + + await dispatch(actions.addExpression(cx, "foo")); + const expression = selectors.getExpression(getState(), "foo"); + if (!expression) { + throw new Error("expression must exist"); + } + + await dispatch(actions.updateExpression(cx, "bar", expression)); + const bar = selectors.getExpression(getState(), "bar"); + + expect(bar && bar.input).toBe("bar"); + }); + + it("should not update an expression w/ invalid code", async () => { + const { dispatch, getState, cx } = createStore(mockThreadFront); + + await dispatch(actions.addExpression(cx, "foo")); + const expression = selectors.getExpression(getState(), "foo"); + if (!expression) { + throw new Error("expression must exist"); + } + await dispatch(actions.updateExpression(cx, "#bar", expression)); + expect(selectors.getExpression(getState(), "bar")).toBeUndefined(); + }); + + it("should delete an expression", async () => { + const { dispatch, getState, cx } = createStore(mockThreadFront); + + await dispatch(actions.addExpression(cx, "foo")); + await dispatch(actions.addExpression(cx, "bar")); + expect(selectors.getExpressions(getState())).toHaveLength(2); + + const expression = selectors.getExpression(getState(), "foo"); + + if (!expression) { + throw new Error("expression must exist"); + } + + const bar = selectors.getExpression(getState(), "bar"); + dispatch(actions.deleteExpression(expression)); + expect(selectors.getExpressions(getState())).toHaveLength(1); + expect(bar && bar.input).toBe("bar"); + }); + + it("should evaluate expressions global scope", async () => { + const { dispatch, getState, cx } = createStore(mockThreadFront); + await dispatch(actions.addExpression(cx, "foo")); + await dispatch(actions.addExpression(cx, "bar")); + + let foo = selectors.getExpression(getState(), "foo"); + let bar = selectors.getExpression(getState(), "bar"); + expect(foo && foo.value).toBe("bla"); + expect(bar && bar.value).toBe("bla"); + + await dispatch(actions.evaluateExpressions(cx)); + foo = selectors.getExpression(getState(), "foo"); + bar = selectors.getExpression(getState(), "bar"); + expect(foo && foo.value).toBe("bla"); + expect(bar && bar.value).toBe("bla"); + }); + + it("should evaluate expressions in specific scope", async () => { + const { dispatch, getState } = createStore(mockThreadFront); + await createFrames(getState, dispatch); + + const cx = selectors.getThreadContext(getState()); + await dispatch(actions.newGeneratedSource(makeSource("source"))); + await dispatch(actions.addExpression(cx, "foo")); + await dispatch(actions.addExpression(cx, "bar")); + + let foo = selectors.getExpression(getState(), "foo"); + let bar = selectors.getExpression(getState(), "bar"); + expect(foo && foo.value).toBe("boo"); + expect(bar && bar.value).toBe("boo"); + + await dispatch(actions.evaluateExpressions(cx)); + foo = selectors.getExpression(getState(), "foo"); + bar = selectors.getExpression(getState(), "bar"); + expect(foo && foo.value).toBe("boo"); + expect(bar && bar.value).toBe("boo"); + }); + + it("should get the autocomplete matches for the input", async () => { + const { cx, dispatch, getState } = createStore(mockThreadFront); + await dispatch(actions.autocomplete(cx, "to", 2)); + expect(selectors.getAutocompleteMatchset(getState())).toMatchSnapshot(); + }); +}); + +async function createFrames(getState, dispatch) { + const frame = makeMockFrame(); + await dispatch(actions.newGeneratedSource(makeSource("example.js"))); + await dispatch(actions.newGeneratedSource(makeSource("source"))); + + await dispatch( + actions.paused({ + thread: "FakeThread", + frame, + frames: [frame], + why: { type: "just because" }, + }) + ); + + await dispatch( + actions.selectFrame(selectors.getThreadContext(getState()), frame) + ); +} diff --git a/devtools/client/debugger/src/actions/tests/fixtures/immutable.js b/devtools/client/debugger/src/actions/tests/fixtures/immutable.js new file mode 100644 index 0000000000..e8ac7fb233 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/fixtures/immutable.js @@ -0,0 +1,2 @@ + +const m = Immutable.Map({a: 2}) diff --git a/devtools/client/debugger/src/actions/tests/fixtures/reactComponent.js b/devtools/client/debugger/src/actions/tests/fixtures/reactComponent.js new file mode 100644 index 0000000000..526c852d99 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/fixtures/reactComponent.js @@ -0,0 +1,7 @@ +import React, { Component } from "react"; + +class FixtureComponent extends Component { + render() { + return null; + } +} diff --git a/devtools/client/debugger/src/actions/tests/fixtures/reactFuncComponent.js b/devtools/client/debugger/src/actions/tests/fixtures/reactFuncComponent.js new file mode 100644 index 0000000000..3103161fe0 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/fixtures/reactFuncComponent.js @@ -0,0 +1,5 @@ +import React, { Component } from "react"; + +export default FixtureComponent = (props) => { + return <div>props.a</div>; +} diff --git a/devtools/client/debugger/src/actions/tests/fixtures/scopes.js b/devtools/client/debugger/src/actions/tests/fixtures/scopes.js new file mode 100644 index 0000000000..3a38097f5e --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/fixtures/scopes.js @@ -0,0 +1,11 @@ +// Program Scope + +function outer() { + function inner() { + const x = 1; + } + + const declaration = function() { + const x = 1; + }; +} diff --git a/devtools/client/debugger/src/actions/tests/helpers/breakpoints.js b/devtools/client/debugger/src/actions/tests/helpers/breakpoints.js new file mode 100644 index 0000000000..e0279e5fc1 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/helpers/breakpoints.js @@ -0,0 +1,77 @@ +/* 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 { createLocation } from "../../../utils/location"; + +export function mockPendingBreakpoint(overrides = {}) { + const { sourceUrl, line, column, condition, disabled, hidden } = overrides; + return { + location: createLocation({ + source: { + id: "", + url: sourceUrl || "http://localhost:8000/examples/bar.js", + }, + sourceId: "", + sourceUrl: sourceUrl || "http://localhost:8000/examples/bar.js", + line: line || 5, + column: column || 1, + }), + generatedLocation: createLocation({ + source: { + id: "", + url: sourceUrl || "http://localhost:8000/examples/bar.js", + }, + sourceId: "", + sourceUrl: sourceUrl || "http://localhost:8000/examples/bar.js", + line: line || 5, + column: column || 1, + }), + astLocation: { + name: undefined, + offset: { + line: line || 5, + }, + index: 0, + }, + options: { + condition: condition || null, + hidden: hidden || false, + }, + disabled: disabled || false, + }; +} + +export function generateBreakpoint(filename, line = 5, column = 0) { + return { + id: "breakpoint", + originalText: "", + text: "", + location: createLocation({ + source: { + url: `http://localhost:8000/examples/${filename}`, + id: filename, + }, + sourceUrl: `http://localhost:8000/examples/${filename}`, + sourceId: filename, + line, + column, + }), + generatedLocation: createLocation({ + source: { + url: `http://localhost:8000/examples/${filename}`, + id: filename, + }, + sourceUrl: `http://localhost:8000/examples/${filename}`, + sourceId: filename, + line, + column, + }), + astLocation: undefined, + options: { + condition: "", + hidden: false, + }, + disabled: false, + }; +} diff --git a/devtools/client/debugger/src/actions/tests/helpers/mockCommandClient.js b/devtools/client/debugger/src/actions/tests/helpers/mockCommandClient.js new file mode 100644 index 0000000000..38dd55c274 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/helpers/mockCommandClient.js @@ -0,0 +1,49 @@ +/* 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/>. */ + +export function createSource(name, code) { + name = name.replace(/\..*$/, ""); + return { + source: code || `function ${name}() {\n return ${name} \n}`, + contentType: "text/javascript", + }; +} + +const sources = [ + "a", + "b", + "foo", + "bar", + "foo1", + "foo2", + "a.js", + "baz.js", + "foobar.js", + "barfoo.js", + "foo.js", + "bar.js", + "base.js", + "bazz.js", + "jquery.js", +]; + +export const mockCommandClient = { + sourceContents: function ({ source }) { + return new Promise((resolve, reject) => { + if (sources.includes(source)) { + resolve(createSource(source)); + } + + reject(`unknown source: ${source}`); + }); + }, + setBreakpoint: async () => {}, + removeBreakpoint: _id => Promise.resolve(), + threadFront: async () => {}, + getFrameScopes: async () => {}, + getFrames: async () => [], + evaluateExpressions: async () => {}, + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], +}; diff --git a/devtools/client/debugger/src/actions/tests/helpers/readFixture.js b/devtools/client/debugger/src/actions/tests/helpers/readFixture.js new file mode 100644 index 0000000000..6c23641226 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/helpers/readFixture.js @@ -0,0 +1,14 @@ +/* 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 fs from "fs"; +import path from "path"; + +export default function readFixture(name) { + const text = fs.readFileSync( + path.join(__dirname, `../fixtures/${name}`), + "utf8" + ); + return text; +} diff --git a/devtools/client/debugger/src/actions/tests/navigation.spec.js b/devtools/client/debugger/src/actions/tests/navigation.spec.js new file mode 100644 index 0000000000..e2572fde80 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/navigation.spec.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/>. */ + +import { createStore, selectors, actions } from "../../utils/test-head"; + +jest.mock("../../utils/editor"); + +const { getActiveSearch } = selectors; + +const threadFront = { + sourceContents: async () => ({ + source: "function foo1() {\n const foo = 5; return foo;\n}", + contentType: "text/javascript", + }), + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], +}; + +describe("navigation", () => { + it("navigation removes activeSearch 'file' value", async () => { + const { dispatch, getState } = createStore(threadFront); + dispatch(actions.setActiveSearch("file")); + expect(getActiveSearch(getState())).toBe("file"); + + await dispatch(actions.willNavigate("will-navigate")); + expect(getActiveSearch(getState())).toBe(null); + }); +}); diff --git a/devtools/client/debugger/src/actions/tests/pending-breakpoints.spec.js b/devtools/client/debugger/src/actions/tests/pending-breakpoints.spec.js new file mode 100644 index 0000000000..2baa17a79f --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/pending-breakpoints.spec.js @@ -0,0 +1,294 @@ +/* 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/>. */ + +// TODO: we would like to mock this in the local tests +import { + generateBreakpoint, + mockPendingBreakpoint, +} from "./helpers/breakpoints.js"; + +import { mockCommandClient } from "./helpers/mockCommandClient"; +import { asyncStore } from "../../utils/prefs"; + +function loadInitialState(opts = {}) { + const mockedPendingBreakpoint = mockPendingBreakpoint({ ...opts, column: 2 }); + const l = mockedPendingBreakpoint.location; + const id = `${l.sourceUrl}:${l.line}:${l.column}`; + asyncStore.pendingBreakpoints = { [id]: mockedPendingBreakpoint }; + + return { pendingBreakpoints: asyncStore.pendingBreakpoints }; +} + +jest.mock("../../utils/prefs", () => ({ + prefs: { + clientSourceMapsEnabled: true, + expressions: [], + }, + asyncStore: { + pendingBreakpoints: {}, + }, + clear: jest.fn(), + features: { + inlinePreview: true, + }, +})); + +import { + createStore, + selectors, + actions, + makeSource, + makeSourceURL, + waitForState, +} from "../../utils/test-head"; + +import sourceMapLoader from "devtools/client/shared/source-map-loader/index"; + +function mockClient(bpPos = {}) { + return { + ...mockCommandClient, + setSkipPausing: jest.fn(), + getSourceActorBreakpointPositions: async () => bpPos, + getSourceActorBreakableLines: async () => [], + }; +} + +function mockSourceMaps() { + return { + ...sourceMapLoader, + getOriginalSourceText: async id => ({ + id, + text: "", + contentType: "text/javascript", + }), + getGeneratedRangesForOriginal: async () => [ + { start: { line: 0, column: 0 }, end: { line: 10, column: 10 } }, + ], + getOriginalLocations: async items => items, + }; +} + +describe("when adding breakpoints", () => { + it("a corresponding pending breakpoint should be added", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ 5: [1] }), + loadInitialState(), + mockSourceMaps() + ); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + const bp = generateBreakpoint("foo.js", 5, 1); + + await dispatch(actions.addBreakpoint(cx, bp.location)); + + expect(selectors.getPendingBreakpointList(getState())).toHaveLength(2); + }); +}); + +describe("initializing when pending breakpoints exist in prefs", () => { + it("syncs pending breakpoints", async () => { + const { getState } = createStore( + mockClient({ 5: [0] }), + loadInitialState(), + mockSourceMaps() + ); + const bps = selectors.getPendingBreakpoints(getState()); + expect(bps).toMatchSnapshot(); + }); + + it("re-adding breakpoints update existing pending breakpoints", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ 5: [1, 2] }), + loadInitialState(), + mockSourceMaps() + ); + const bar = generateBreakpoint("bar.js", 5, 1); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + await dispatch(actions.addBreakpoint(cx, bar.location)); + + const bps = selectors.getPendingBreakpointList(getState()); + expect(bps).toHaveLength(2); + }); + + it("adding bps doesn't remove existing pending breakpoints", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ 5: [0] }), + loadInitialState(), + mockSourceMaps() + ); + const bp = generateBreakpoint("foo.js"); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + await dispatch(actions.addBreakpoint(cx, bp.location)); + + const bps = selectors.getPendingBreakpointList(getState()); + expect(bps).toHaveLength(2); + }); +}); + +describe("initializing with disabled pending breakpoints in prefs", () => { + it("syncs breakpoints with pending breakpoints", async () => { + const store = createStore( + mockClient({ 5: [2] }), + loadInitialState({ disabled: true }), + mockSourceMaps() + ); + + const { getState, dispatch, cx } = store; + + const source = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + await waitForState(store, state => { + const bps = selectors.getBreakpointsForSource(state, source.id); + return bps && !!Object.values(bps).length; + }); + + const bp = selectors.getBreakpointsList(getState()).find(({ location }) => { + return ( + location.line == 5 && + location.column == 2 && + location.sourceId == source.id + ); + }); + + if (!bp) { + throw new Error("no bp"); + } + expect(bp.location.sourceId).toEqual(source.id); + expect(bp.disabled).toEqual(true); + }); +}); + +describe("adding sources", () => { + it("corresponding breakpoints are added for a single source", async () => { + const store = createStore( + mockClient({ 5: [2] }), + loadInitialState({ disabled: true }), + mockSourceMaps() + ); + const { getState, dispatch, cx } = store; + + expect(selectors.getBreakpointCount(getState())).toEqual(0); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + await waitForState(store, state => selectors.getBreakpointCount(state) > 0); + + expect(selectors.getBreakpointCount(getState())).toEqual(1); + }); + + it("corresponding breakpoints are added to the original source", async () => { + const sourceURL = makeSourceURL("bar.js"); + const store = createStore(mockClient({ 5: [2] }), loadInitialState(), { + getOriginalURLs: async source => [ + { + id: sourceMapLoader.generatedToOriginalId(source.id, sourceURL), + url: sourceURL, + }, + ], + getOriginalSourceText: async () => ({ text: "" }), + getGeneratedLocation: async location => location, + getOriginalLocation: async location => location, + getGeneratedRangesForOriginal: async () => [ + { start: { line: 0, column: 0 }, end: { line: 10, column: 10 } }, + ], + getOriginalLocations: async items => + items.map(item => ({ + ...item, + sourceId: sourceMapLoader.generatedToOriginalId( + item.sourceId, + sourceURL + ), + })), + }); + + const { getState, dispatch } = store; + + expect(selectors.getBreakpointCount(getState())).toEqual(0); + + await dispatch( + actions.newGeneratedSource(makeSource("bar.js", { sourceMapURL: "foo" })) + ); + + await waitForState(store, state => selectors.getBreakpointCount(state) > 0); + + expect(selectors.getBreakpointCount(getState())).toEqual(1); + }); + + it("add corresponding breakpoints for multiple sources", async () => { + const store = createStore( + mockClient({ 5: [2] }), + loadInitialState({ disabled: true }), + mockSourceMaps() + ); + const { getState, dispatch, cx } = store; + + expect(selectors.getBreakpointCount(getState())).toEqual(0); + + const [source1, source2] = await dispatch( + actions.newGeneratedSources([makeSource("bar.js"), makeSource("foo.js")]) + ); + const sourceActor1 = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source1.id + ); + const sourceActor2 = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source2.id + ); + + await dispatch( + actions.loadGeneratedSourceText({ cx, sourceActor: sourceActor1 }) + ); + await dispatch( + actions.loadGeneratedSourceText({ cx, sourceActor: sourceActor2 }) + ); + + await waitForState(store, state => selectors.getBreakpointCount(state) > 0); + expect(selectors.getBreakpointCount(getState())).toEqual(1); + }); +}); diff --git a/devtools/client/debugger/src/actions/tests/preview.spec.js b/devtools/client/debugger/src/actions/tests/preview.spec.js new file mode 100644 index 0000000000..3b6c9c23ac --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/preview.spec.js @@ -0,0 +1,217 @@ +/* eslint max-nested-callbacks: ["error", 6] */ +/* 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 { + createStore, + selectors, + actions, + makeSource, + makeFrame, + waitForState, + waitATick, +} from "../../utils/test-head"; +import { createLocation } from "../../utils/location"; + +function waitForPreview(store, expression) { + return waitForState(store, state => { + const preview = selectors.getPreview(state); + return preview && preview.expression == expression; + }); +} + +function mockThreadFront(overrides) { + return { + evaluate: async () => ({ result: {} }), + getFrameScopes: async () => {}, + getFrames: async () => [], + sourceContents: async () => ({ + source: "", + contentType: "text/javascript", + }), + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], + evaluateExpressions: async () => [], + loadObjectProperties: async () => ({}), + ...overrides, + }; +} + +function dispatchSetPreview(dispatch, context, expression, target) { + return dispatch( + actions.setPreview( + context, + expression, + { + start: { url: "foo.js", line: 1, column: 2 }, + end: { url: "foo.js", line: 1, column: 5 }, + }, + { line: 2, column: 3 }, + target.getBoundingClientRect(), + target + ) + ); +} + +async function pause(store, client) { + const { dispatch, cx, getState } = store; + const source = makeSource("base.js"); + const base = await dispatch(actions.newGeneratedSource(source)); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + base.id + ); + + await dispatch(actions.selectSource(cx, base, sourceActor)); + const location = createLocation({ source: base, sourceActor }); + await waitForState(store, state => selectors.getSymbols(state, location)); + + const { thread } = cx; + const frames = [makeFrame({ id: "frame1", sourceId: base.id, thread })]; + client.getFrames = async () => frames; + + await dispatch( + actions.paused({ + thread, + frame: frames[0], + loadedObjects: [], + why: { type: "debuggerStatement" }, + }) + ); +} + +describe("preview", () => { + it("should generate previews", async () => { + const store = createStore(mockThreadFront()); + const { dispatch, getState, cx } = store; + const source = makeSource("base.js"); + const base = await dispatch(actions.newGeneratedSource(source)); + + await dispatch(actions.selectSource(cx, base)); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + base.id + ); + const location = createLocation({ source: base, sourceActor }); + + await waitForState(store, state => selectors.getSymbols(state, location)); + const frames = [makeFrame({ id: "f1", sourceId: base.id })]; + + await dispatch( + actions.paused({ + thread: store.cx.thread, + frame: frames[0], + frames, + loadedObjects: [], + why: { type: "debuggerStatement" }, + }) + ); + + const newCx = selectors.getContext(getState()); + const firstTarget = document.createElement("div"); + + dispatchSetPreview(dispatch, newCx, "foo", firstTarget); + + expect(selectors.getPreview(getState())).toMatchSnapshot(); + }); + + // When a 2nd setPreview is called before a 1st setPreview dispatches + // and the 2nd setPreview has not dispatched yet, + // the first setPreview should not finish dispatching + it("queued previews (w/ the 1st finishing first)", async () => { + let resolveFirst, resolveSecond; + const promises = [ + new Promise(resolve => { + resolveFirst = resolve; + }), + new Promise(resolve => { + resolveSecond = resolve; + }), + ]; + + const client = mockThreadFront({ + loadObjectProperties: () => promises.shift(), + }); + const store = createStore(client); + + const { dispatch, getState } = store; + await pause(store, client); + + const newCx = selectors.getContext(getState()); + const firstTarget = document.createElement("div"); + const secondTarget = document.createElement("div"); + + // Start the dispatch of the first setPreview. At this point, it will not + // finish execution until we resolve the firstSetPreview + dispatchSetPreview(dispatch, newCx, "firstSetPreview", firstTarget); + + // Start the dispatch of the second setPreview. At this point, it will not + // finish execution until we resolve the secondSetPreview + dispatchSetPreview(dispatch, newCx, "secondSetPreview", secondTarget); + + let fail = false; + + resolveFirst(); + waitForPreview(store, "firstSetPreview").then(() => { + fail = true; + }); + + resolveSecond(); + await waitForPreview(store, "secondSetPreview"); + expect(fail).toEqual(false); + + const preview = selectors.getPreview(getState()); + expect(preview && preview.expression).toEqual("secondSetPreview"); + }); + + // When a 2nd setPreview is called before a 1st setPreview dispatches + // and the 2nd setPreview has dispatched, + // the first setPreview should not finish dispatching + it("queued previews (w/ the 2nd finishing first)", async () => { + let resolveFirst, resolveSecond; + const promises = [ + new Promise(resolve => { + resolveFirst = resolve; + }), + new Promise(resolve => { + resolveSecond = resolve; + }), + ]; + + const client = mockThreadFront({ + loadObjectProperties: () => promises.shift(), + }); + const store = createStore(client); + + const { dispatch, getState } = store; + await pause(store, client); + + const cx = selectors.getThreadContext(getState()); + const firstTarget = document.createElement("div"); + const secondTarget = document.createElement("div"); + + // Start the dispatch of the first setPreview. At this point, it will not + // finish execution until we resolve the firstSetPreview + dispatchSetPreview(dispatch, cx, "firstSetPreview", firstTarget); + + // Start the dispatch of the second setPreview. At this point, it will not + // finish execution until we resolve the secondSetPreview + dispatchSetPreview(dispatch, cx, "secondSetPreview", secondTarget); + + let fail = false; + + resolveSecond(); + await waitForPreview(store, "secondSetPreview"); + + resolveFirst(); + waitForPreview(store, "firstSetPreview").then(() => { + fail = true; + }); + + await waitATick(() => expect(fail).toEqual(false)); + + const preview = selectors.getPreview(getState()); + expect(preview && preview.expression).toEqual("secondSetPreview"); + }); +}); diff --git a/devtools/client/debugger/src/actions/tests/sources-tree.spec.js b/devtools/client/debugger/src/actions/tests/sources-tree.spec.js new file mode 100644 index 0000000000..916b2d015b --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/sources-tree.spec.js @@ -0,0 +1,17 @@ +/* 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 } from "../../utils/test-head"; +const { getExpandedState } = selectors; + +describe("source tree", () => { + it("should set the expanded state", () => { + const { dispatch, getState } = createStore(); + const expandedState = new Set(["foo", "bar"]); + + expect(getExpandedState(getState())).toEqual(new Set([])); + dispatch(actions.setExpandedState(expandedState)); + expect(getExpandedState(getState())).toEqual(expandedState); + }); +}); diff --git a/devtools/client/debugger/src/actions/tests/tabs.spec.js b/devtools/client/debugger/src/actions/tests/tabs.spec.js new file mode 100644 index 0000000000..419e5b7e60 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/tabs.spec.js @@ -0,0 +1,187 @@ +/* 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, +} from "../../utils/test-head"; +const { getSelectedSource, getSourceTabs } = selectors; +import { createLocation } from "../../utils/location"; + +import { mockCommandClient } from "./helpers/mockCommandClient"; + +describe("closing tabs", () => { + it("closing a tab", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 })) + ); + dispatch(actions.closeTab(cx, fooSource)); + + expect(getSelectedSource(getState())).toBe(undefined); + expect(getSourceTabs(getState())).toHaveLength(0); + }); + + it("closing the inactive tab", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + const barSource = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 })) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: barSource, line: 1 })) + ); + dispatch(actions.closeTab(cx, fooSource)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe("bar.js"); + expect(getSourceTabs(getState())).toHaveLength(1); + }); + + it("closing the only tab", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 })) + ); + dispatch(actions.closeTab(cx, fooSource)); + + expect(getSelectedSource(getState())).toBe(undefined); + expect(getSourceTabs(getState())).toHaveLength(0); + }); + + it("closing the active tab", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + const barSource = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 })) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: barSource, line: 1 })) + ); + await dispatch(actions.closeTab(cx, barSource)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe("foo.js"); + expect(getSourceTabs(getState())).toHaveLength(1); + }); + + it("closing many inactive tabs", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + const barSource = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + const bazzSource = await dispatch( + actions.newGeneratedSource(makeSource("bazz.js")) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 })) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: barSource, line: 1 })) + ); + await dispatch( + actions.selectLocation( + cx, + createLocation({ source: bazzSource, line: 1 }) + ) + ); + + const tabs = [ + "http://localhost:8000/examples/foo.js", + "http://localhost:8000/examples/bar.js", + ]; + dispatch(actions.closeTabs(cx, tabs)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe("bazz.js"); + expect(getSourceTabs(getState())).toHaveLength(1); + }); + + it("closing many tabs including the active tab", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + const barSource = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + const bazzSource = await dispatch( + actions.newGeneratedSource(makeSource("bazz.js")) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 })) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: barSource, line: 1 })) + ); + await dispatch( + actions.selectLocation( + cx, + createLocation({ source: bazzSource, line: 1 }) + ) + ); + const tabs = [ + "http://localhost:8000/examples/bar.js", + "http://localhost:8000/examples/bazz.js", + ]; + await dispatch(actions.closeTabs(cx, tabs)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe("foo.js"); + expect(getSourceTabs(getState())).toHaveLength(1); + }); + + it("closing all the tabs", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + const barSource = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 })) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: barSource, line: 1 })) + ); + await dispatch( + actions.closeTabs(cx, [ + "http://localhost:8000/examples/foo.js", + "http://localhost:8000/examples/bar.js", + ]) + ); + + expect(getSelectedSource(getState())).toBe(undefined); + expect(getSourceTabs(getState())).toHaveLength(0); + }); +}); diff --git a/devtools/client/debugger/src/actions/tests/ui.spec.js b/devtools/client/debugger/src/actions/tests/ui.spec.js new file mode 100644 index 0000000000..0e13681a12 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/ui.spec.js @@ -0,0 +1,90 @@ +/* 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 { + createStore, + selectors, + actions, + makeSource, +} from "../../utils/test-head"; +import { createLocation } from "../../utils/location"; +import { mockCommandClient } from "./helpers/mockCommandClient"; + +const { + getActiveSearch, + getFrameworkGroupingState, + getPaneCollapse, + getHighlightedLineRangeForSelectedSource, +} = selectors; + +describe("ui", () => { + it("should toggle the visible state of file search", () => { + const { dispatch, getState } = createStore(); + expect(getActiveSearch(getState())).toBe(null); + dispatch(actions.setActiveSearch("file")); + expect(getActiveSearch(getState())).toBe("file"); + }); + + it("should close file search", () => { + const { dispatch, getState } = createStore(); + expect(getActiveSearch(getState())).toBe(null); + dispatch(actions.setActiveSearch("file")); + dispatch(actions.closeActiveSearch()); + expect(getActiveSearch(getState())).toBe(null); + }); + + it("should toggle the collapse state of a pane", () => { + const { dispatch, getState } = createStore(); + expect(getPaneCollapse(getState(), "start")).toBe(false); + dispatch(actions.togglePaneCollapse("start", true)); + expect(getPaneCollapse(getState(), "start")).toBe(true); + }); + + it("should toggle the collapsed state of frameworks in the callstack", () => { + const { dispatch, getState } = createStore(); + const currentState = getFrameworkGroupingState(getState()); + dispatch(actions.toggleFrameworkGrouping(!currentState)); + expect(getFrameworkGroupingState(getState())).toBe(!currentState); + }); + + it("should highlight lines", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + const base = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + base.id + ); + const cx = selectors.getThreadContext(getState()); + //await dispatch(actions.selectSource(cx, base, sourceActor)); + const location = createLocation({ + source: base, + line: 1, + sourceActor, + }); + await dispatch(actions.selectLocation(cx, location)); + + const range = { start: 3, end: 5, sourceId: base.id }; + dispatch(actions.highlightLineRange(range)); + expect(getHighlightedLineRangeForSelectedSource(getState())).toEqual(range); + }); + + it("should clear highlight lines", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + const base = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + base.id + ); + const cx = selectors.getThreadContext(getState()); + await dispatch(actions.selectSource(cx, base, sourceActor)); + const range = { start: 3, end: 5, sourceId: "2" }; + dispatch(actions.highlightLineRange(range)); + dispatch(actions.clearHighlightLineRange()); + expect(getHighlightedLineRangeForSelectedSource(getState())).toEqual(null); + }); +}); diff --git a/devtools/client/debugger/src/actions/threads.js b/devtools/client/debugger/src/actions/threads.js new file mode 100644 index 0000000000..13f53e7c67 --- /dev/null +++ b/devtools/client/debugger/src/actions/threads.js @@ -0,0 +1,44 @@ +/* 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 { createThread } from "../client/firefox/create"; +import { getSourcesToRemoveForThread } from "../selectors"; + +export function addTarget(targetFront) { + return { type: "INSERT_THREAD", newThread: createThread(targetFront) }; +} + +export function removeTarget(targetFront) { + return ({ getState, dispatch }) => { + const threadActorID = targetFront.targetForm.threadActor; + + // Just before emitting the REMOVE_THREAD action, + // synchronously compute the list of source and source actor objects + // which should be removed as that one target get removed. + // + // The list of source objects isn't trivial to compute as these objects + // are shared across targets/threads. + const { actors, sources } = getSourcesToRemoveForThread( + getState(), + threadActorID + ); + + dispatch({ + type: "REMOVE_THREAD", + threadActorID, + actors, + sources, + }); + }; +} + +export function toggleJavaScriptEnabled(enabled) { + return async ({ dispatch, client }) => { + await client.toggleJavaScriptEnabled(enabled); + dispatch({ + type: "TOGGLE_JAVASCRIPT_ENABLED", + value: enabled, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/toolbox.js b/devtools/client/debugger/src/actions/toolbox.js new file mode 100644 index 0000000000..a343c92863 --- /dev/null +++ b/devtools/client/debugger/src/actions/toolbox.js @@ -0,0 +1,43 @@ +/* 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/>. */ + +/** + * @memberof actions/toolbox + * @static + */ +export function openLink(url) { + return async function ({ panel }) { + return panel.openLink(url); + }; +} + +export function evaluateInConsole(inputString) { + return async ({ panel }) => { + return panel.openConsoleAndEvaluate(inputString); + }; +} + +export function openElementInInspectorCommand(grip) { + return async ({ panel }) => { + return panel.openElementInInspector(grip); + }; +} + +export function openInspector(grip) { + return async ({ panel }) => { + return panel.openInspector(); + }; +} + +export function highlightDomElement(grip) { + return async ({ panel }) => { + return panel.highlightDomElement(grip); + }; +} + +export function unHighlightDomElement(grip) { + return async ({ panel }) => { + return panel.unHighlightDomElement(grip); + }; +} diff --git a/devtools/client/debugger/src/actions/tracing.js b/devtools/client/debugger/src/actions/tracing.js new file mode 100644 index 0000000000..9cbe7bc20e --- /dev/null +++ b/devtools/client/debugger/src/actions/tracing.js @@ -0,0 +1,49 @@ +/* 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 { getIsThreadCurrentlyTracing, getAllThreads } from "../selectors"; +import { PROMISE } from "./utils/middleware/promise"; + +/** + * Toggle ON/OFF Javascript tracing for all targets, + * using the specified log method. + * + * @param {string} logMethod + * Can be "stdout" or "console". See TracerActor. + */ +export function toggleTracing(logMethod) { + return async ({ dispatch, getState, client, panel }) => { + // Check if any of the thread is currently tracing. + // For now, the UI can only toggle all the targets all at once. + const threads = getAllThreads(getState()); + const isTracingEnabled = threads.some(thread => + getIsThreadCurrentlyTracing(getState(), thread.actor) + ); + + // Automatically open the split console when enabling tracing to the console + if (!isTracingEnabled && logMethod == "console") { + await panel.toolbox.openSplitConsole({ focusConsoleInput: false }); + } + + return dispatch({ + type: "TOGGLE_TRACING", + [PROMISE]: isTracingEnabled + ? client.stopTracing() + : client.startTracing(logMethod), + }); + }; +} + +/** + * Called when tracing is toggled ON/OFF on a particular thread. + */ +export function tracingToggled(thread, enabled) { + return ({ dispatch }) => { + dispatch({ + type: "TRACING_TOGGLED", + thread, + enabled, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/ui.js b/devtools/client/debugger/src/actions/ui.js new file mode 100644 index 0000000000..67b2629135 --- /dev/null +++ b/devtools/client/debugger/src/actions/ui.js @@ -0,0 +1,290 @@ +/* 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 { + getActiveSearch, + getPaneCollapse, + getQuickOpenEnabled, + getSource, + getSourceContent, + getMainThread, + getIgnoreListSourceUrls, + getSourceByURL, + getBreakpointsForSource, +} from "../selectors"; +import { selectSource } from "../actions/sources/select"; +import { + getEditor, + getLocationsInViewport, + updateDocuments, +} from "../utils/editor"; +import { blackboxSourceActorsForSource } from "./sources/blackbox"; +import { toggleBreakpoints } from "./breakpoints"; +import { copyToTheClipboard } from "../utils/clipboard"; +import { isFulfilled } from "../utils/async-value"; +import { primaryPaneTabs } from "../constants"; + +export function setPrimaryPaneTab(tabName) { + return { type: "SET_PRIMARY_PANE_TAB", tabName }; +} + +export function closeActiveSearch() { + return { + type: "TOGGLE_ACTIVE_SEARCH", + value: null, + }; +} + +export function setActiveSearch(activeSearch) { + return ({ dispatch, getState }) => { + const activeSearchState = getActiveSearch(getState()); + if (activeSearchState === activeSearch) { + return; + } + + if (getQuickOpenEnabled(getState())) { + dispatch({ type: "CLOSE_QUICK_OPEN" }); + } + + // Open start panel if it was collapsed so the project search UI is visible + if ( + activeSearch === primaryPaneTabs.PROJECT_SEARCH && + getPaneCollapse(getState(), "start") + ) { + dispatch({ + type: "TOGGLE_PANE", + position: "start", + paneCollapsed: false, + }); + } + + dispatch({ + type: "TOGGLE_ACTIVE_SEARCH", + value: activeSearch, + }); + }; +} + +export function toggleFrameworkGrouping(toggleValue) { + return ({ dispatch, getState }) => { + dispatch({ + type: "TOGGLE_FRAMEWORK_GROUPING", + value: toggleValue, + }); + }; +} + +export function toggleInlinePreview(toggleValue) { + return ({ dispatch, getState }) => { + dispatch({ + type: "TOGGLE_INLINE_PREVIEW", + value: toggleValue, + }); + }; +} + +export function toggleEditorWrapping(toggleValue) { + return ({ dispatch, getState }) => { + updateDocuments(doc => doc.cm.setOption("lineWrapping", toggleValue)); + + dispatch({ + type: "TOGGLE_EDITOR_WRAPPING", + value: toggleValue, + }); + }; +} + +export function toggleSourceMapsEnabled(toggleValue) { + return ({ dispatch, getState }) => { + dispatch({ + type: "TOGGLE_SOURCE_MAPS_ENABLED", + value: toggleValue, + }); + }; +} + +export function showSource(cx, sourceId) { + return ({ dispatch, getState }) => { + const source = getSource(getState(), sourceId); + if (!source) { + return; + } + + if (getPaneCollapse(getState(), "start")) { + dispatch({ + type: "TOGGLE_PANE", + position: "start", + paneCollapsed: false, + }); + } + + dispatch(setPrimaryPaneTab("sources")); + + dispatch(selectSource(cx, source)); + }; +} + +export function togglePaneCollapse(position, paneCollapsed) { + return ({ dispatch, getState }) => { + const prevPaneCollapse = getPaneCollapse(getState(), position); + if (prevPaneCollapse === paneCollapsed) { + return; + } + + // Set active search to null when closing start panel if project search was active + if ( + position === "start" && + paneCollapsed && + getActiveSearch(getState()) === primaryPaneTabs.PROJECT_SEARCH + ) { + dispatch(closeActiveSearch()); + } + + dispatch({ + type: "TOGGLE_PANE", + position, + paneCollapsed, + }); + }; +} + +/** + * Highlight one or many lines in CodeMirror for a given source. + * + * @param {Object} location + * @param {String} location.sourceId + * The precise source to highlight. + * @param {Number} location.start + * The 1-based index of first line to highlight. + * @param {Number} location.end + * The 1-based index of last line to highlight. + */ +export function highlightLineRange(location) { + return { + type: "HIGHLIGHT_LINES", + location, + }; +} + +export function flashLineRange(location) { + return ({ dispatch }) => { + dispatch(highlightLineRange(location)); + setTimeout(() => dispatch(clearHighlightLineRange()), 200); + }; +} + +export function clearHighlightLineRange() { + return { + type: "CLEAR_HIGHLIGHT_LINES", + }; +} + +export function openConditionalPanel(location, log = false) { + if (!location) { + return null; + } + + return { + type: "OPEN_CONDITIONAL_PANEL", + location, + log, + }; +} + +export function closeConditionalPanel() { + return { + type: "CLOSE_CONDITIONAL_PANEL", + }; +} + +export function clearProjectDirectoryRoot(cx) { + return { + type: "SET_PROJECT_DIRECTORY_ROOT", + cx, + url: "", + name: "", + }; +} + +export function setProjectDirectoryRoot(cx, newRoot, newName) { + return ({ dispatch, getState }) => { + // If the new project root is against the top level thread, + // replace its thread ID with "top-level", so that later, + // getDirectoryForUniquePath could match the project root, + // even after a page reload where the new top level thread actor ID + // will be different. + const mainThread = getMainThread(getState()); + if (mainThread && newRoot.startsWith(mainThread.actor)) { + newRoot = newRoot.replace(mainThread.actor, "top-level"); + } + dispatch({ + type: "SET_PROJECT_DIRECTORY_ROOT", + cx, + url: newRoot, + name: newName, + }); + }; +} + +export function updateViewport() { + return { + type: "SET_VIEWPORT", + viewport: getLocationsInViewport(getEditor()), + }; +} + +export function updateCursorPosition(cursorPosition) { + return { type: "SET_CURSOR_POSITION", cursorPosition }; +} + +export function setOrientation(orientation) { + return { type: "SET_ORIENTATION", orientation }; +} + +export function setSearchOptions(searchKey, searchOptions) { + return { type: "SET_SEARCH_OPTIONS", searchKey, searchOptions }; +} + +export function copyToClipboard(location) { + return ({ dispatch, getState }) => { + const content = getSourceContent(getState(), location); + if (content && isFulfilled(content) && content.value.type === "text") { + copyToTheClipboard(content.value.value); + } + }; +} + +export function setJavascriptTracingLogMethod(value) { + return ({ dispatch, getState }) => { + dispatch({ + type: "SET_JAVASCRIPT_TRACING_LOG_METHOD", + value, + }); + }; +} + +export function setHideOrShowIgnoredSources(shouldHide) { + return ({ dispatch, getState }) => { + dispatch({ type: "HIDE_IGNORED_SOURCES", shouldHide }); + }; +} + +export function toggleSourceMapIgnoreList(cx, shouldEnable) { + return async thunkArgs => { + const { dispatch, getState } = thunkArgs; + const ignoreListSourceUrls = getIgnoreListSourceUrls(getState()); + // Blackbox the source actors on the server + for (const url of ignoreListSourceUrls) { + const source = getSourceByURL(getState(), url); + await blackboxSourceActorsForSource(thunkArgs, source, shouldEnable); + // Disable breakpoints in sources on the ignore list + const breakpoints = getBreakpointsForSource(getState(), source.id); + await dispatch(toggleBreakpoints(cx, shouldEnable, breakpoints)); + } + await dispatch({ + type: "ENABLE_SOURCEMAP_IGNORELIST", + shouldEnable, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/utils/create-store.js b/devtools/client/debugger/src/actions/utils/create-store.js new file mode 100644 index 0000000000..9527c67afc --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/create-store.js @@ -0,0 +1,72 @@ +/* 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/>. */ + +/* global window */ + +/** + * Redux store utils + * @module utils/create-store + */ + +import { createStore, applyMiddleware } from "redux"; +import { waitUntilService } from "./middleware/wait-service"; +import { log } from "./middleware/log"; +import { promise } from "./middleware/promise"; +import { thunk } from "./middleware/thunk"; +import { timing } from "./middleware/timing"; +import { context } from "./middleware/context"; + +/** + * @memberof utils/create-store + * @static + */ + +/** + * This creates a dispatcher with all the standard middleware in place + * that all code requires. It can also be optionally configured in + * various ways, such as logging and recording. + * + * @param {object} opts: + * - log: log all dispatched actions to console + * - history: an array to store every action in. Should only be + * used in tests. + * - middleware: array of middleware to be included in the redux store + * @memberof utils/create-store + * @static + */ +const configureStore = (opts = {}) => { + const middleware = [ + thunk(opts.makeThunkArgs), + context, + promise, + + // Order is important: services must go last as they always + // operate on "already transformed" actions. Actions going through + // them shouldn't have any special fields like promises, they + // should just be normal JSON objects. + waitUntilService, + ]; + + if (opts.middleware) { + opts.middleware.forEach(fn => middleware.push(fn)); + } + + if (opts.log) { + middleware.push(log); + } + + if (opts.timing) { + middleware.push(timing); + } + + // Hook in the redux devtools browser extension if it exists + const devtoolsExt = + typeof window === "object" && window.devToolsExtension + ? window.devToolsExtension() + : f => f; + + return applyMiddleware(...middleware)(devtoolsExt(createStore)); +}; + +export default configureStore; diff --git a/devtools/client/debugger/src/actions/utils/middleware/context.js b/devtools/client/debugger/src/actions/utils/middleware/context.js new file mode 100644 index 0000000000..ebadaa4eff --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/context.js @@ -0,0 +1,33 @@ +/* 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 { + validateNavigateContext, + validateContext, +} from "../../../utils/context"; + +function validateActionContext(getState, action) { + if (action.type == "COMMAND" && action.status == "done") { + // The thread will have resumed execution since the action was initiated, + // so just make sure we haven't navigated. + validateNavigateContext(getState(), action.cx); + return; + } + + // Validate using all available information in the context. + validateContext(getState(), action.cx); +} + +// Middleware which looks for actions that have a cx property and ignores +// them if the context is no longer valid. +function context({ dispatch, getState }) { + return next => action => { + if ("cx" in action) { + validateActionContext(getState, action); + } + return next(action); + }; +} + +export { context }; diff --git a/devtools/client/debugger/src/actions/utils/middleware/log.js b/devtools/client/debugger/src/actions/utils/middleware/log.js new file mode 100644 index 0000000000..b9592ce22c --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/log.js @@ -0,0 +1,111 @@ +/* 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 flags from "devtools/shared/flags"; +import { prefs } from "../../../utils/prefs"; + +const ignoreList = [ + "ADD_BREAKPOINT_POSITIONS", + "SET_SYMBOLS", + "OUT_OF_SCOPE_LOCATIONS", + "MAP_SCOPES", + "MAP_FRAMES", + "ADD_SCOPES", + "IN_SCOPE_LINES", + "REMOVE_BREAKPOINT", + "NODE_PROPERTIES_LOADED", + "SET_FOCUSED_SOURCE_ITEM", + "NODE_EXPAND", + "IN_SCOPE_LINES", + "SET_PREVIEW", +]; + +function cloneAction(action) { + action = action || {}; + action = { ...action }; + + // ADD_TAB, ... + if (action.source?.text) { + const source = { ...action.source, text: "" }; + action.source = source; + } + + if (action.sources) { + const sources = action.sources.slice(0, 20).map(source => { + const url = !source.url || source.url.includes("data:") ? "" : source.url; + return { ...source, url }; + }); + action.sources = sources; + } + + // LOAD_SOURCE_TEXT + if (action.text) { + action.text = ""; + } + + if (action.value?.text) { + const value = { ...action.value, text: "" }; + action.value = value; + } + + return action; +} + +function formatPause(pause) { + return { + ...pause, + pauseInfo: { why: pause.why }, + scopes: [], + loadedObjects: [], + }; +} + +function serializeAction(action) { + try { + action = cloneAction(action); + if (ignoreList.includes(action.type)) { + action = {}; + } + + if (action.type === "PAUSED") { + action = formatPause(action); + } + + const serializer = function (key, value) { + // Serialize Object/LongString fronts + if (value?.getGrip) { + return value.getGrip(); + } + return value; + }; + + // dump(`> ${action.type}...\n ${JSON.stringify(action, serializer)}\n`); + return JSON.stringify(action, serializer); + } catch (e) { + console.error(e); + return ""; + } +} + +/** + * A middleware that logs all actions coming through the system + * to the console. + */ +export function log({ dispatch, getState }) { + return next => action => { + const asyncMsg = !action.status ? "" : `[${action.status}]`; + + if (prefs.logActions) { + if (flags.testing) { + dump( + `[ACTION] ${action.type} ${asyncMsg} - ${serializeAction(action)}\n` + ); + } else { + console.log(action, asyncMsg); + } + } + + next(action); + }; +} diff --git a/devtools/client/debugger/src/actions/utils/middleware/moz.build b/devtools/client/debugger/src/actions/utils/middleware/moz.build new file mode 100644 index 0000000000..f46a0bb725 --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/moz.build @@ -0,0 +1,15 @@ +# 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( + "context.js", + "log.js", + "promise.js", + "thunk.js", + "timing.js", + "wait-service.js", +) diff --git a/devtools/client/debugger/src/actions/utils/middleware/promise.js b/devtools/client/debugger/src/actions/utils/middleware/promise.js new file mode 100644 index 0000000000..52054a1fcc --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/promise.js @@ -0,0 +1,61 @@ +/* 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 { executeSoon } from "../../../utils/DevToolsUtils"; + +import { pending, rejected, fulfilled } from "../../../utils/async-value"; +export function asyncActionAsValue(action) { + if (action.status === "start") { + return pending(); + } + if (action.status === "error") { + return rejected(action.error); + } + return fulfilled(action.value); +} + +let seqIdVal = 1; + +function seqIdGen() { + return seqIdVal++; +} + +function promiseMiddleware({ dispatch, getState }) { + return next => action => { + if (!(PROMISE in action)) { + return next(action); + } + + const seqId = seqIdGen().toString(); + const { [PROMISE]: promiseInst, ...originalActionProperties } = action; + + // Create a new action that doesn't have the promise field and has + // the `seqId` field that represents the sequence id + action = { ...originalActionProperties, seqId }; + + dispatch({ ...action, status: "start" }); + + // Return the promise so action creators can still compose if they + // want to. + return Promise.resolve(promiseInst) + .finally(() => new Promise(resolve => executeSoon(resolve))) + .then( + value => { + dispatch({ ...action, status: "done", value: value }); + return value; + }, + error => { + dispatch({ + ...action, + status: "error", + error: error.message || error, + }); + return Promise.reject(error); + } + ); + }; +} + +export const PROMISE = "@@dispatch/promise"; +export { promiseMiddleware as promise }; diff --git a/devtools/client/debugger/src/actions/utils/middleware/thunk.js b/devtools/client/debugger/src/actions/utils/middleware/thunk.js new file mode 100644 index 0000000000..fba17d516c --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/thunk.js @@ -0,0 +1,22 @@ +/* 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/>. */ + +/** + * A middleware that allows thunks (functions) to be dispatched. If + * it's a thunk, it is called with an argument that contains + * `dispatch`, `getState`, and any additional args passed in via the + * middleware constructure. This allows the action to create multiple + * actions (most likely asynchronously). + */ +export function thunk(makeArgs) { + return ({ dispatch, getState }) => { + const args = { dispatch, getState }; + + return next => action => { + return typeof action === "function" + ? action(makeArgs ? makeArgs(args, getState()) : args) + : next(action); + }; + }; +} diff --git a/devtools/client/debugger/src/actions/utils/middleware/timing.js b/devtools/client/debugger/src/actions/utils/middleware/timing.js new file mode 100644 index 0000000000..d0bfa05977 --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/timing.js @@ -0,0 +1,26 @@ +/* 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/>. */ + +/** + * Redux middleware that sets performance markers for all actions such that they + * will appear in performance tooling under the User Timing API + */ + +const mark = window.performance?.mark + ? window.performance.mark.bind(window.performance) + : a => {}; + +const measure = window.performance?.measure + ? window.performance.measure.bind(window.performance) + : (a, b, c) => {}; + +export function timing(store) { + return next => action => { + mark(`${action.type}_start`); + const result = next(action); + mark(`${action.type}_end`); + measure(`${action.type}`, `${action.type}_start`, `${action.type}_end`); + return result; + }; +} diff --git a/devtools/client/debugger/src/actions/utils/middleware/wait-service.js b/devtools/client/debugger/src/actions/utils/middleware/wait-service.js new file mode 100644 index 0000000000..337df7e336 --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/wait-service.js @@ -0,0 +1,62 @@ +/* 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/>. */ + +/** + * A middleware which acts like a service, because it is stateful + * and "long-running" in the background. It provides the ability + * for actions to install a function to be run once when a specific + * condition is met by an action coming through the system. Think of + * it as a thunk that blocks until the condition is met. Example: + * + * ```js + * const services = { WAIT_UNTIL: require('wait-service').NAME }; + * + * { type: services.WAIT_UNTIL, + * predicate: action => action.type === "ADD_ITEM", + * run: (dispatch, getState, action) => { + * // Do anything here. You only need to accept the arguments + * // if you need them. `action` is the action that satisfied + * // the predicate. + * } + * } + * ``` + */ +export const NAME = "@@service/waitUntil"; + +export function waitUntilService({ dispatch, getState }) { + let pending = []; + + function checkPending(action) { + const readyRequests = []; + const stillPending = []; + + // Find the pending requests whose predicates are satisfied with + // this action. Wait to run the requests until after we update the + // pending queue because the request handler may synchronously + // dispatch again and run this service (that use case is + // completely valid). + for (const request of pending) { + if (request.predicate(action)) { + readyRequests.push(request); + } else { + stillPending.push(request); + } + } + + pending = stillPending; + for (const request of readyRequests) { + request.run(dispatch, getState, action); + } + } + + return next => action => { + if (action.type === NAME) { + pending.push(action); + return null; + } + const result = next(action); + checkPending(action); + return result; + }; +} diff --git a/devtools/client/debugger/src/actions/utils/moz.build b/devtools/client/debugger/src/actions/utils/moz.build new file mode 100644 index 0000000000..08a43a218c --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/moz.build @@ -0,0 +1,12 @@ +# 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 += [ + "middleware", +] + +CompiledModules( + "create-store.js", +) |