diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /devtools/client/debugger/src/actions | |
parent | Initial commit. (diff) | |
download | firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/debugger/src/actions')
111 files changed, 11494 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..0a8d0fee7a --- /dev/null +++ b/devtools/client/debugger/src/actions/ast/index.js @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +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..ee87a58766 --- /dev/null +++ b/devtools/client/debugger/src/actions/ast/setInScopeLines.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/>. */ + +// @flow + +import { + hasInScopeLines, + getSourceWithContent, + getVisibleSelectedFrame, +} from "../../selectors"; + +import { getSourceLineCount } from "../../utils/source"; + +import { range, flatMap, uniq, without } from "lodash"; +import { isFulfilled } from "../../utils/async-value"; + +import type { AstLocation } from "../../workers/parser"; +import type { ThunkArgs } from "../types"; +import type { Context, SourceLocation } from "../../types"; + +function getOutOfScopeLines( + outOfScopeLocations: ?(AstLocation[]) +): ?(AstLocation[]) { + if (!outOfScopeLocations) { + return null; + } + + return uniq( + flatMap(outOfScopeLocations, location => + range(location.start.line, location.end.line) + ) + ); +} + +async function getInScopeLines( + cx: Context, + location: SourceLocation, + { dispatch, getState, parser }: ThunkArgs +) { + const source = getSourceWithContent(getState(), location.sourceId); + + let locations = null; + if (location.line && source && !source.isWasm) { + locations = await parser.findOutOfScopeLocations( + source.id, + ((location: any): parser.AstPosition) + ); + } + + const linesOutOfScope = getOutOfScopeLines(locations); + const sourceNumLines = + !source.content || !isFulfilled(source.content) + ? 0 + : getSourceLineCount(source.content.value); + + const sourceLines = range(1, sourceNumLines + 1); + + return !linesOutOfScope + ? sourceLines + : without(sourceLines, ...linesOutOfScope); +} + +export function setInScopeLines(cx: Context) { + return async (thunkArgs: ThunkArgs) => { + const { getState, dispatch } = thunkArgs; + const visibleFrame = getVisibleSelectedFrame(getState()); + + if (!visibleFrame) { + return; + } + + const { location } = visibleFrame; + const { content } = getSourceWithContent(getState(), location.sourceId); + + if (hasInScopeLines(getState(), location) || !content) { + 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..b3b483222b --- /dev/null +++ b/devtools/client/debugger/src/actions/ast/tests/setInScopeLines.spec.js @@ -0,0 +1,69 @@ +/* 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/>. */ + +// @flow +import readFixture from "../../tests/helpers/readFixture"; + +import { makeMockFrame, makeMockSource } from "../../../utils/test-mockup"; +import { + createStore, + selectors, + actions, + makeSource, + waitForState, +} from "../../../utils/test-head"; + +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]; + + await dispatch(actions.newGeneratedSource(makeSource("scopes.js"))); + + await dispatch( + actions.selectLocation(selectors.getContext(getState()), { + sourceId: "scopes.js", + 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..e28c325f01 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js @@ -0,0 +1,249 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + isOriginalId, + isGeneratedId, + originalToGeneratedId, +} from "devtools-source-map"; +import { uniqBy, zip } from "lodash"; + +import { + getSource, + getSourceFromId, + getBreakpointPositionsForSource, + getSourceActorsForSource, +} from "../../selectors"; + +import type { + MappedLocation, + Range, + Source, + SourceLocation, + SourceId, + BreakpointPositions, + Context, +} from "../../types"; + +import { makeBreakpointId } from "../../utils/breakpoint"; +import { + memoizeableAction, + type MemoizedAction, +} from "../../utils/memoizableAction"; +import { fulfilled } from "../../utils/async-value"; +import type { ThunkArgs } from "../../actions/types"; +import { loadSourceActorBreakpointColumns } from "../source-actors"; + +type LocationsList = { + number: ?(number[]), +}; + +async function mapLocations( + generatedLocations: SourceLocation[], + { sourceMaps }: ThunkArgs +): Promise<MappedLocation[]> { + if (generatedLocations.length == 0) { + return []; + } + + const originalLocations = await sourceMaps.getOriginalLocations( + generatedLocations + ); + + return zip( + originalLocations, + generatedLocations + ).map(([location, generatedLocation]) => ({ location, generatedLocation })); +} + +// Filter out positions, that are not in the original source Id +function filterBySource( + positions: MappedLocation[], + sourceId: SourceId +): MappedLocation[] { + if (!isOriginalId(sourceId)) { + return positions; + } + return positions.filter(position => position.location.sourceId == sourceId); +} + +function filterByUniqLocation(positions: MappedLocation[]): MappedLocation[] { + return uniqBy(positions, ({ location }) => makeBreakpointId(location)); +} + +function convertToList( + results: LocationsList, + source: Source +): SourceLocation[] { + const { id, url } = source; + const positions = []; + + for (const line in results) { + for (const column of results[line]) { + positions.push({ + line: Number(line), + column, + sourceId: id, + sourceUrl: url, + }); + } + } + + return positions; +} + +function groupByLine(results: MappedLocation[], sourceId: SourceId, line) { + const isOriginal = isOriginalId(sourceId); + const positions = {}; + + // Ensure that we have an entry for the line fetched + if (typeof line === "number") { + positions[line] = []; + } + + for (const result of results) { + const location = isOriginal ? result.location : result.generatedLocation; + + if (!positions[location.line]) { + positions[location.line] = []; + } + + positions[location.line].push(result); + } + + return positions; +} + +async function _setBreakpointPositions( + cx: Context, + sourceId: SourceId, + line, + thunkArgs: ThunkArgs +): Promise<void> { + const { client, dispatch, getState, sourceMaps } = thunkArgs; + let generatedSource = getSource(getState(), sourceId); + if (!generatedSource) { + return; + } + + const results = {}; + if (isOriginalId(sourceId)) { + // Explicitly typing ranges is required to work around the following issue + // https://github.com/facebook/flow/issues/5294 + const ranges: Range[] = await sourceMaps.getGeneratedRangesForOriginal( + sourceId, + true + ); + const generatedSourceId = originalToGeneratedId(sourceId); + generatedSource = getSourceFromId(getState(), generatedSourceId); + + // Note: While looping here may not look ideal, in the vast majority of + // cases, the number of ranges here should be very small, and is quite + // likely to only be a single range. + for (const range of ranges) { + // Wrap infinite end positions to the next line to keep things simple + // and because we know we don't care about the end-line whitespace + // in this case. + if (range.end.column === Infinity) { + range.end = { + line: range.end.line + 1, + column: 0, + }; + } + + const actorBps = await Promise.all( + getSourceActorsForSource(getState(), generatedSource.id).map(actor => + client.getSourceActorBreakpointPositions(actor, range) + ) + ); + + for (const actorPositions of actorBps) { + for (const rangeLine of Object.keys(actorPositions)) { + let columns = actorPositions[parseInt(rangeLine, 10)]; + const existing = results[rangeLine]; + if (existing) { + columns = [...new Set([...existing, ...columns])]; + } + + results[rangeLine] = columns; + } + } + } + } else { + if (typeof line !== "number") { + throw new Error("Line is required for generated sources"); + } + + const actorColumns = await Promise.all( + getSourceActorsForSource(getState(), generatedSource.id).map(actor => + dispatch(loadSourceActorBreakpointColumns({ id: actor.id, line, cx })) + ) + ); + + for (const columns of actorColumns) { + results[line] = (results[line] || []).concat(columns); + } + } + + let positions = convertToList(results, generatedSource); + positions = await mapLocations(positions, thunkArgs); + + positions = filterBySource(positions, sourceId); + positions = filterByUniqLocation(positions); + positions = groupByLine(positions, sourceId, line); + + const source = getSource(getState(), sourceId); + // NOTE: it's possible that the source was removed during a navigate + if (!source) { + return; + } + + dispatch({ + type: "ADD_BREAKPOINT_POSITIONS", + cx, + source, + positions, + }); +} + +function generatedSourceActorKey(state, sourceId: SourceId): string { + const generatedSource = getSource( + state, + isOriginalId(sourceId) ? originalToGeneratedId(sourceId) : sourceId + ); + const actors = generatedSource + ? getSourceActorsForSource(state, generatedSource.id).map( + ({ actor }) => actor + ) + : []; + return [sourceId, ...actors].join(":"); +} + +export const setBreakpointPositions: MemoizedAction< + {| cx: Context, sourceId: SourceId, line?: number |}, + ?BreakpointPositions +> = memoizeableAction("setBreakpointPositions", { + getValue: ({ sourceId, line }, { getState }) => { + const positions = getBreakpointPositionsForSource(getState(), sourceId); + if (!positions) { + return null; + } + + if (isGeneratedId(sourceId) && line && !positions[line]) { + // We always return the full position dataset, but if a given line is + // not available, we treat the whole set as loading. + return null; + } + + return fulfilled(positions); + }, + createKey({ sourceId, line }, { getState }) { + const key = generatedSourceActorKey(getState(), sourceId); + return isGeneratedId(sourceId) && line ? `${key}-${line}` : key; + }, + action: async ({ cx, sourceId, line }, thunkArgs) => + _setBreakpointPositions(cx, sourceId, line, thunkArgs), +}); diff --git a/devtools/client/debugger/src/actions/breakpoints/index.js b/devtools/client/debugger/src/actions/breakpoints/index.js new file mode 100644 index 0000000000..7d8e2ca737 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/index.js @@ -0,0 +1,421 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * Redux actions for breakpoints + * @module actions/breakpoints + */ + +import { PROMISE } from "../utils/middleware/promise"; +import { + getBreakpointsList, + getXHRBreakpoints, + getSelectedSource, + getBreakpointAtLocation, + getBreakpointsForSource, + getBreakpointsAtLine, +} from "../../selectors"; +import { createXHRBreakpoint } from "../../utils/breakpoint"; +import { + addBreakpoint, + removeBreakpoint, + enableBreakpoint, + disableBreakpoint, +} from "./modify"; +import remapLocations from "./remapLocations"; + +// this will need to be changed so that addCLientBreakpoint is removed + +import type { ThunkArgs } from "../types"; +import type { + Breakpoint, + Source, + SourceLocation, + SourceId, + XHRBreakpoint, + Context, +} from "../../types"; + +export * from "./breakpointPositions"; +export * from "./modify"; +export * from "./syncBreakpoint"; + +export function addHiddenBreakpoint(cx: Context, location: SourceLocation) { + return ({ dispatch }: ThunkArgs) => { + return dispatch(addBreakpoint(cx, location, { hidden: true })); + }; +} + +/** + * Disable all breakpoints in a source + * + * @memberof actions/breakpoints + * @static + */ +export function disableBreakpointsInSource(cx: Context, source: Source) { + return async ({ dispatch, getState, client }: ThunkArgs) => { + const breakpoints = getBreakpointsForSource(getState(), source.id); + for (const breakpoint of breakpoints) { + if (!breakpoint.disabled) { + dispatch(disableBreakpoint(cx, breakpoint)); + } + } + }; +} + +/** + * Enable all breakpoints in a source + * + * @memberof actions/breakpoints + * @static + */ +export function enableBreakpointsInSource(cx: Context, source: Source) { + return async ({ dispatch, getState, client }: ThunkArgs) => { + const breakpoints = getBreakpointsForSource(getState(), source.id); + for (const breakpoint of breakpoints) { + if (breakpoint.disabled) { + dispatch(enableBreakpoint(cx, breakpoint)); + } + } + }; +} + +/** + * Toggle All Breakpoints + * + * @memberof actions/breakpoints + * @static + */ +export function toggleAllBreakpoints( + cx: Context, + shouldDisableBreakpoints: boolean +) { + return async ({ dispatch, getState, client }: ThunkArgs) => { + const breakpoints = getBreakpointsList(getState()); + + for (const breakpoint of breakpoints) { + if (shouldDisableBreakpoints) { + dispatch(disableBreakpoint(cx, breakpoint)); + } else { + dispatch(enableBreakpoint(cx, breakpoint)); + } + } + }; +} + +/** + * Toggle Breakpoints + * + * @memberof actions/breakpoints + * @static + */ +export function toggleBreakpoints( + cx: Context, + shouldDisableBreakpoints: boolean, + breakpoints: Breakpoint[] +) { + return async ({ dispatch }: ThunkArgs) => { + const promises = breakpoints.map(breakpoint => + shouldDisableBreakpoints + ? dispatch(disableBreakpoint(cx, breakpoint)) + : dispatch(enableBreakpoint(cx, breakpoint)) + ); + + await Promise.all(promises); + }; +} + +export function toggleBreakpointsAtLine( + cx: Context, + shouldDisableBreakpoints: boolean, + line: number +) { + return async ({ dispatch, getState }: ThunkArgs) => { + const breakpoints = getBreakpointsAtLine(getState(), line); + return dispatch( + toggleBreakpoints(cx, shouldDisableBreakpoints, breakpoints) + ); + }; +} + +/** + * Removes all breakpoints + * + * @memberof actions/breakpoints + * @static + */ +export function removeAllBreakpoints(cx: Context) { + return async ({ dispatch, getState }: ThunkArgs) => { + const breakpointList = getBreakpointsList(getState()); + await Promise.all( + breakpointList.map(bp => dispatch(removeBreakpoint(cx, bp))) + ); + dispatch({ type: "REMOVE_BREAKPOINTS" }); + }; +} + +/** + * Removes breakpoints + * + * @memberof actions/breakpoints + * @static + */ +export function removeBreakpoints(cx: Context, breakpoints: Breakpoint[]) { + return async ({ dispatch }: ThunkArgs) => { + return Promise.all( + breakpoints.map(bp => dispatch(removeBreakpoint(cx, bp))) + ); + }; +} + +/** + * Removes all breakpoints in a source + * + * @memberof actions/breakpoints + * @static + */ +export function removeBreakpointsInSource(cx: Context, source: Source) { + return async ({ dispatch, getState, client }: ThunkArgs) => { + const breakpoints = getBreakpointsForSource(getState(), source.id); + for (const breakpoint of breakpoints) { + dispatch(removeBreakpoint(cx, breakpoint)); + } + }; +} + +export function remapBreakpoints(cx: Context, sourceId: SourceId) { + return async ({ dispatch, getState, sourceMaps }: ThunkArgs) => { + const breakpoints = getBreakpointsForSource(getState(), sourceId); + const newBreakpoints = await remapLocations( + breakpoints, + sourceId, + sourceMaps + ); + + // Normally old breakpoints will be clobbered if we re-add them, but when + // remapping we have changed the source maps and the old breakpoints will + // have different locations than the new ones. Manually remove the + // old breakpoints before adding the new ones. + for (const bp of breakpoints) { + dispatch(removeBreakpoint(cx, bp)); + } + + for (const bp of newBreakpoints) { + await dispatch(addBreakpoint(cx, bp.location, bp.options, bp.disabled)); + } + }; +} + +export function toggleBreakpointAtLine(cx: Context, line: number) { + return ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => { + const state = getState(); + const selectedSource = getSelectedSource(state); + + if (!selectedSource) { + return; + } + + const bp = getBreakpointAtLocation(state, { line, column: undefined }); + if (bp) { + return dispatch(removeBreakpoint(cx, bp)); + } + return dispatch( + addBreakpoint(cx, { + sourceId: selectedSource.id, + sourceUrl: selectedSource.url, + line, + }) + ); + }; +} + +export function addBreakpointAtLine( + cx: Context, + line: number, + shouldLog: boolean = false, + disabled: boolean = false +) { + return ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => { + const state = getState(); + const source = getSelectedSource(state); + + if (!source) { + return; + } + const breakpointLocation = { + sourceId: source.id, + sourceUrl: source.url, + column: undefined, + line, + }; + + const options = {}; + if (shouldLog) { + options.logValue = "displayName"; + } + + return dispatch(addBreakpoint(cx, breakpointLocation, options, disabled)); + }; +} + +export function removeBreakpointsAtLine( + cx: Context, + sourceId: SourceId, + line: number +) { + return ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => { + const breakpointsAtLine = getBreakpointsForSource( + getState(), + sourceId, + line + ); + return dispatch(removeBreakpoints(cx, breakpointsAtLine)); + }; +} + +export function disableBreakpointsAtLine( + cx: Context, + sourceId: SourceId, + line: number +) { + return ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => { + const breakpointsAtLine = getBreakpointsForSource( + getState(), + sourceId, + line + ); + return dispatch(toggleBreakpoints(cx, true, breakpointsAtLine)); + }; +} + +export function enableBreakpointsAtLine( + cx: Context, + sourceId: SourceId, + line: number +) { + return ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => { + const breakpointsAtLine = getBreakpointsForSource( + getState(), + sourceId, + line + ); + return dispatch(toggleBreakpoints(cx, false, breakpointsAtLine)); + }; +} + +export function toggleDisabledBreakpoint(cx: Context, breakpoint: Breakpoint) { + return ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => { + if (!breakpoint.disabled) { + return dispatch(disableBreakpoint(cx, breakpoint)); + } + return dispatch(enableBreakpoint(cx, breakpoint)); + }; +} + +export function enableXHRBreakpoint(index: number, bp?: XHRBreakpoint) { + return ({ dispatch, getState, client }: ThunkArgs) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const breakpoint = bp || xhrBreakpoints[index]; + const enabledBreakpoint = { + ...breakpoint, + disabled: false, + }; + + return dispatch({ + type: "ENABLE_XHR_BREAKPOINT", + breakpoint: enabledBreakpoint, + index, + [PROMISE]: client.setXHRBreakpoint(breakpoint.path, breakpoint.method), + }); + }; +} + +export function disableXHRBreakpoint(index: number, bp?: XHRBreakpoint) { + return ({ dispatch, getState, client }: ThunkArgs) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const breakpoint = bp || xhrBreakpoints[index]; + const disabledBreakpoint = { + ...breakpoint, + disabled: true, + }; + + return dispatch({ + type: "DISABLE_XHR_BREAKPOINT", + breakpoint: disabledBreakpoint, + index, + [PROMISE]: client.removeXHRBreakpoint(breakpoint.path, breakpoint.method), + }); + }; +} + +export function updateXHRBreakpoint( + index: number, + path: string, + method: string +) { + return ({ dispatch, getState, client }: ThunkArgs) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const breakpoint = xhrBreakpoints[index]; + + const updatedBreakpoint = { + ...breakpoint, + path, + method, + text: L10N.getFormatStr("xhrBreakpoints.item.label", path), + }; + + return dispatch({ + type: "UPDATE_XHR_BREAKPOINT", + breakpoint: updatedBreakpoint, + index, + [PROMISE]: Promise.all([ + client.removeXHRBreakpoint(breakpoint.path, breakpoint.method), + client.setXHRBreakpoint(path, method), + ]), + }); + }; +} +export function togglePauseOnAny() { + return ({ dispatch, getState }: ThunkArgs) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const index = xhrBreakpoints.findIndex(({ path }) => path.length === 0); + if (index < 0) { + return dispatch(setXHRBreakpoint("", "ANY")); + } + + const bp = xhrBreakpoints[index]; + if (bp.disabled) { + return dispatch(enableXHRBreakpoint(index, bp)); + } + + return dispatch(disableXHRBreakpoint(index, bp)); + }; +} + +export function setXHRBreakpoint(path: string, method: string) { + return ({ dispatch, getState, client }: ThunkArgs) => { + const breakpoint = createXHRBreakpoint(path, method); + + return dispatch({ + type: "SET_XHR_BREAKPOINT", + breakpoint, + [PROMISE]: client.setXHRBreakpoint(path, method), + }); + }; +} + +export function removeXHRBreakpoint(index: number) { + return ({ dispatch, getState, client }: ThunkArgs) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const breakpoint = xhrBreakpoints[index]; + return dispatch({ + type: "REMOVE_XHR_BREAKPOINT", + breakpoint, + index, + [PROMISE]: client.removeXHRBreakpoint(breakpoint.path, breakpoint.method), + }); + }; +} diff --git a/devtools/client/debugger/src/actions/breakpoints/modify.js b/devtools/client/debugger/src/actions/breakpoints/modify.js new file mode 100644 index 0000000000..9979380d07 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/modify.js @@ -0,0 +1,395 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + makeBreakpointLocation, + makeBreakpointId, + getASTLocation, +} from "../../utils/breakpoint"; + +import { + getBreakpoint, + getBreakpointPositionsForLocation, + getFirstBreakpointPosition, + getSymbols, + getSource, + getSourceContent, + getBreakpointsList, + getPendingBreakpointList, + isMapScopesEnabled, +} from "../../selectors"; + +import { setBreakpointPositions } from "./breakpointPositions"; +import { setSkipPausing } from "../pause/skipPausing"; + +import { PROMISE } from "../utils/middleware/promise"; +import { recordEvent } from "../../utils/telemetry"; +import { comparePosition } from "../../utils/location"; +import { getTextAtPosition } from "../../utils/source"; +import { getMappedScopesForLocation } from "../pause/mapScopes"; +import { isOriginalSource } from "../../utils/source-maps"; +import { validateNavigateContext } from "../../utils/context"; + +import type { ThunkArgs } from "../types"; +import type { + Breakpoint, + BreakpointOptions, + BreakpointPosition, + SourceLocation, + Context, +} from "../../types"; +import type { ParserDispatcher } from "../../workers/parser"; +import type { State } from "../../reducers/types"; + +// This file has the primitive operations used to modify individual breakpoints +// and keep them in sync with the breakpoints installed on server threads. These +// are collected here to make it easier to preserve the following invariant: +// +// Breakpoints are included in reducer state iff they are disabled or requests +// have been dispatched to set them in all server threads. +// +// To maintain this property, updates to the reducer and installed breakpoints +// must happen with no intervening await. Using await allows other operations to +// modify the breakpoint state in the interim and potentially cause breakpoint +// state to go out of sync. +// +// The reducer is optimistically updated when users set or remove a breakpoint, +// but it might take a little while before the breakpoints have been set or +// removed in each thread. Once all outstanding requests sent to a thread have +// been processed, the reducer and server threads will be in sync. +// +// There is another exception to the above invariant when first connecting to +// the server: breakpoints have been installed on all generated locations in the +// pending breakpoints, but no breakpoints have been added to the reducer. When +// a matching source appears, either the server breakpoint will be removed or a +// breakpoint will be added to the reducer, to restore the above invariant. +// See syncBreakpoint.js for more. + +async function clientSetBreakpoint( + client, + cx, + { getState, dispatch }: ThunkArgs, + breakpoint: Breakpoint +) { + const breakpointLocation = makeBreakpointLocation( + getState(), + breakpoint.generatedLocation + ); + const shouldMapBreakpointExpressions = + isMapScopesEnabled(getState()) && + isOriginalSource(getSource(getState(), breakpoint.location?.sourceId)) && + (breakpoint.options.logValue || breakpoint.options.condition); + + if (shouldMapBreakpointExpressions) { + breakpoint = await dispatch(updateBreakpointSourceMapping(cx, breakpoint)); + } + return client.setBreakpoint(breakpointLocation, breakpoint.options); +} + +function clientRemoveBreakpoint( + client, + state: State, + generatedLocation: SourceLocation +) { + const breakpointLocation = makeBreakpointLocation(state, generatedLocation); + return client.removeBreakpoint(breakpointLocation); +} + +export function enableBreakpoint(cx: Context, initialBreakpoint: Breakpoint) { + return (thunkArgs: ThunkArgs) => { + const { dispatch, getState, client } = thunkArgs; + const breakpoint = getBreakpoint(getState(), initialBreakpoint.location); + if (!breakpoint || !breakpoint.disabled) { + return; + } + + dispatch(setSkipPausing(false)); + return dispatch({ + type: "SET_BREAKPOINT", + cx, + breakpoint: { ...breakpoint, disabled: false }, + [PROMISE]: clientSetBreakpoint(client, cx, thunkArgs, breakpoint), + }); + }; +} + +export function addBreakpoint( + cx: Context, + initialLocation: SourceLocation, + options: BreakpointOptions = {}, + disabled: boolean = false, + shouldCancel: () => boolean = () => false +) { + return async (thunkArgs: ThunkArgs) => { + const { dispatch, getState, client } = thunkArgs; + recordEvent("add_breakpoint"); + + const { sourceId, column, line } = initialLocation; + + await dispatch(setBreakpointPositions({ cx, sourceId, line })); + + const position: ?BreakpointPosition = column + ? getBreakpointPositionsForLocation(getState(), initialLocation) + : getFirstBreakpointPosition(getState(), initialLocation); + + if (!position) { + return; + } + + const { location, generatedLocation } = position; + + const source = getSource(getState(), location.sourceId); + const generatedSource = getSource(getState(), generatedLocation.sourceId); + + if (!source || !generatedSource) { + return; + } + + const symbols = getSymbols(getState(), source); + const astLocation = getASTLocation(source, symbols, location); + + const originalContent = getSourceContent(getState(), source.id); + const originalText = getTextAtPosition( + source.id, + originalContent, + location + ); + + const content = getSourceContent(getState(), generatedSource.id); + const text = getTextAtPosition( + generatedSource.id, + content, + generatedLocation + ); + + const id = makeBreakpointId(location); + const breakpoint = { + id, + disabled, + options, + location, + astLocation, + generatedLocation, + text, + originalText, + }; + + if (shouldCancel()) { + return; + } + + dispatch(setSkipPausing(false)); + return dispatch({ + type: "SET_BREAKPOINT", + cx, + breakpoint, + // If we just clobbered an enabled breakpoint with a disabled one, we need + // to remove any installed breakpoint in the server. + [PROMISE]: disabled + ? clientRemoveBreakpoint(client, getState(), generatedLocation) + : clientSetBreakpoint(client, cx, thunkArgs, breakpoint), + }); + }; +} + +/** + * Remove a single breakpoint + * + * @memberof actions/breakpoints + * @static + */ +export function removeBreakpoint(cx: Context, initialBreakpoint: Breakpoint) { + return ({ dispatch, getState, client }: ThunkArgs) => { + recordEvent("remove_breakpoint"); + + const breakpoint = getBreakpoint(getState(), initialBreakpoint.location); + if (!breakpoint) { + return; + } + + dispatch(setSkipPausing(false)); + return dispatch({ + type: "REMOVE_BREAKPOINT", + cx, + location: breakpoint.location, + // If the breakpoint is disabled then it is not installed in the server. + [PROMISE]: breakpoint.disabled + ? Promise.resolve() + : clientRemoveBreakpoint( + client, + getState(), + breakpoint.generatedLocation + ), + }); + }; +} + +/** + * Remove all installed, pending, and client breakpoints associated with a + * target generated location. + * + * @memberof actions/breakpoints + * @static + */ +export function removeBreakpointAtGeneratedLocation( + cx: Context, + target: SourceLocation +) { + return ({ dispatch, getState, client }: ThunkArgs) => { + // remove breakpoint from the server + const onBreakpointRemoved = clientRemoveBreakpoint( + client, + getState(), + target + ); + // Remove any breakpoints matching the generated location. + const breakpoints = getBreakpointsList(getState()); + for (const { location, generatedLocation } of breakpoints) { + if ( + generatedLocation.sourceId == target.sourceId && + comparePosition(generatedLocation, target) + ) { + dispatch({ + type: "REMOVE_BREAKPOINT", + cx, + location, + [PROMISE]: onBreakpointRemoved, + }); + } + } + + // Remove any remaining pending breakpoints matching the generated location. + const pending = getPendingBreakpointList(getState()); + for (const { location, generatedLocation } of pending) { + if ( + generatedLocation.sourceUrl == target.sourceUrl && + comparePosition(generatedLocation, target) + ) { + dispatch({ + type: "REMOVE_PENDING_BREAKPOINT", + cx, + location, + }); + } + } + return onBreakpointRemoved; + }; +} + +/** + * Disable a single breakpoint + * + * @memberof actions/breakpoints + * @static + */ +export function disableBreakpoint(cx: Context, initialBreakpoint: Breakpoint) { + return ({ dispatch, getState, client }: ThunkArgs) => { + const breakpoint = getBreakpoint(getState(), initialBreakpoint.location); + if (!breakpoint || breakpoint.disabled) { + return; + } + + dispatch(setSkipPausing(false)); + return dispatch({ + type: "SET_BREAKPOINT", + cx, + breakpoint: { ...breakpoint, disabled: true }, + [PROMISE]: clientRemoveBreakpoint( + client, + getState(), + breakpoint.generatedLocation + ), + }); + }; +} + +/** + * Update the options of a breakpoint. + * + * @throws {Error} "not implemented" + * @memberof actions/breakpoints + * @static + * @param {SourceLocation} location + * @see DebuggerController.Breakpoints.addBreakpoint + * @param {Object} options + * Any options to set on the breakpoint + */ +export function setBreakpointOptions( + cx: Context, + location: SourceLocation, + options: BreakpointOptions = {} +) { + return (thunkArgs: ThunkArgs) => { + const { dispatch, getState, client } = thunkArgs; + let breakpoint = getBreakpoint(getState(), location); + if (!breakpoint) { + return dispatch(addBreakpoint(cx, location, options)); + } + + // Note: setting a breakpoint's options implicitly enables it. + breakpoint = { ...breakpoint, disabled: false, options }; + + return dispatch({ + type: "SET_BREAKPOINT", + cx, + breakpoint, + [PROMISE]: clientSetBreakpoint(client, cx, thunkArgs, breakpoint), + }); + }; +} + +async function updateExpression( + evaluationsParser: ParserDispatcher, + mappings, + originalExpression: string +) { + const mapped = await evaluationsParser.mapExpression( + originalExpression, + mappings, + [], + false, + false + ); + if (!mapped) { + return originalExpression; + } + if (!originalExpression.trimEnd().endsWith(";")) { + return mapped.expression.replace(/;$/, ""); + } + return mapped.expression; +} + +function updateBreakpointSourceMapping(cx: Context, breakpoint: Breakpoint) { + return async ({ getState, dispatch, evaluationsParser }: ThunkArgs) => { + const options = { ...breakpoint.options }; + + const mappedScopes = await dispatch( + getMappedScopesForLocation(breakpoint.location) + ); + if (!mappedScopes) { + return breakpoint; + } + const { mappings } = mappedScopes; + + if (options.condition) { + options.condition = await updateExpression( + evaluationsParser, + mappings, + options.condition + ); + } + if (options.logValue) { + options.logValue = await updateExpression( + evaluationsParser, + mappings, + options.logValue + ); + } + + validateNavigateContext(getState(), cx); + return { ...breakpoint, options }; + }; +} diff --git a/devtools/client/debugger/src/actions/breakpoints/moz.build b/devtools/client/debugger/src/actions/breakpoints/moz.build new file mode 100644 index 0000000000..b686529199 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/moz.build @@ -0,0 +1,14 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "breakpointPositions.js", + "index.js", + "modify.js", + "remapLocations.js", + "syncBreakpoint.js", +) diff --git a/devtools/client/debugger/src/actions/breakpoints/remapLocations.js b/devtools/client/debugger/src/actions/breakpoints/remapLocations.js new file mode 100644 index 0000000000..0afbc640bc --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/remapLocations.js @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import typeof SourceMaps from "devtools-source-map"; + +import type { Breakpoint, SourceId } from "../../types"; + +export default function remapLocations( + breakpoints: Breakpoint[], + sourceId: SourceId, + sourceMaps: SourceMaps +): Promise<Breakpoint[]> { + const sourceBreakpoints: Promise<Breakpoint>[] = breakpoints.map( + async breakpoint => { + if (breakpoint.location.sourceId !== sourceId) { + return breakpoint; + } + const location = await sourceMaps.getOriginalLocation( + breakpoint.location + ); + return { ...breakpoint, location }; + } + ); + + return Promise.all(sourceBreakpoints); +} diff --git a/devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js b/devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js new file mode 100644 index 0000000000..bed0193313 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js @@ -0,0 +1,194 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { setBreakpointPositions } from "./breakpointPositions"; +import { setSymbols } from "../sources/symbols"; +import { + assertPendingBreakpoint, + findFunctionByName, + findPosition, + makeBreakpointLocation, +} from "../../utils/breakpoint"; + +import { comparePosition, createLocation } from "../../utils/location"; + +import { originalToGeneratedId, isOriginalId } from "devtools-source-map"; +import { getSource } from "../../selectors"; +import { addBreakpoint, removeBreakpointAtGeneratedLocation } from "."; + +import type { ThunkArgs } from "../types"; +import type { LoadedSymbols } from "../../reducers/types"; + +import type { + SourceLocation, + ASTLocation, + PendingBreakpoint, + SourceId, + Source, + BreakpointPositions, + Context, +} from "../../types"; + +async function findBreakpointPosition( + cx: Context, + { getState, dispatch }: ThunkArgs, + location: SourceLocation +) { + const { sourceId, line } = location; + const positions: BreakpointPositions = await dispatch( + setBreakpointPositions({ cx, sourceId, line }) + ); + + const position = findPosition(positions, location); + return position?.generatedLocation; +} + +async function findNewLocation( + cx: Context, + { name, offset, index }: ASTLocation, + location: SourceLocation, + source: Source, + thunkArgs: ThunkArgs +) { + const symbols: LoadedSymbols = await thunkArgs.dispatch( + setSymbols({ cx, source }) + ); + const func = symbols ? findFunctionByName(symbols, name, index) : null; + + // Fallback onto the location line, if we do not find a function. + let { line } = location; + if (func) { + line = func.location.start.line + offset.line; + } + + return { + line, + column: location.column, + sourceUrl: source.url, + sourceId: source.id, + }; +} + +// Breakpoint syncing occurs when a source is found that matches either the +// original or generated URL of a pending breakpoint. A new breakpoint is +// constructed that might have a different original and/or generated location, +// if the original source has changed since the pending breakpoint was created. +// There are a couple subtle aspects to syncing: +// +// - We handle both the original and generated source because there is no +// guarantee that seeing the generated source means we will also see the +// original source. When connecting, a breakpoint will be installed in the +// client for the generated location in the pending breakpoint, and we need +// to make sure that either a breakpoint is added to the reducer or that this +// client breakpoint is deleted. +// +// - If we see both the original and generated sources and the source mapping +// has changed, we need to make sure that only a single breakpoint is added +// to the reducer for the new location corresponding to the original location +// in the pending breakpoint. +export function syncBreakpoint( + cx: Context, + sourceId: SourceId, + pendingBreakpoint: PendingBreakpoint +) { + return async (thunkArgs: ThunkArgs) => { + const { getState, client, dispatch } = thunkArgs; + assertPendingBreakpoint(pendingBreakpoint); + + const source = getSource(getState(), sourceId); + + const generatedSourceId = isOriginalId(sourceId) + ? originalToGeneratedId(sourceId) + : sourceId; + + const generatedSource = getSource(getState(), generatedSourceId); + + if (!source || !generatedSource) { + return; + } + + const { location, generatedLocation, astLocation } = pendingBreakpoint; + const sourceGeneratedLocation = createLocation({ + ...generatedLocation, + sourceId: generatedSourceId, + }); + + if ( + source == generatedSource && + location.sourceUrl != generatedLocation.sourceUrl + ) { + // We are handling the generated source and the pending breakpoint has a + // source mapping. Supply a cancellation callback that will abort the + // breakpoint if the original source was synced to a different location, + // in which case the client breakpoint has been removed. + const breakpointLocation = makeBreakpointLocation( + getState(), + sourceGeneratedLocation + ); + return dispatch( + addBreakpoint( + cx, + sourceGeneratedLocation, + pendingBreakpoint.options, + pendingBreakpoint.disabled, + () => !client.hasBreakpoint(breakpointLocation) + ) + ); + } + + const previousLocation = { ...location, sourceId }; + + const newLocation = await findNewLocation( + cx, + astLocation, + previousLocation, + source, + thunkArgs + ); + + const newGeneratedLocation = await findBreakpointPosition( + cx, + thunkArgs, + newLocation + ); + + if (!newGeneratedLocation) { + // We couldn't find a new mapping for the breakpoint. If there is a source + // mapping, remove any breakpoints for the generated location, as if the + // breakpoint moved. If the old generated location still maps to an + // original location then we don't want to add a breakpoint for it. + if (location.sourceUrl != generatedLocation.sourceUrl) { + dispatch( + removeBreakpointAtGeneratedLocation(cx, sourceGeneratedLocation) + ); + } + return; + } + + const isSameLocation = comparePosition( + generatedLocation, + newGeneratedLocation + ); + + // If the new generated location has changed from that in the pending + // breakpoint, remove any breakpoint associated with the old generated + // location. + if (!isSameLocation) { + dispatch( + removeBreakpointAtGeneratedLocation(cx, sourceGeneratedLocation) + ); + } + + return dispatch( + addBreakpoint( + cx, + newLocation, + pendingBreakpoint.options, + pendingBreakpoint.disabled + ) + ); + }; +} diff --git a/devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap b/devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap new file mode 100644 index 0000000000..c55e71c546 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap @@ -0,0 +1,131 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`breakpoints should add a breakpoint 1`] = ` +Array [ + Object { + "breakpoints": Array [ + Object { + "astLocation": Object { + "index": 0, + "name": undefined, + "offset": Object { + "column": 1, + "line": 2, + "sourceId": "a", + "sourceUrl": "http://localhost:8000/examples/a", + }, + }, + "disabled": false, + "generatedLocation": Object { + "column": 1, + "line": 2, + "sourceId": "a", + "sourceUrl": "http://localhost:8000/examples/a", + }, + "id": "a:2:1", + "location": Object { + "column": 1, + "line": 2, + "sourceId": "a", + "sourceUrl": "http://localhost:8000/examples/a", + }, + "options": Object {}, + "originalText": "return a", + "text": "return a", + }, + ], + "source": Object { + "extensionName": null, + "id": "a", + "isBlackBoxed": false, + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "relativeUrl": "/examples/a", + "url": "http://localhost:8000/examples/a", + }, + }, +] +`; + +exports[`breakpoints should not show a breakpoint that does not have text 1`] = `Array []`; + +exports[`breakpoints should remap breakpoints on pretty print 1`] = ` +Object { + "astLocation": Object { + "index": 0, + "name": "a", + "offset": Object { + "column": undefined, + "line": 0, + }, + }, + "disabled": false, + "generatedLocation": Object { + "column": 0, + "line": 1, + "sourceId": "a.js", + "sourceUrl": "http://localhost:8000/examples/a.js", + }, + "id": "a.js/originalSource-d6d70368d5c252598541e693a7ad6c27:1:", + "location": Object { + "column": 0, + "line": 1, + "sourceId": "a.js/originalSource-d6d70368d5c252598541e693a7ad6c27", + "sourceUrl": "http://localhost:8000/examples/a.js:formatted", + }, + "options": Object {}, + "originalText": "function a() {", + "text": "function a() {", +} +`; + +exports[`breakpoints should show a disabled breakpoint that does not have text 1`] = ` +Array [ + Object { + "breakpoints": Array [ + Object { + "astLocation": Object { + "index": 0, + "name": undefined, + "offset": Object { + "column": 1, + "line": 5, + "sourceId": "a", + "sourceUrl": "http://localhost:8000/examples/a", + }, + }, + "disabled": true, + "generatedLocation": Object { + "column": 1, + "line": 5, + "sourceId": "a", + "sourceUrl": "http://localhost:8000/examples/a", + }, + "id": "a:5:1", + "location": Object { + "column": 1, + "line": 5, + "sourceId": "a", + "sourceUrl": "http://localhost:8000/examples/a", + }, + "options": Object {}, + "originalText": "", + "text": "", + }, + ], + "source": Object { + "extensionName": null, + "id": "a", + "isBlackBoxed": false, + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "relativeUrl": "/examples/a", + "url": "http://localhost:8000/examples/a", + }, + }, +] +`; diff --git a/devtools/client/debugger/src/actions/breakpoints/tests/breakpointPositions.spec.js b/devtools/client/debugger/src/actions/breakpoints/tests/breakpointPositions.spec.js new file mode 100644 index 0000000000..6798cf419b --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/tests/breakpointPositions.spec.js @@ -0,0 +1,112 @@ +// @flow + +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + actions, + selectors, + createStore, + makeSource, + waitForState, +} from "../../../utils/test-head"; +import { createSource } from "../../tests/helpers/mockCommandClient"; + +describe("breakpointPositions", () => { + it("fetches positions", async () => { + const fooContent = createSource("foo", ""); + + const store = createStore({ + getSourceActorBreakpointPositions: async () => ({ "9": [1] }), + getSourceActorBreakableLines: async () => [], + sourceContents: async () => fooContent, + }); + + const { dispatch, getState, cx } = store; + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + await dispatch(actions.loadSourceById(cx, source.id)); + + dispatch(actions.setBreakpointPositions({ cx, sourceId: "foo", line: 9 })); + + await waitForState(store, state => + selectors.hasBreakpointPositions(state, "foo") + ); + + expect( + selectors.getBreakpointPositionsForSource(getState(), "foo") + ).toEqual({ + [9]: [ + { + location: { + line: 9, + column: 1, + sourceId: "foo", + sourceUrl: "http://localhost:8000/examples/foo", + }, + generatedLocation: { + line: 9, + column: 1, + sourceId: "foo", + sourceUrl: "http://localhost:8000/examples/foo", + }, + }, + ], + }); + }); + + it("doesn't re-fetch positions", async () => { + const fooContent = createSource("foo", ""); + + let resolve = _ => {}; + let count = 0; + const store = createStore({ + getSourceActorBreakpointPositions: () => + new Promise(r => { + count++; + resolve = r; + }), + getSourceActorBreakableLines: async () => [], + sourceContents: async () => fooContent, + }); + + const { dispatch, getState, cx } = store; + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + await dispatch(actions.loadSourceById(cx, source.id)); + + dispatch(actions.setBreakpointPositions({ cx, sourceId: "foo", line: 9 })); + dispatch(actions.setBreakpointPositions({ cx, sourceId: "foo", line: 9 })); + + resolve({ "9": [1] }); + await waitForState(store, state => + selectors.hasBreakpointPositions(state, "foo") + ); + + expect( + selectors.getBreakpointPositionsForSource(getState(), "foo") + ).toEqual({ + [9]: [ + { + location: { + line: 9, + column: 1, + sourceId: "foo", + sourceUrl: "http://localhost:8000/examples/foo", + }, + generatedLocation: { + line: 9, + column: 1, + sourceId: "foo", + sourceUrl: "http://localhost:8000/examples/foo", + }, + }, + ], + }); + + expect(count).toEqual(1); + }); +}); diff --git a/devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js b/devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js new file mode 100644 index 0000000000..4a1e68a0bd --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js @@ -0,0 +1,487 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + createStore, + selectors, + actions, + makeSource, + getTelemetryEvents, +} from "../../../utils/test-head"; + +import { mockCommandClient } from "../../tests/helpers/mockCommandClient"; +import { mockPendingBreakpoint } from "../../tests/helpers/breakpoints.js"; +import { makePendingLocationId } from "../../../utils/breakpoint"; + +function mockClient(positionsResponse = {}) { + return { + ...mockCommandClient, + getSourceActorBreakpointPositions: async () => positionsResponse, + getSourceActorBreakableLines: async () => [], + }; +} + +describe("breakpoints", () => { + it("should add a breakpoint", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ "2": [1] })); + const loc1 = { + sourceId: "a", + line: 2, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }; + + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + await dispatch(actions.loadSourceText({ cx, source })); + await dispatch( + actions.setSelectedLocation(cx, source, { + line: 1, + column: 1, + sourceId: source.id, + }) + ); + + await dispatch(actions.addBreakpoint(cx, loc1)); + + expect(selectors.getBreakpointCount(getState())).toEqual(1); + const bp = selectors.getBreakpoint(getState(), loc1); + expect(bp && bp.location).toEqual(loc1); + expect(getTelemetryEvents("add_breakpoint")).toHaveLength(1); + + const bpSources = selectors.getBreakpointSources(getState()); + expect(bpSources).toMatchSnapshot(); + }); + + it("should not show a breakpoint that does not have text", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ "5": [1] })); + const loc1 = { + sourceId: "a", + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }; + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + await dispatch(actions.loadSourceText({ cx, source })); + await dispatch( + actions.setSelectedLocation(cx, source, { + line: 1, + column: 1, + sourceId: source.id, + }) + ); + + await dispatch(actions.addBreakpoint(cx, loc1)); + + expect(selectors.getBreakpointCount(getState())).toEqual(1); + const bp = selectors.getBreakpoint(getState(), loc1); + expect(bp && bp.location).toEqual(loc1); + expect(selectors.getBreakpointSources(getState())).toMatchSnapshot(); + }); + + it("should show a disabled breakpoint that does not have text", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ "5": [1] })); + const loc1 = { + sourceId: "a", + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }; + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + await dispatch(actions.loadSourceText({ cx, source })); + await dispatch( + actions.setSelectedLocation(cx, source, { + line: 1, + column: 1, + sourceId: source.id, + }) + ); + + await dispatch(actions.addBreakpoint(cx, loc1)); + const breakpoint = selectors.getBreakpoint(getState(), loc1); + if (!breakpoint) { + throw new Error("no breakpoint"); + } + + await dispatch(actions.disableBreakpoint(cx, breakpoint)); + + expect(selectors.getBreakpointCount(getState())).toEqual(1); + const bp = selectors.getBreakpoint(getState(), loc1); + expect(bp && bp.location).toEqual(loc1); + expect(selectors.getBreakpointSources(getState())).toMatchSnapshot(); + }); + + it("should not re-add a breakpoint", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ "5": [1] })); + const loc1 = { + sourceId: "a", + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }; + + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + await dispatch(actions.loadSourceText({ cx, source })); + await dispatch( + actions.setSelectedLocation(cx, source, { + line: 1, + column: 1, + sourceId: source.id, + }) + ); + + await dispatch(actions.addBreakpoint(cx, loc1)); + expect(selectors.getBreakpointCount(getState())).toEqual(1); + const bp = selectors.getBreakpoint(getState(), loc1); + expect(bp && bp.location).toEqual(loc1); + + await dispatch(actions.addBreakpoint(cx, loc1)); + expect(selectors.getBreakpointCount(getState())).toEqual(1); + }); + + it("should remove a breakpoint", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ "5": [1], "6": [2] }) + ); + + const loc1 = { + sourceId: "a", + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }; + + const loc2 = { + sourceId: "b", + line: 6, + column: 2, + sourceUrl: "http://localhost:8000/examples/b", + }; + + const aSource = await dispatch(actions.newGeneratedSource(makeSource("a"))); + await dispatch(actions.loadSourceText({ cx, source: aSource })); + + const bSource = await dispatch(actions.newGeneratedSource(makeSource("b"))); + await dispatch(actions.loadSourceText({ cx, source: bSource })); + + await dispatch( + actions.setSelectedLocation(cx, aSource, { + line: 1, + column: 1, + sourceId: aSource.id, + }) + ); + + await dispatch(actions.addBreakpoint(cx, loc1)); + await dispatch(actions.addBreakpoint(cx, loc2)); + + const bp = selectors.getBreakpoint(getState(), loc1); + if (!bp) { + throw new Error("no bp"); + } + await dispatch(actions.removeBreakpoint(cx, bp)); + + expect(selectors.getBreakpointCount(getState())).toEqual(1); + }); + + it("should disable a breakpoint", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ "5": [1], "6": [2] }) + ); + + const loc1 = { + sourceId: "a", + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }; + + const loc2 = { + sourceId: "b", + line: 6, + column: 2, + sourceUrl: "http://localhost:8000/examples/b", + }; + + const aSource = await dispatch(actions.newGeneratedSource(makeSource("a"))); + await dispatch(actions.loadSourceText({ cx, source: aSource })); + + const bSource = await dispatch(actions.newGeneratedSource(makeSource("b"))); + await dispatch(actions.loadSourceText({ cx, source: bSource })); + + await dispatch(actions.addBreakpoint(cx, loc1)); + await dispatch(actions.addBreakpoint(cx, loc2)); + + const breakpoint = selectors.getBreakpoint(getState(), loc1); + if (!breakpoint) { + throw new Error("no breakpoint"); + } + + await dispatch(actions.disableBreakpoint(cx, breakpoint)); + + const bp = selectors.getBreakpoint(getState(), loc1); + expect(bp && bp.disabled).toBe(true); + }); + + it("should enable breakpoint", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ "5": [1], "6": [2] }) + ); + const loc = { + sourceId: "a", + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }; + + const aSource = await dispatch(actions.newGeneratedSource(makeSource("a"))); + await dispatch(actions.loadSourceText({ cx, source: aSource })); + + await dispatch(actions.addBreakpoint(cx, loc)); + let bp = selectors.getBreakpoint(getState(), loc); + if (!bp) { + throw new Error("no breakpoint"); + } + + await dispatch(actions.disableBreakpoint(cx, bp)); + + bp = selectors.getBreakpoint(getState(), loc); + if (!bp) { + throw new Error("no breakpoint"); + } + + expect(bp && bp.disabled).toBe(true); + + await dispatch(actions.enableBreakpoint(cx, bp)); + + bp = selectors.getBreakpoint(getState(), loc); + expect(bp && !bp.disabled).toBe(true); + }); + + it("should toggle all the breakpoints", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ "5": [1], "6": [2] }) + ); + + const loc1 = { + sourceId: "a", + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }; + + const loc2 = { + sourceId: "b", + line: 6, + column: 2, + sourceUrl: "http://localhost:8000/examples/b", + }; + + const aSource = await dispatch(actions.newGeneratedSource(makeSource("a"))); + await dispatch(actions.loadSourceText({ cx, source: aSource })); + + const bSource = await dispatch(actions.newGeneratedSource(makeSource("b"))); + await dispatch(actions.loadSourceText({ cx, source: bSource })); + + await dispatch(actions.addBreakpoint(cx, loc1)); + await dispatch(actions.addBreakpoint(cx, loc2)); + + await dispatch(actions.toggleAllBreakpoints(cx, true)); + + let bp1 = selectors.getBreakpoint(getState(), loc1); + let bp2 = selectors.getBreakpoint(getState(), loc2); + + expect(bp1 && bp1.disabled).toBe(true); + expect(bp2 && bp2.disabled).toBe(true); + + await dispatch(actions.toggleAllBreakpoints(cx, false)); + + bp1 = selectors.getBreakpoint(getState(), loc1); + bp2 = selectors.getBreakpoint(getState(), loc2); + expect(bp1 && bp1.disabled).toBe(false); + expect(bp2 && bp2.disabled).toBe(false); + }); + + it("should remove all the breakpoints", async () => { + const mockedPendingBreakpoint = mockPendingBreakpoint({ column: 2 }); + const id = makePendingLocationId(mockedPendingBreakpoint.location); + const pendingBreakpoints = { [id]: mockedPendingBreakpoint }; + + const { dispatch, getState, cx } = createStore( + mockClient({ "5": [1], "6": [2] }), + { pendingBreakpoints } + ); + + const loc1 = { + sourceId: "a", + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }; + + const loc2 = { + sourceId: "b", + line: 6, + column: 2, + sourceUrl: "http://localhost:8000/examples/b", + }; + + const aSource = await dispatch(actions.newGeneratedSource(makeSource("a"))); + await dispatch(actions.loadSourceText({ cx, source: aSource })); + + const bSource = await dispatch(actions.newGeneratedSource(makeSource("b"))); + await dispatch(actions.loadSourceText({ cx, source: bSource })); + + await dispatch(actions.addBreakpoint(cx, loc1)); + await dispatch(actions.addBreakpoint(cx, loc2)); + + await dispatch(actions.removeAllBreakpoints(cx)); + + const bps = selectors.getBreakpointsList(getState()); + const pendingBps = selectors.getPendingBreakpointList(getState()); + + expect(bps).toHaveLength(0); + expect(pendingBps).toHaveLength(0); + }); + + it("should toggle a breakpoint at a location", async () => { + const loc = { sourceId: "foo1", line: 5, column: 1 }; + const getBp = () => selectors.getBreakpoint(getState(), loc); + + const { dispatch, getState, cx } = createStore(mockClient({ "5": [1] })); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo1")) + ); + await dispatch(actions.loadSourceText({ cx, source })); + + await dispatch(actions.selectLocation(cx, loc)); + + await dispatch(actions.toggleBreakpointAtLine(cx, 5)); + const bp = getBp(); + expect(bp && !bp.disabled).toBe(true); + + await dispatch(actions.toggleBreakpointAtLine(cx, 5)); + expect(getBp()).toBe(undefined); + }); + + it("should disable/enable a breakpoint at a location", async () => { + const location = { sourceId: "foo1", line: 5, column: 1 }; + const getBp = () => selectors.getBreakpoint(getState(), location); + + const { dispatch, getState, cx } = createStore(mockClient({ "5": [1] })); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo1")) + ); + await dispatch(actions.loadSourceText({ cx, source })); + + await dispatch(actions.selectLocation(cx, { sourceId: "foo1", line: 1 })); + + await dispatch(actions.toggleBreakpointAtLine(cx, 5)); + let bp = getBp(); + expect(bp && !bp.disabled).toBe(true); + bp = getBp(); + if (!bp) { + throw new Error("no bp"); + } + await dispatch(actions.toggleDisabledBreakpoint(cx, bp)); + bp = getBp(); + expect(bp && bp.disabled).toBe(true); + }); + + it("should set the breakpoint condition", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ "5": [1] })); + + const loc = { + sourceId: "a", + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }; + + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + await dispatch(actions.loadSourceText({ cx, source })); + + await dispatch(actions.addBreakpoint(cx, loc)); + + let bp = selectors.getBreakpoint(getState(), loc); + expect(bp && bp.options.condition).toBe(undefined); + + await dispatch( + actions.setBreakpointOptions(cx, loc, { + condition: "const foo = 0", + getTextForLine: () => {}, + }) + ); + + bp = selectors.getBreakpoint(getState(), loc); + expect(bp && bp.options.condition).toBe("const foo = 0"); + }); + + it("should set the condition and enable a breakpoint", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ "5": [1] })); + + const loc = { + sourceId: "a", + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }; + + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + await dispatch(actions.loadSourceText({ cx, source })); + + await dispatch(actions.addBreakpoint(cx, loc)); + let bp = selectors.getBreakpoint(getState(), loc); + if (!bp) { + throw new Error("no breakpoint"); + } + + await dispatch(actions.disableBreakpoint(cx, bp)); + + bp = selectors.getBreakpoint(getState(), loc); + expect(bp && bp.options.condition).toBe(undefined); + + await dispatch( + actions.setBreakpointOptions(cx, loc, { + condition: "const foo = 0", + getTextForLine: () => {}, + }) + ); + const newBreakpoint = selectors.getBreakpoint(getState(), loc); + expect(newBreakpoint && !newBreakpoint.disabled).toBe(true); + expect(newBreakpoint && newBreakpoint.options.condition).toBe( + "const foo = 0" + ); + }); + + it("should remap breakpoints on pretty print", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ "1": [0] })); + + const loc = { + sourceId: "a.js", + line: 1, + column: 0, + sourceUrl: "http://localhost:8000/examples/a.js", + }; + + const source = await dispatch( + actions.newGeneratedSource(makeSource("a.js")) + ); + await dispatch(actions.loadSourceText({ cx, source })); + + await dispatch(actions.addBreakpoint(cx, loc)); + await dispatch(actions.togglePrettyPrint(cx, "a.js")); + + const breakpoint = selectors.getBreakpointsList(getState())[0]; + + expect( + breakpoint.location.sourceUrl && + breakpoint.location.sourceUrl.includes("formatted") + ).toBe(true); + expect(breakpoint).toMatchSnapshot(); + }); +}); 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..e2112ff763 --- /dev/null +++ b/devtools/client/debugger/src/actions/event-listeners.js @@ -0,0 +1,85 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { uniq, remove } from "lodash"; + +import { + getActiveEventListeners, + getEventListenerExpanded, + shouldLogEventBreakpoints, +} from "../selectors"; + +import type { ThunkArgs } from "./types"; + +async function updateBreakpoints(dispatch, client, newEvents: string[]) { + dispatch({ type: "UPDATE_EVENT_LISTENERS", active: newEvents }); + await client.setEventListenerBreakpoints(newEvents); +} + +async function updateExpanded(dispatch, newExpanded: string[]) { + dispatch({ + type: "UPDATE_EVENT_LISTENER_EXPANDED", + expanded: newExpanded, + }); +} + +export function addEventListenerBreakpoints(eventsToAdd: string[]) { + return async ({ dispatch, client, getState }: ThunkArgs) => { + const activeListenerBreakpoints = await getActiveEventListeners(getState()); + + const newEvents = uniq([...eventsToAdd, ...activeListenerBreakpoints]); + + await updateBreakpoints(dispatch, client, newEvents); + }; +} + +export function removeEventListenerBreakpoints(eventsToRemove: string[]) { + return async ({ dispatch, client, getState }: ThunkArgs) => { + const activeListenerBreakpoints = await getActiveEventListeners(getState()); + + const newEvents = remove( + activeListenerBreakpoints, + event => !eventsToRemove.includes(event) + ); + + await updateBreakpoints(dispatch, client, newEvents); + }; +} + +export function toggleEventLogging() { + return async ({ dispatch, getState, client }: ThunkArgs) => { + const logEventBreakpoints = !shouldLogEventBreakpoints(getState()); + await client.toggleEventLogging(logEventBreakpoints); + dispatch({ type: "TOGGLE_EVENT_LISTENERS", logEventBreakpoints }); + }; +} + +export function addEventListenerExpanded(category: string) { + return async ({ dispatch, getState }: ThunkArgs) => { + const expanded = await getEventListenerExpanded(getState()); + + const newExpanded = uniq([...expanded, category]); + + await updateExpanded(dispatch, newExpanded); + }; +} + +export function removeEventListenerExpanded(category: string) { + return async ({ dispatch, getState }: ThunkArgs) => { + const expanded = await getEventListenerExpanded(getState()); + + const newExpanded = expanded.filter(expand => expand != category); + + updateExpanded(dispatch, newExpanded); + }; +} + +export function getEventListenerBreakpointTypes() { + return async ({ dispatch, client }: ThunkArgs) => { + 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..f054715041 --- /dev/null +++ b/devtools/client/debugger/src/actions/exceptions.js @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { hasException } from "../selectors"; + +import type { ThunkArgs } from "./types"; +import type { Exception } from "../types"; + +export function addExceptionFromResources(resources: Array<Object>) { + return async function({ dispatch }: ThunkArgs) { + 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, + }; + + dispatch(addException(exception)); + } + }; +} + +export function addException(exception: Exception) { + return async function({ dispatch, getState }: ThunkArgs) { + const { columnNumber, lineNumber } = exception; + + if (!hasException(getState(), lineNumber, columnNumber)) { + 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..0f3ea67cae --- /dev/null +++ b/devtools/client/debugger/src/actions/expressions.js @@ -0,0 +1,210 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + getExpression, + getExpressions, + getSelectedFrame, + getSelectedFrameId, + getSourceFromId, + getSelectedSource, + getSelectedScopeMappings, + getSelectedFrameBindings, + getCurrentThread, + getIsPaused, + isMapScopesEnabled, +} from "../selectors"; +import { PROMISE } from "./utils/middleware/promise"; +import { wrapExpression } from "../utils/expressions"; +import { features } from "../utils/prefs"; +import { isOriginal } from "../utils/source"; + +import type { Expression, ThreadContext } from "../types"; +import type { ThunkArgs } from "./types"; + +/** + * Add expression for debugger to watch + * + * @param {object} expression + * @param {number} expression.id + * @memberof actions/pause + * @static + */ +export function addExpression(cx: ThreadContext, input: string) { + return async ({ dispatch, getState, evaluationsParser }: ThunkArgs) => { + if (!input) { + return; + } + + const expressionError = await evaluationsParser.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)); + } + }; +} + +export function autocomplete(cx: ThreadContext, input: string, cursor: number) { + return async ({ dispatch, getState, client }: ThunkArgs) => { + if (!input) { + return; + } + const frameId = getSelectedFrameId(getState(), cx.thread); + const result = await client.autocomplete(input, cursor, frameId); + await 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: ThreadContext, + input: string, + expression: Expression +) { + return async ({ dispatch, getState, parser }: ThunkArgs) => { + if (!input) { + return; + } + + const expressionError = await parser.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: Expression) { + return ({ dispatch }: ThunkArgs) => { + dispatch({ + type: "DELETE_EXPRESSION", + input: expression.input, + }); + }; +} + +/** + * + * @memberof actions/pause + * @param {number} selectedFrameId + * @static + */ +export function evaluateExpressions(cx: ThreadContext) { + return async function({ dispatch, getState, client }: ThunkArgs) { + const expressions = getExpressions(getState()); + const inputs = expressions.map(({ input }) => input); + const frameId = getSelectedFrameId(getState(), cx.thread); + const results = await client.evaluateExpressions(inputs, { + frameId, + thread: cx.thread, + }); + dispatch({ type: "EVALUATE_EXPRESSIONS", cx, inputs, results }); + }; +} + +function evaluateExpression(cx: ThreadContext, expression: Expression) { + return async function({ dispatch, getState, client, sourceMaps }: ThunkArgs) { + if (!expression.input) { + console.warn("Expressions should not be empty"); + return; + } + + let { input } = expression; + const frame = getSelectedFrame(getState(), cx.thread); + + if (frame) { + const { location } = frame; + const source = getSourceFromId(getState(), location.sourceId); + + const selectedSource = getSelectedSource(getState()); + + if (selectedSource && isOriginal(source) && isOriginal(selectedSource)) { + 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.evaluateInFrame(wrapExpression(input), { + frameId, + thread: cx.thread, + }), + }); + }; +} + +/** + * Gets information about original variable names from the source map + * and replaces all posible generated names. + */ +export function getMappedExpression(expression: string) { + return async function({ + dispatch, + getState, + client, + sourceMaps, + evaluationsParser, + }: ThunkArgs) { + 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 evaluationsParser + // 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 evaluationsParser.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..92205f9fdc --- /dev/null +++ b/devtools/client/debugger/src/actions/file-search.js @@ -0,0 +1,215 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + clearSearch, + find, + findNext, + findPrev, + removeOverlay, + searchSourceForHighlight, +} from "../utils/editor"; +import { renderWasmText } from "../utils/wasm"; +import { getMatches } from "../workers/search"; +import type { Action, FileTextSearchModifier, ThunkArgs } from "./types"; +import type { Context } from "../types"; + +import { + getSelectedSourceWithContent, + getFileSearchModifiers, + getFileSearchQuery, + getFileSearchResults, +} from "../selectors"; + +import { + closeActiveSearch, + clearHighlightLineRange, + setActiveSearch, +} from "./ui"; +import { isFulfilled } from "../utils/async-value"; +type Editor = Object; +type Match = Object; + +export function doSearch(cx: Context, query: string, editor: Editor) { + return ({ getState, dispatch }: ThunkArgs) => { + const selectedSource = getSelectedSourceWithContent(getState()); + if (!selectedSource || !selectedSource.content) { + return; + } + + dispatch(setFileSearchQuery(cx, query)); + dispatch(searchContents(cx, query, editor)); + }; +} + +export function doSearchForHighlight( + query: string, + editor: Editor, + line: number, + ch: number +) { + return async ({ getState, dispatch }: ThunkArgs) => { + const selectedSource = getSelectedSourceWithContent(getState()); + if (!selectedSource?.content) { + return; + } + dispatch(searchContentsForHighlight(query, editor, line, ch)); + }; +} + +export function setFileSearchQuery(cx: Context, query: string): Action { + return { + type: "UPDATE_FILE_SEARCH_QUERY", + cx, + query, + }; +} + +export function toggleFileSearchModifier( + cx: Context, + modifier: FileTextSearchModifier +): Action { + return { type: "TOGGLE_FILE_SEARCH_MODIFIER", cx, modifier }; +} + +export function updateSearchResults( + cx: Context, + characterIndex: number, + line: number, + matches: Match[] +): Action { + const matchIndex = matches.findIndex( + elm => elm.line === line && elm.ch === characterIndex + ); + + return { + type: "UPDATE_SEARCH_RESULTS", + cx, + results: { + matches, + matchIndex, + count: matches.length, + index: characterIndex, + }, + }; +} + +export function searchContents( + cx: Context, + query: string, + editor: Object, + focusFirstResult?: boolean = true +) { + return async ({ getState, dispatch }: ThunkArgs) => { + const modifiers = getFileSearchModifiers(getState()); + const selectedSource = getSelectedSourceWithContent(getState()); + + if ( + !editor || + !selectedSource || + !selectedSource.content || + !isFulfilled(selectedSource.content) || + !modifiers + ) { + return; + } + const selectedContent = selectedSource.content.value; + + const ctx = { ed: editor, cm: editor.codeMirror }; + + if (!query) { + clearSearch(ctx.cm, query); + return; + } + + let text; + if (selectedContent.type === "wasm") { + text = renderWasmText(selectedSource.id, selectedContent).join("\n"); + } else { + text = selectedContent.value; + } + + const matches = await getMatches(query, text, modifiers); + + const res = find(ctx, query, true, modifiers, focusFirstResult); + if (!res) { + return; + } + + const { ch, line } = res; + + dispatch(updateSearchResults(cx, ch, line, matches)); + }; +} + +export function searchContentsForHighlight( + query: string, + editor: Object, + line: number, + ch: number +) { + return async ({ getState, dispatch }: ThunkArgs) => { + const modifiers = getFileSearchModifiers(getState()); + const selectedSource = getSelectedSourceWithContent(getState()); + + if ( + !query || + !editor || + !selectedSource || + !selectedSource.content || + !modifiers + ) { + return; + } + + const ctx = { ed: editor, cm: editor.codeMirror }; + searchSourceForHighlight(ctx, false, query, true, modifiers, line, ch); + }; +} + +export function traverseResults(cx: Context, rev: boolean, editor: Editor) { + return async ({ getState, dispatch }: ThunkArgs) => { + if (!editor) { + return; + } + + const ctx = { ed: editor, cm: editor.codeMirror }; + + const query = getFileSearchQuery(getState()); + const modifiers = getFileSearchModifiers(getState()); + const { matches } = getFileSearchResults(getState()); + + if (query === "") { + dispatch(setActiveSearch("file")); + } + + if (modifiers) { + const matchedLocations = matches || []; + const findArgs = [ctx, query, true, modifiers]; + const results = rev ? findPrev(...findArgs) : findNext(...findArgs); + + if (!results) { + return; + } + const { ch, line } = results; + dispatch(updateSearchResults(cx, ch, line, matchedLocations)); + } + }; +} + +export function closeFileSearch(cx: Context, editor: Editor) { + return ({ getState, dispatch }: ThunkArgs) => { + if (editor) { + const query = getFileSearchQuery(getState()); + const ctx = { ed: editor, cm: editor.codeMirror }; + removeOverlay(ctx, query); + } + + dispatch(setFileSearchQuery(cx, "")); + 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..205f70114f --- /dev/null +++ b/devtools/client/debugger/src/actions/index.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/>. */ + +// @flow + +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 sourceTree from "./source-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"; + +// $FlowIgnore +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, + ...sourceTree, + ...threads, + ...toolbox, + ...preview, +}; diff --git a/devtools/client/debugger/src/actions/moz.build b/devtools/client/debugger/src/actions/moz.build new file mode 100644 index 0000000000..32978f266b --- /dev/null +++ b/devtools/client/debugger/src/actions/moz.build @@ -0,0 +1,30 @@ +# 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", + "source-tree.js", + "tabs.js", + "toolbox.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..555efb719b --- /dev/null +++ b/devtools/client/debugger/src/actions/navigation.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/>. */ + +// @flow + +import { clearDocuments } from "../utils/editor"; +import sourceQueue from "../utils/source-queue"; + +import { evaluateExpressions } from "./expressions"; + +import { clearWasmStates } from "../utils/wasm"; +import { getMainThread, getThreadContext } from "../selectors"; +import type { Action, ThunkArgs } from "./types"; +import type { ActorId, URL } from "../types"; + +/** + * Redux actions for the navigation state + * @module actions/navigation + */ + +/** + * @memberof actions/navigation + * @static + */ +export function willNavigate(event: Object) { + return async function({ + dispatch, + getState, + client, + sourceMaps, + parser, + }: ThunkArgs) { + sourceQueue.clear(); + sourceMaps.clearSourceMaps(); + clearWasmStates(); + clearDocuments(); + parser.clear(); + const thread = getMainThread(getState()); + + dispatch({ + type: "NAVIGATE", + mainThread: { ...thread, url: event.url }, + }); + }; +} + +export function connect( + url: URL, + actor: ActorId, + traits: Object, + isWebExtension: boolean +) { + return async function({ dispatch, getState }: ThunkArgs) { + await dispatch( + ({ + type: "CONNECT", + traits, + mainThreadActorID: actor, + isWebExtension, + }: Action) + ); + + const cx = getThreadContext(getState()); + dispatch(evaluateExpressions(cx)); + }; +} + +/** + * @memberof actions/navigation + * @static + */ +export function navigated() { + return async function({ dispatch, panel }: ThunkArgs) { + 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..f82fdb7c20 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/breakOnNext.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/>. */ + +// @flow + +import type { ThunkArgs } from "../types"; +import type { PauseAction } from "../types/PauseAction"; +import type { ThreadContext } from "../../types"; + +/** + * 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: ThreadContext): PauseAction { + return async ({ dispatch, getState, client }: ThunkArgs) => { + 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..bf39687de6 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/commands.js @@ -0,0 +1,150 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + getSelectedFrame, + getThreadContext, + getCurrentThread, +} 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 { features } from "../../utils/prefs"; +import assert from "../../utils/assert"; + +import type { ThreadId, Context, ThreadContext, Frame } from "../../types"; + +import type { ThunkArgs } from "../types"; +import type { Command } from "../../reducers/types"; + +export function selectThread(cx: Context, thread: ThreadId) { + return async ({ dispatch, getState, client }: ThunkArgs) => { + if (getCurrentThread(getState()) === thread) { + return; + } + + await dispatch({ cx, type: "SELECT_THREAD", thread }); + + // Get a new context now that the current thread has changed. + const threadcx = getThreadContext(getState()); + assert(threadcx.thread == thread, "Thread mismatch"); + + const serverRequests = []; + serverRequests.push(dispatch(evaluateExpressions(threadcx))); + + 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(cx: ThreadContext, type: Command) { + return async ({ dispatch, getState, client }: ThunkArgs) => { + if (!type) { + return; + } + + const frame = features.frameStep && getSelectedFrame(getState(), cx.thread); + + return dispatch({ + type: "COMMAND", + command: type, + cx, + thread: cx.thread, + [PROMISE]: client[type](cx.thread, frame?.id), + }); + }; +} + +/** + * StepIn + * @memberof actions/pause + * @static + * @returns {Function} {@link command} + */ +export function stepIn(cx: ThreadContext) { + return ({ dispatch, getState }: ThunkArgs) => { + if (cx.isPaused) { + return dispatch(command(cx, "stepIn")); + } + }; +} + +/** + * stepOver + * @memberof actions/pause + * @static + * @returns {Function} {@link command} + */ +export function stepOver(cx: ThreadContext) { + return ({ dispatch, getState }: ThunkArgs) => { + if (cx.isPaused) { + return dispatch(command(cx, "stepOver")); + } + }; +} + +/** + * stepOut + * @memberof actions/pause + * @static + * @returns {Function} {@link command} + */ +export function stepOut(cx: ThreadContext) { + return ({ dispatch, getState }: ThunkArgs) => { + if (cx.isPaused) { + return dispatch(command(cx, "stepOut")); + } + }; +} + +/** + * resume + * @memberof actions/pause + * @static + * @returns {Function} {@link command} + */ +export function resume(cx: ThreadContext) { + return ({ dispatch, getState }: ThunkArgs) => { + if (cx.isPaused) { + recordEvent("continue"); + return dispatch(command(cx, "resume")); + } + }; +} + +/** + * restart frame + * @memberof actions/pause + * @static + */ +export function restart(cx: ThreadContext, frame: Frame) { + return async ({ dispatch, getState, client }: ThunkArgs) => { + if (cx.isPaused) { + return dispatch({ + type: "COMMAND", + command: "restart", + cx, + 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..70e6192d19 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/continueToHere.js @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + getSelectedSource, + getSelectedFrame, + getClosestBreakpointPosition, + getBreakpoint, +} from "../../selectors"; +import { addHiddenBreakpoint } from "../breakpoints"; +import { setBreakpointPositions } from "../breakpoints/breakpointPositions"; + +import { resume } from "./commands"; + +import type { ThunkArgs } from "../types"; +import type { ThreadContext, SourceLocation } from "../../types"; + +export function continueToHere(cx: ThreadContext, location: SourceLocation) { + return async function({ dispatch, getState }: ThunkArgs) { + const { line, column, sourceId } = 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, sourceId, line })); + 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, { + sourceId: selectedSource.id, + 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..034d809858 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/expandScopes.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { getScopeItemPath } from "../../utils/pause/scopes/utils"; +import type { ThunkArgs } from "../types"; +import type { ThreadContext } from "../../types"; + +export function setExpandedScope( + cx: ThreadContext, + item: Object, + expanded: boolean +) { + return function({ dispatch, getState }: ThunkArgs) { + 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..a6b448bf26 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/fetchFrames.js @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { ThreadContext } from "../../types"; +import type { ThunkArgs } from "../types"; +import { isValidThreadContext } from "../../utils/context"; + +export function fetchFrames(cx: ThreadContext) { + return async function({ dispatch, client, getState }: ThunkArgs) { + 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..801330a934 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/fetchScopes.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/>. */ + +// @flow + +import { getSelectedFrame, getGeneratedFrameScope } from "../../selectors"; +import { mapScopes } from "./mapScopes"; +import { generateInlinePreview } from "./inlinePreview"; +import { PROMISE } from "../utils/middleware/promise"; +import type { ThreadContext } from "../../types"; +import type { ThunkArgs } from "../types"; + +export function fetchScopes(cx: ThreadContext) { + return async function({ dispatch, getState, client, sourceMaps }: ThunkArgs) { + 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..0c52a2ce16 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/highlightCalls.js @@ -0,0 +1,99 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + getSymbols, + getSource, + getSelectedFrame, + getCurrentThread, +} from "../../selectors"; +import type { ThreadContext } from "../../types"; +import type { ThunkArgs } from "../types"; + +// 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: Object, b: Object): boolean { + 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: ThreadContext) { + return async function({ dispatch, getState, parser, client }: ThunkArgs) { + if (!cx) { + return; + } + + const frame = await getSelectedFrame( + getState(), + getCurrentThread(getState()) + ); + + if (!frame) { + return; + } + + const { thread } = cx; + + const originalAstScopes = await parser.getScopes(frame.location); + if (!originalAstScopes) { + return; + } + + const source = getSource(getState(), frame.location.sourceId); + if (!source) { + return; + } + + const symbols = getSymbols(getState(), source); + + if (!symbols || symbols.loading) { + return; + } + + if (!symbols.callExpressions) { + return; + } + + 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: ThreadContext) { + return async function({ dispatch, getState, parser, client }: ThunkArgs) { + 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..621918b0f4 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/index.js @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * Redux actions for 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 { 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"; +export { + previewPausedLocation, + clearPreviewPausedLocation, +} from "./previewPausedLocation"; 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..2dd7d36031 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/inlinePreview.js @@ -0,0 +1,238 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +import { sortBy } from "lodash"; +import { + getOriginalFrameScope, + getGeneratedFrameScope, + getInlinePreviews, + getSelectedLocation, +} from "../../selectors"; +import { features } from "../../utils/prefs"; +import { validateThreadContext } from "../../utils/context"; + +import type { OriginalScope } from "../../utils/pause/mapScopes"; +import type { ThreadContext, Frame, Scope, Preview } from "../../types"; +import type { ThunkArgs } from "../types"; +import type { SourceScope } from "../../workers/parser/getScopes"; + +// 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: SourceScope[]): number { + let levels = 0; + while ( + originalAstScopes[levels] && + originalAstScopes[levels].type === "block" + ) { + levels++; + } + return levels; +} + +export function generateInlinePreview(cx: ThreadContext, frame: ?Frame) { + return async function({ dispatch, getState, parser, client }: ThunkArgs) { + if (!frame || !features.inlinePreview) { + return; + } + + const { thread } = cx; + + // Avoid regenerating inline previews when we already have preview data + if (getInlinePreviews(getState(), thread, frame.id)) { + return; + } + + const originalFrameScopes = getOriginalFrameScope( + getState(), + thread, + frame.location.sourceId, + frame.id + ); + + const generatedFrameScopes = getGeneratedFrameScope( + getState(), + thread, + frame.id + ); + + let scopes: ?OriginalScope | Scope | null = + originalFrameScopes?.scope || generatedFrameScopes?.scope; + + if (!scopes || !scopes.bindings) { + return; + } + + // 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; + } + + const originalAstScopes = await parser.getScopes(selectedLocation); + validateThreadContext(getState(), cx); + if (!originalAstScopes) { + return; + } + + const allPreviews = []; + const pausedOnLine: number = selectedLocation.line; + const levels: number = 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 objectFront = bindings[name].value; + if (objectFront.actorID && objectFront.class === "Object") { + properties = await client.loadObjectProperties({ + name, + path: name, + contents: { value: objectFront }, + }); + } + + const previewsFromBindings: Array<Preview> = getBindingValues( + originalAstScopes, + pausedOnLine, + name, + bindings[name].value, + curLevel, + properties + ); + + allPreviews.push(...previewsFromBindings); + }); + await Promise.all(previewBindings); + + scopes = scopes.parent; + } + + const previews = {}; + const sortedPreviews = sortBy(allPreviews, ["line", "column"]); + + sortedPreviews.forEach(preview => { + const { line } = preview; + if (!previews[line]) { + previews[line] = [preview]; + } else { + previews[line].push(preview); + } + }); + + return dispatch({ + type: "ADD_INLINE_PREVIEW", + thread, + frame, + previews, + }); + }; +} + +function getBindingValues( + originalAstScopes: Object, + pausedOnLine: number, + name: string, + value: any, + curLevel: number, + properties: Array<Object> | null +): Array<Preview> { + 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: number = 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: string, + value: any, + // TODO: Add data type to ref + ref: Object, + properties: Array<Object> | null +) { + 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: Object = 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..f4b916afda --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/mapDisplayNames.js @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { getFrames, getSymbols, getSource } from "../../selectors"; + +import { findClosestFunction } from "../../utils/ast"; + +import type { Frame, ThreadContext } from "../../types"; +import type { ThunkArgs } from "../types"; + +function mapDisplayName(frame: Frame, { getState }) { + if (frame.isOriginal) { + return frame; + } + + const source = getSource(getState(), frame.location.sourceId); + + if (!source) { + return frame; + } + + const symbols = getSymbols(getState(), source); + + 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: ThreadContext) { + return function({ dispatch, getState }: ThunkArgs) { + 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..8de3e09f3d --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/mapFrames.js @@ -0,0 +1,177 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { getFrames, getSource, getSelectedFrame } from "../../selectors"; + +import assert from "../../utils/assert"; + +import type { + Frame, + FrameId, + OriginalFrame, + ThreadContext, + ThreadId, +} from "../../types"; +import type { State } from "../../reducers/types"; +import type { ThunkArgs } from "../types"; + +import SourceMaps, { isGeneratedId } from "devtools-source-map"; + +function isFrameBlackboxed(state: State, frame: Frame): boolean { + const source = getSource(state, frame.location.sourceId); + return !!source?.isBlackBoxed; +} + +function getSelectedFrameId( + state: State, + thread: ThreadId, + frames: Frame[] +): ?FrameId { + let selectedFrame = getSelectedFrame(state, thread); + if (selectedFrame && !isFrameBlackboxed(state, selectedFrame)) { + return selectedFrame.id; + } + + selectedFrame = frames.find(frame => !isFrameBlackboxed(state, frame)); + return selectedFrame?.id; +} + +export function updateFrameLocation( + frame: Frame, + sourceMaps: typeof SourceMaps +): Promise<Frame> { + if (frame.isOriginal) { + return Promise.resolve(frame); + } + return sourceMaps.getOriginalLocation(frame.location).then(loc => ({ + ...frame, + location: loc, + generatedLocation: frame.generatedLocation || frame.location, + })); +} + +function updateFrameLocations( + frames: Frame[], + sourceMaps: typeof SourceMaps +): Promise<Frame[]> { + if (!frames || frames.length == 0) { + return Promise.resolve(frames); + } + + return Promise.all( + frames.map(frame => updateFrameLocation(frame, sourceMaps)) + ); +} + +function isWasmOriginalSourceFrame( + frame: Frame, + getState: () => State +): boolean { + if (isGeneratedId(frame.location.sourceId)) { + return false; + } + const generatedSource = getSource( + getState(), + frame.generatedLocation.sourceId + ); + + return Boolean(generatedSource?.isWasm); +} + +async function expandFrames( + frames: Frame[], + sourceMaps: typeof SourceMaps, + getState: () => State +): Promise<Frame[]> { + 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: ?Array<OriginalFrame> = await sourceMaps.getOriginalStackFrames( + frame.generatedLocation + ); + if (!originalFrames) { + result.push(frame); + continue; + } + + assert(originalFrames.length > 0, "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: 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: ThreadContext) { + return async function(thunkArgs: ThunkArgs) { + const { dispatch, getState, sourceMaps } = thunkArgs; + const frames = getFrames(getState(), cx.thread); + if (!frames) { + return; + } + + let mappedFrames = await updateFrameLocations(frames, sourceMaps); + + mappedFrames = await expandFrames(mappedFrames, sourceMaps, getState); + + 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..9e5e249e2d --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/mapScopes.js @@ -0,0 +1,216 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + getSelectedFrameId, + getSource, + getSourceContent, + isMapScopesEnabled, + getSelectedFrame, + getSelectedGeneratedScope, + getSelectedOriginalScope, + getThreadContext, +} from "../../selectors"; +import { loadSourceText } from "../sources/loadSourceText"; +import { PROMISE } from "../utils/middleware/promise"; +import assert from "../../utils/assert"; + +import { log } from "../../utils/log"; +import { isGenerated, isOriginal } from "../../utils/source"; +import type { + Frame, + Scope, + ThreadContext, + XScopeVariables, + SourceLocation, +} from "../../types"; + +import type { ThunkArgs } from "../types"; + +import { + buildMappedScopes, + type MappedFrameLocation, +} from "../../utils/pause/mapScopes"; +import { isFulfilled } from "../../utils/async-value"; + +import type { OriginalScope } from "../../utils/pause/mapScopes"; +import { getMappedLocation } from "../../utils/source-maps"; + +const expressionRegex = /\bfp\(\)/g; + +export async function buildOriginalScopes( + frame: Frame, + client: any, + cx: ThreadContext, + frameId: any, + generatedScopes: Promise<Scope> +): Promise<?{ + mappings: { + [string]: string, + }, + scope: OriginalScope, +}> { + if (!frame.originalVariables) { + throw new TypeError("(frame.originalVariables: XScopeVariables)"); + } + const originalVariables: XScopeVariables = 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, + thread: cx.thread, + }); + + 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, client, sourceMaps }: ThunkArgs) { + if (isMapScopesEnabled(getState())) { + return dispatch({ type: "TOGGLE_MAP_SCOPES", mapScopes: false }); + } + + 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: ThreadContext, + scopes: Promise<Scope>, + frame: Frame +) { + return async function(thunkArgs: 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: ThreadContext, + scopes: ?Promise<Scope>, + frame: MappedFrameLocation +) { + return async function(thunkArgs: ThunkArgs) { + const { getState, dispatch } = thunkArgs; + const generatedSource = getSource( + getState(), + frame.generatedLocation.sourceId + ); + + const source = getSource(getState(), frame.location.sourceId); + + if ( + !isMapScopesEnabled(getState()) || + !source || + !generatedSource || + generatedSource.isWasm || + source.isPrettyPrinted || + isGenerated(source) + ) { + return null; + } + + await dispatch(loadSourceText({ cx, source })); + if (isOriginal(source)) { + await dispatch(loadSourceText({ cx, source: generatedSource })); + } + + try { + const content = + getSource(getState(), source.id) && + getSourceContent(getState(), source.id); + + 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: SourceLocation) { + return async function(thunkArgs: ThunkArgs) { + const { dispatch, getState, sourceMaps } = thunkArgs; + const cx = getThreadContext(getState()); + const mappedLocation: $Shape<MappedFrameLocation> = await getMappedLocation( + getState(), + sourceMaps, + location + ); + 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..ab509b9d16 --- /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", + "index.js", + "inlinePreview.js", + "mapDisplayNames.js", + "mapFrames.js", + "mapScopes.js", + "paused.js", + "pauseOnExceptions.js", + "previewPausedLocation.js", + "resumed.js", + "selectFrame.js", + "highlightCalls.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..185c4f2437 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/pauseOnExceptions.js @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { PROMISE } from "../utils/middleware/promise"; +import { recordEvent } from "../../utils/telemetry"; +import type { ThunkArgs } from "../types"; + +/** + * + * @memberof actions/pause + * @static + */ +export function pauseOnExceptions( + shouldPauseOnExceptions: boolean, + shouldPauseOnCaughtExceptions: boolean +) { + return ({ dispatch, getState, client }: ThunkArgs) => { + 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..84cc54d2c3 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/paused.js @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +import { + getHiddenBreakpoint, + isEvaluatingExpression, + getSelectedFrame, + getThreadContext, +} from "../../selectors"; + +import { mapFrames, fetchFrames } from "."; +import { removeBreakpoint } from "../breakpoints"; +import { evaluateExpressions } from "../expressions"; +import { selectSpecificLocation } from "../sources"; +import assert from "../../utils/assert"; + +import { fetchScopes } from "./fetchScopes"; + +import type { Pause } from "../../types"; +import type { ThunkArgs } from "../types"; + +/** + * Debugger has just paused + * + * @param {object} pauseInfo + * @memberof actions/pause + * @static + */ +export function paused(pauseInfo: Pause) { + return async function({ dispatch, getState, client, sourceMaps }: ThunkArgs) { + 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()); + assert(cx.thread == thread, "Thread mismatch"); + + await dispatch(fetchFrames(cx)); + + const hiddenBreakpoint = getHiddenBreakpoint(getState()); + if (hiddenBreakpoint) { + dispatch(removeBreakpoint(cx, hiddenBreakpoint)); + } + + await dispatch(mapFrames(cx)); + + const selectedFrame = getSelectedFrame(getState(), thread); + if (selectedFrame) { + await dispatch(selectSpecificLocation(cx, selectedFrame.location)); + } + + 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/previewPausedLocation.js b/devtools/client/debugger/src/actions/pause/previewPausedLocation.js new file mode 100644 index 0000000000..7be462abfb --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/previewPausedLocation.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/>. */ + +// @flow + +import { selectLocation } from "../sources"; +import { getContext, getSourceByURL } from "../../selectors"; +import type { ThunkArgs } from "../types"; +import type { URL } from "../../types"; + +type Location = { + sourceUrl: URL, + column: number, + line: number, +}; + +export function previewPausedLocation(location: Location) { + return ({ dispatch, getState }: ThunkArgs) => { + const cx = getContext(getState()); + const source = getSourceByURL(getState(), location.sourceUrl); + if (!source) { + return; + } + + const sourceLocation = { + line: location.line, + column: location.column, + sourceId: source.id, + }; + dispatch(selectLocation(cx, sourceLocation)); + + dispatch({ + type: "PREVIEW_PAUSED_LOCATION", + location: sourceLocation, + }); + }; +} + +export function clearPreviewPausedLocation() { + return { + type: "CLEAR_PREVIEW_PAUSED_LOCATION", + }; +} 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..12b2492f5b --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/resumed.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/>. */ + +// @flow + +import { isStepping, getPauseReason, getThreadContext } from "../../selectors"; +import { evaluateExpressions } from "../expressions"; +import { inDebuggerEval } from "../../utils/pause"; + +import type { ThunkArgs } from "../types"; +import type { ActorId } from "../../types"; + +/** + * Debugger has just resumed + * + * @memberof actions/pause + * @static + */ +export function resumed(thread: ActorId) { + return async ({ dispatch, client, getState }: ThunkArgs) => { + 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..4344a1c00b --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/selectFrame.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/>. */ + +// @flow + +import { selectLocation } from "../sources"; +import { evaluateExpressions } from "../expressions"; +import { fetchScopes } from "./fetchScopes"; +import assert from "../../utils/assert"; + +import type { Frame, ThreadContext } from "../../types"; +import type { ThunkArgs } from "../types"; + +/** + * @memberof actions/pause + * @static + */ +export function selectFrame(cx: ThreadContext, frame: Frame) { + return async ({ dispatch, client, getState, sourceMaps }: ThunkArgs) => { + 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") { + return dispatch(selectLocation(cx, frame.location)); + } + + 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..e5d3a9ef40 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/skipPausing.js @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { ThunkArgs } from "../types"; +import { getSkipPausing } from "../../selectors"; + +/** + * @memberof actions/pause + * @static + */ +export function toggleSkipPausing() { + return async ({ dispatch, client, getState, sourceMaps }: ThunkArgs) => { + const skipPausing = !getSkipPausing(getState()); + await client.setSkipPausing(skipPausing); + dispatch({ type: "TOGGLE_SKIP_PAUSING", skipPausing }); + }; +} + +/** + * @memberof actions/pause + * @static + */ +export function setSkipPausing(skipPausing: boolean) { + return async ({ dispatch, client, getState, sourceMaps }: ThunkArgs) => { + 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..3873810de3 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/tests/pause.spec.js @@ -0,0 +1,383 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + actions, + selectors, + createStore, + waitForState, + makeSource, + makeOriginalSource, + makeFrame, +} from "../../../utils/test-head"; + +import { makeWhyNormal } from "../../../utils/test-mockup"; + +const { isStepping } = selectors; + +let stepInResolve = null; +const mockCommandClient = { + stepIn: () => + new Promise(_resolve => { + stepInResolve = _resolve; + }), + stepOver: () => new Promise(_resolve => _resolve), + evaluate: async () => {}, + evaluateInFrame: 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", + }); + } + }); + }, + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], + actorID: "threadActorID", +}; + +const mockFrameId = "1"; + +function createPauseInfo( + frameLocation = { sourceId: "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 generatedLocation = { + sourceId: "foo", + line: 1, + column: 0, + }; + + const client = { ...mockCommandClient }; + const store = createStore(client, {}); + const { dispatch, getState } = store; + const mockPauseInfo = createPauseInfo(generatedLocation, { + scope: { + bindings: { + variables: { b: { value: {} } }, + arguments: [{ a: { value: {} } }], + }, + }, + }); + + const { frames } = mockPauseInfo; + client.getFrames = async () => frames; + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + await dispatch(actions.newOriginalSource(makeOriginalSource(source))); + + await dispatch(actions.paused(mockPauseInfo)); + expect(selectors.getFrames(getState(), "FakeThread")).toEqual([ + { + generatedLocation: { column: 0, line: 1, sourceId: "foo" }, + id: mockFrameId, + location: { column: 0, line: 1, sourceId: "foo" }, + 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 generatedLocation = { + sourceId: "foo", + line: 1, + column: 0, + }; + + const originalLocation = { + sourceId: "foo-original", + line: 3, + column: 0, + }; + + const sourceMapsMock = { + 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, {}, sourceMapsMock); + const { dispatch, getState } = store; + const mockPauseInfo = createPauseInfo(generatedLocation); + + const { frames } = mockPauseInfo; + client.getFrames = async () => frames; + + await dispatch(actions.newGeneratedSource(makeSource("foo"))); + await dispatch(actions.newGeneratedSource(makeSource("foo-original"))); + + await dispatch(actions.paused(mockPauseInfo)); + expect(selectors.getFrames(getState(), "FakeThread")).toEqual([ + { + generatedLocation: { column: 0, line: 1, sourceId: "foo" }, + id: mockFrameId, + location: { column: 0, line: 3, sourceId: "foo-original" }, + originalDisplayName: "fooOriginal", + scope: { bindings: { arguments: [], variables: {} } }, + thread: "FakeThread", + }, + ]); + }); + + it("maps frame to original frames", async () => { + const generatedLocation = { + sourceId: "foo-wasm", + line: 1, + column: 0, + }; + + const originalLocation = { + sourceId: "foo-wasm/originalSource", + line: 1, + column: 1, + }; + const originalLocation2 = { + sourceId: "foo-wasm/originalSource", + line: 2, + column: 14, + }; + + const originStackFrames = [ + { + displayName: "fooBar", + thread: "FakeThread", + }, + { + displayName: "barZoo", + location: originalLocation2, + thread: "FakeThread", + }, + ]; + + const sourceMapsMock = { + 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, {}, sourceMapsMock); + const { dispatch, getState } = store; + const mockPauseInfo = createPauseInfo(generatedLocation); + const { frames } = mockPauseInfo; + client.getFrames = async () => frames; + + const source = await dispatch( + actions.newGeneratedSource( + makeSource("foo-wasm", { introductionType: "wasm" }) + ) + ); + await dispatch(actions.newOriginalSource(makeOriginalSource(source))); + + await dispatch(actions.paused(mockPauseInfo)); + expect(selectors.getFrames(getState(), "FakeThread")).toEqual([ + { + asyncCause: undefined, + displayName: "fooBar", + generatedLocation: { column: 0, line: 1, sourceId: "foo-wasm" }, + id: "1", + index: undefined, + isOriginal: true, + location: { column: 1, line: 1, sourceId: "foo-wasm/originalSource" }, + originalDisplayName: "fooBar", + originalVariables: undefined, + scope: { bindings: { arguments: [], variables: {} } }, + source: null, + state: undefined, + this: undefined, + thread: "FakeThread", + }, + { + asyncCause: undefined, + displayName: "barZoo", + generatedLocation: { column: 0, line: 1, sourceId: "foo-wasm" }, + id: "1-originalFrame1", + index: undefined, + isOriginal: true, + location: { + column: 14, + line: 2, + sourceId: "foo-wasm/originalSource", + }, + 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..d6c265c56c --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/tests/pauseOnExceptions.spec.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/>. */ + +// @flow + +import { + actions, + createStore, + getTelemetryEvents, +} from "../../../utils/test-head"; + +import { + getShouldPauseOnExceptions, + getShouldPauseOnCaughtExceptions, +} from "../../../reducers/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..7f915d478d --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/tests/skipPausing.spec.js @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { 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..b07ae69d1f --- /dev/null +++ b/devtools/client/debugger/src/actions/preview.js @@ -0,0 +1,231 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { isConsole } from "../utils/preview"; +import { findBestMatchExpression } from "../utils/ast"; +import { getGrip, getFront } from "../utils/evaluation-result"; +import { getExpressionFromCoords } from "../utils/editor/get-expression"; +import { isOriginal } from "../utils/source"; +import { isNodeTest } from "../utils/environment"; + +import { + getPreview, + isLineInScope, + isSelectedFrameVisible, + getSelectedSource, + getSelectedFrame, + getSymbols, + getCurrentThread, + getPreviewCount, + getSelectedException, +} from "../selectors"; + +import { getMappedExpression } from "./expressions"; + +import type { Action, ThunkArgs } from "./types"; +import type { Position, Context } from "../types"; +import type { AstLocation } from "../workers/parser"; + +function findExpressionMatch(state, codeMirror: any, tokenPos: Object) { + const source = getSelectedSource(state); + if (!source) { + return; + } + + const symbols = getSymbols(state, source); + + let match; + if (!symbols || symbols.loading) { + match = getExpressionFromCoords(codeMirror, tokenPos); + } else { + match = findBestMatchExpression(symbols, tokenPos); + } + return match; +} + +export function updatePreview( + cx: Context, + target: HTMLElement, + tokenPos: Object, + codeMirror: any +) { + return ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => { + 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: Context, + expression: string, + location: AstLocation, + tokenPos: Position, + cursorPos: ClientRect, + target: HTMLElement +) { + return async ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => { + 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 && isOriginal(source)) { + const mapResult = await dispatch(getMappedExpression(expression)); + if (mapResult) { + expression = mapResult.expression; + } + } + + if (!selectedFrame) { + return; + } + + const { result } = await client.evaluateInFrame(expression, { + frameId: selectedFrame.id, + thread, + }); + + 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 = { + name: expression, + path: expression, + contents: { + value: resultGrip, + front: getFront(result), + }, + }; + const properties = await client.loadObjectProperties(root); + + // 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: Context) { + return ({ dispatch, getState, client }: ThunkArgs) => { + const currentSelection = getPreview(getState()); + if (!currentSelection) { + return; + } + + return dispatch( + ({ + type: "CLEAR_PREVIEW", + cx, + }: Action) + ); + }; +} + +export function setExceptionPreview( + cx: Context, + target: HTMLElement, + tokenPos: Object, + codeMirror: any +) { + return async ({ dispatch, getState }: ThunkArgs) => { + 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..327dbccda5 --- /dev/null +++ b/devtools/client/debugger/src/actions/project-text-search.js @@ -0,0 +1,136 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * Redux actions for the search state + * @module actions/search + */ + +import { isFulfilled } from "../utils/async-value"; +import { findSourceMatches } from "../workers/search"; +import { + getSource, + hasPrettySource, + getSourceList, + getSourceContent, +} from "../selectors"; +import { isThirdParty } from "../utils/source"; +import { loadSourceText } from "./sources/loadSourceText"; +import { + statusType, + getTextSearchOperation, + getTextSearchStatus, +} from "../reducers/project-text-search"; + +import type { Action, ThunkArgs } from "./types"; +import type { Context, SourceId } from "../types"; +import type { + SearchOperation, + StatusType, +} from "../reducers/project-text-search"; + +export function addSearchQuery(cx: Context, query: string): Action { + return { type: "ADD_QUERY", cx, query }; +} + +export function addOngoingSearch( + cx: Context, + ongoingSearch: SearchOperation +): Action { + return { type: "ADD_ONGOING_SEARCH", cx, ongoingSearch }; +} + +export function addSearchResult( + cx: Context, + sourceId: SourceId, + filepath: string, + matches: Object[] +): Action { + return { + type: "ADD_SEARCH_RESULT", + cx, + result: { sourceId, filepath, matches }, + }; +} + +export function clearSearchResults(cx: Context): Action { + return { type: "CLEAR_SEARCH_RESULTS", cx }; +} + +export function clearSearch(cx: Context): Action { + return { type: "CLEAR_SEARCH", cx }; +} + +export function updateSearchStatus(cx: Context, status: StatusType): Action { + return { type: "UPDATE_STATUS", cx, status }; +} + +export function closeProjectSearch(cx: Context) { + return ({ dispatch, getState }: ThunkArgs) => { + dispatch(stopOngoingSearch(cx)); + dispatch({ type: "CLOSE_PROJECT_SEARCH" }); + }; +} + +export function stopOngoingSearch(cx: Context) { + return ({ dispatch, getState }: ThunkArgs) => { + const state = getState(); + const ongoingSearch = getTextSearchOperation(state); + const status = getTextSearchStatus(state); + if (ongoingSearch && status !== statusType.done) { + ongoingSearch.cancel(); + dispatch(updateSearchStatus(cx, statusType.cancelled)); + } + }; +} + +export function searchSources(cx: Context, query: string) { + let cancelled = false; + + const search = async ({ dispatch, getState }: ThunkArgs) => { + dispatch(stopOngoingSearch(cx)); + await dispatch(addOngoingSearch(cx, search)); + await dispatch(clearSearchResults(cx)); + await dispatch(addSearchQuery(cx, query)); + dispatch(updateSearchStatus(cx, statusType.fetching)); + const validSources = getSourceList(getState()).filter( + source => !hasPrettySource(getState(), source.id) && !isThirdParty(source) + ); + for (const source of validSources) { + if (cancelled) { + return; + } + await dispatch(loadSourceText({ cx, source })); + await dispatch(searchSource(cx, source.id, query)); + } + dispatch(updateSearchStatus(cx, statusType.done)); + }; + + search.cancel = () => { + cancelled = true; + }; + + return search; +} + +export function searchSource(cx: Context, sourceId: SourceId, query: string) { + return async ({ dispatch, getState }: ThunkArgs) => { + const source = getSource(getState(), sourceId); + if (!source) { + return; + } + + const content = getSourceContent(getState(), source.id); + let matches = []; + if (content && isFulfilled(content) && content.value.type === "text") { + matches = await findSourceMatches(source.id, content.value, query); + } + if (!matches.length) { + return; + } + dispatch(addSearchResult(cx, source.id, source.url, 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..26f1cc91cf --- /dev/null +++ b/devtools/client/debugger/src/actions/quick-open.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { QuickOpenAction } from "./types"; + +export function setQuickOpenQuery(query: string): QuickOpenAction { + return { + type: "SET_QUICK_OPEN_QUERY", + query, + }; +} + +export function openQuickOpen(query?: string): QuickOpenAction { + if (query != null) { + return { type: "OPEN_QUICK_OPEN", query }; + } + return { type: "OPEN_QUICK_OPEN" }; +} + +export function closeQuickOpen(): QuickOpenAction { + 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..2def429a21 --- /dev/null +++ b/devtools/client/debugger/src/actions/source-actors.js @@ -0,0 +1,84 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + getSourceActor, + getSourceActorBreakableLines, + getSourceActorBreakpointColumns, + type SourceActorId, + type SourceActor, +} from "../reducers/source-actors"; +import { + memoizeableAction, + type MemoizedAction, +} from "../utils/memoizableAction"; +import { PROMISE } from "./utils/middleware/promise"; + +import type { ThunkArgs } from "./types"; +import type { Context } from "../utils/context"; + +export function insertSourceActors(items: Array<SourceActor>) { + return function({ dispatch }: ThunkArgs) { + dispatch({ + type: "INSERT_SOURCE_ACTORS", + items, + }); + }; +} + +export function removeSourceActor(item: SourceActor) { + return removeSourceActors([item]); +} +export function removeSourceActors(items: Array<SourceActor>) { + return function({ dispatch }: ThunkArgs) { + dispatch({ type: "REMOVE_SOURCE_ACTORS", items }); + }; +} + +export const loadSourceActorBreakpointColumns: MemoizedAction< + {| id: SourceActorId, line: number, cx: Context |}, + Array<number> +> = memoizeableAction("loadSourceActorBreakpointColumns", { + createKey: ({ id, line }) => `${id}:${line}`, + getValue: ({ id, line }, { getState }) => + getSourceActorBreakpointColumns(getState(), id, line), + action: async ({ id, line }, { dispatch, getState, client }) => { + await dispatch({ + type: "SET_SOURCE_ACTOR_BREAKPOINT_COLUMNS", + sourceId: id, + line, + [PROMISE]: (async () => { + const positions = await client.getSourceActorBreakpointPositions( + getSourceActor(getState(), id), + { + start: { line, column: 0 }, + end: { line: line + 1, column: 0 }, + } + ); + + return positions[line] || []; + })(), + }); + }, +}); + +export const loadSourceActorBreakableLines: MemoizedAction< + {| id: SourceActorId, cx: Context |}, + Array<number> +> = memoizeableAction("loadSourceActorBreakableLines", { + createKey: args => args.id, + getValue: ({ id }, { getState }) => + getSourceActorBreakableLines(getState(), id), + action: async ({ id }, { dispatch, getState, client }) => { + await dispatch({ + type: "SET_SOURCE_ACTOR_BREAKABLE_LINES", + sourceId: id, + [PROMISE]: client.getSourceActorBreakableLines( + getSourceActor(getState(), id) + ), + }); + }, +}); diff --git a/devtools/client/debugger/src/actions/source-tree.js b/devtools/client/debugger/src/actions/source-tree.js new file mode 100644 index 0000000000..b2cfa0c44b --- /dev/null +++ b/devtools/client/debugger/src/actions/source-tree.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/>. */ +// @flow + +import type { TreeNode } from "../utils/sources-tree/types"; + +export function setExpandedState(expanded: Set<string>) { + return { type: "SET_EXPANDED_STATE", expanded }; +} + +export function focusItem(item: TreeNode) { + 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..3561b5f77f --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/blackbox.js @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * Redux actions for the sources state + * @module actions/sources + */ + +import SourceMaps, { + isOriginalId, + originalToGeneratedId, +} from "devtools-source-map"; +import { recordEvent } from "../../utils/telemetry"; +import { features } from "../../utils/prefs"; +import { getSourceActorsForSource } from "../../selectors"; + +import { PROMISE } from "../utils/middleware/promise"; + +import type { Source, Context, SourceId } from "../../types"; +import type { ThunkArgs } from "../types"; +import type { State } from "../../reducers/types"; + +async function blackboxActors( + state: State, + client, + sourceId: SourceId, + isBlackBoxed: boolean, + range? +): Promise<{ isBlackBoxed: boolean }> { + for (const actor of getSourceActorsForSource(state, sourceId)) { + await client.blackBox(actor, isBlackBoxed, range); + } + return { isBlackBoxed: !isBlackBoxed }; +} + +async function getSourceId(source: Source, sourceMaps: typeof SourceMaps) { + let sourceId = source.id, + range; + if (features.originalBlackbox && isOriginalId(source.id)) { + range = await sourceMaps.getFileGeneratedRange(source.id); + sourceId = originalToGeneratedId(source.id); + } + return { sourceId, range }; +} + +export function toggleBlackBox(cx: Context, source: Source) { + return async ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => { + const { isBlackBoxed } = source; + + if (!isBlackBoxed) { + recordEvent("blackbox"); + } + + const { sourceId, range } = await getSourceId(source, sourceMaps); + + return dispatch({ + type: "BLACKBOX", + cx, + source, + [PROMISE]: blackboxActors( + getState(), + client, + sourceId, + isBlackBoxed, + range + ), + }); + }; +} + +export function blackBoxSources( + cx: Context, + sourcesToBlackBox: Source[], + shouldBlackBox: boolean +) { + return async ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => { + const state = getState(); + const sources = sourcesToBlackBox.filter( + source => source.isBlackBoxed !== shouldBlackBox + ); + + if (shouldBlackBox) { + recordEvent("blackbox"); + } + + const promises = [ + ...sources.map(async source => { + const { sourceId, range } = await getSourceId(source, sourceMaps); + + return getSourceActorsForSource(state, sourceId).map(actor => + client.blackBox(actor, source.isBlackBoxed, range) + ); + }), + ]; + + return dispatch({ + type: "BLACKBOX_SOURCES", + cx, + shouldBlackBox, + [PROMISE]: Promise.all(promises).then(() => ({ 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..f7931ad622 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/breakableLines.js @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { isOriginalId } from "devtools-source-map"; +import { getSourceActorsForSource, getBreakableLines } from "../../selectors"; +import { setBreakpointPositions } from "../breakpoints/breakpointPositions"; +import { union } from "lodash"; +import type { Context, SourceId } from "../../types"; +import type { ThunkArgs } from "../../actions/types"; +import { loadSourceActorBreakableLines } from "../source-actors"; + +function calculateBreakableLines(positions): number[] { + const lines = []; + for (const line in positions) { + if (positions[line].length > 0) { + lines.push(Number(line)); + } + } + + return lines; +} + +export function setBreakableLines(cx: Context, sourceId: SourceId) { + return async ({ getState, dispatch, client }: ThunkArgs) => { + let breakableLines; + if (isOriginalId(sourceId)) { + const positions = await dispatch( + setBreakpointPositions({ cx, sourceId }) + ); + breakableLines = calculateBreakableLines(positions); + + const existingBreakableLines = getBreakableLines(getState(), sourceId); + if (existingBreakableLines) { + breakableLines = union(existingBreakableLines, breakableLines); + } + + dispatch({ + type: "SET_ORIGINAL_BREAKABLE_LINES", + cx, + sourceId, + breakableLines, + }); + } else { + const actors = getSourceActorsForSource(getState(), sourceId); + + await Promise.all( + actors.map(({ id }) => + dispatch(loadSourceActorBreakableLines({ id, cx })) + ) + ); + } + }; +} 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..48767a107c --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/index.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/>. */ + +// @flow +export * from "./blackbox"; +export * from "./breakableLines"; +export * from "./loadSourceText"; +export * from "./newSources"; +export * from "./prettyPrint"; +export * from "./select"; +export { setSymbols } from "./symbols"; 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..8f34cc3525 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/loadSourceText.js @@ -0,0 +1,176 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { PROMISE } from "../utils/middleware/promise"; +import { + getSource, + getSourceFromId, + getSourceWithContent, + getSourceContent, + getGeneratedSource, + getSourcesEpoch, + getBreakpointsForSource, + getSourceActorsForSource, +} from "../../selectors"; +import { addBreakpoint } from "../breakpoints"; + +import { prettyPrintSource } from "./prettyPrint"; +import { isFulfilled, fulfilled } from "../../utils/async-value"; + +import { isOriginal, isPretty } from "../../utils/source"; +import { + memoizeableAction, + type MemoizedAction, +} from "../../utils/memoizableAction"; + +// $FlowIgnore +const Telemetry = require("devtools/client/shared/telemetry"); + +import type { ThunkArgs } from "../types"; +import type { Source, Context, SourceId } from "../../types"; +import type { State } from "../../reducers/types"; + +// Measures the time it takes for a source to load +const loadSourceHistogram = "DEVTOOLS_DEBUGGER_LOAD_SOURCE_MS"; +const telemetry = new Telemetry(); + +async function loadSource( + state: State, + source: Source, + { sourceMaps, client, getState } +): Promise<?{ + text: string, + contentType: string, +}> { + if (isPretty(source) && isOriginal(source)) { + const generatedSource = getGeneratedSource(state, source); + if (!generatedSource) { + throw new Error("Unable to find minified original."); + } + const content = getSourceContent(state, generatedSource.id); + if (!content || !isFulfilled(content)) { + throw new Error("Cannot pretty-print a file that has not loaded"); + } + + return prettyPrintSource( + sourceMaps, + generatedSource, + content.value, + getSourceActorsForSource(state, generatedSource.id) + ); + } + + if (isOriginal(source)) { + const result = await sourceMaps.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; + } + + // We only need the source text from one actor, but messages sent to retrieve + // the source might fail if the actor has or is about to shut down. Keep + // trying with different actors until one request succeeds. + let response; + const handledActors = new Set(); + while (true) { + const actors = getSourceActorsForSource(state, source.id); + const actor = actors.find(({ actor: a }) => !handledActors.has(a)); + if (!actor) { + throw new Error("Unknown source"); + } + handledActors.add(actor.actor); + + try { + telemetry.start(loadSourceHistogram, source); + response = await client.sourceContents(actor); + telemetry.finish(loadSourceHistogram, source); + break; + } catch (e) { + console.warn(`sourceContents failed: ${e}`); + } + } + + return { + text: (response: any).source, + contentType: (response: any).contentType || "text/javascript", + }; +} + +async function loadSourceTextPromise( + cx: Context, + source: Source, + { dispatch, getState, client, sourceMaps, parser }: ThunkArgs +): Promise<?Source> { + const epoch = getSourcesEpoch(getState()); + await dispatch({ + type: "LOAD_SOURCE_TEXT", + sourceId: source.id, + epoch, + [PROMISE]: loadSource(getState(), source, { sourceMaps, client, getState }), + }); + + const newSource = getSource(getState(), source.id); + + if (!newSource) { + return; + } + const content = getSourceContent(getState(), newSource.id); + + if (!newSource.isWasm && content) { + parser.setSource( + newSource.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 { location, options, disabled } of breakpoints) { + await dispatch(addBreakpoint(cx, location, options, disabled)); + } + } +} + +export function loadSourceById(cx: Context, sourceId: SourceId) { + return ({ getState, dispatch }: ThunkArgs) => { + const source = getSourceFromId(getState(), sourceId); + return dispatch(loadSourceText({ cx, source })); + }; +} + +export const loadSourceText: MemoizedAction< + {| cx: Context, source: Source |}, + ?Source +> = memoizeableAction("loadSourceText", { + getValue: ({ source }, { getState }) => { + source = source ? getSource(getState(), source.id) : null; + if (!source) { + return null; + } + + const { content } = getSourceWithContent(getState(), source.id); + if (!content || content.state === "pending") { + return content; + } + + // 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(source); + }, + createKey: ({ source }, { getState }) => { + const epoch = getSourcesEpoch(getState()); + return `${epoch}:${source.id}`; + }, + action: ({ cx, source }, thunkArgs) => + loadSourceTextPromise(cx, source, thunkArgs), +}); 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..6cd0516e5a --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/newSources.js @@ -0,0 +1,400 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * Redux actions for the sources state + * @module actions/sources + */ + +import { flatten } from "lodash"; + +import { + stringToSourceActorId, + type SourceActor, +} from "../../reducers/source-actors"; +import { insertSourceActors } from "../../actions/source-actors"; +import { makeSourceId } from "../../client/firefox/create"; +import { toggleBlackBox } from "./blackbox"; +import { syncBreakpoint } from "../breakpoints"; +import { loadSourceText } from "./loadSourceText"; +import { togglePrettyPrint } from "./prettyPrint"; +import { selectLocation, setBreakableLines } from "../sources"; + +import { + getRawSourceURL, + isPrettyURL, + isUrlExtension, + isInlineScript, +} from "../../utils/source"; +import { + getBlackBoxList, + getSource, + getSourceFromId, + hasSourceActor, + getSourceByActorId, + getPendingSelectedLocation, + getPendingBreakpointsForSource, + getContext, + isSourceLoadingOrLoaded, +} from "../../selectors"; + +import { prefs, features } from "../../utils/prefs"; +import sourceQueue from "../../utils/source-queue"; +import { validateNavigateContext, ContextError } from "../../utils/context"; + +import type { + Source, + SourceId, + Context, + OriginalSourceData, + GeneratedSourceData, + QueuedSourceData, +} from "../../types"; +import type { Action, ThunkArgs } from "../types"; + +function loadSourceMaps(cx: Context, sources: SourceActor[]) { + return async function({ + dispatch, + sourceMaps, + }: ThunkArgs): Promise<?(Promise<Source>[])> { + try { + const sourceList = await Promise.all( + sources.map(async sourceActor => { + const originalSources = await dispatch( + loadSourceMap(cx, sourceActor) + ); + sourceQueue.queueSources( + originalSources.map(data => ({ + type: "original", + data, + })) + ); + return originalSources; + }) + ); + + await sourceQueue.flush(); + + return flatten(sourceList); + } catch (error) { + if (!(error instanceof ContextError)) { + throw error; + } + } + }; +} + +/** + * @memberof actions/sources + * @static + */ +function loadSourceMap(cx: Context, sourceActor: SourceActor) { + return async function({ + dispatch, + getState, + sourceMaps, + }: ThunkArgs): Promise<OriginalSourceData[]> { + 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 sourceMaps.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", + }); + } + } catch (e) { + console.error(e); + } + + if (!data) { + // If this source doesn't have a sourcemap, enable it for pretty printing + dispatch( + ({ + type: "CLEAR_SOURCE_ACTOR_MAP_URL", + cx, + id: sourceActor.id, + }: Action) + ); + return []; + } + + validateNavigateContext(getState(), cx); + return data; + }; +} + +// If a request has been made to show this source, go ahead and +// select it. +function checkSelectedSource(cx: Context, sourceId: SourceId) { + return async ({ dispatch, getState }: ThunkArgs) => { + 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)); + return dispatch(checkPendingBreakpoints(cx, prettySource.id)); + } + + await dispatch( + selectLocation(cx, { + sourceId: source.id, + line: + typeof pendingLocation.line === "number" ? pendingLocation.line : 0, + column: pendingLocation.column, + }) + ); + } + }; +} + +function checkPendingBreakpoints(cx: Context, sourceId: SourceId) { + return async ({ dispatch, getState }: ThunkArgs) => { + // source may have been modified by selectLocation + const source = getSource(getState(), sourceId); + if (!source) { + return; + } + + 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 })); + + await dispatch(setBreakableLines(cx, source.id)); + + await Promise.all( + pendingBreakpoints.map(bp => { + return dispatch(syncBreakpoint(cx, sourceId, bp)); + }) + ); + }; +} + +function restoreBlackBoxedSources(cx: Context, sources: Source[]) { + return async ({ dispatch, getState }: ThunkArgs) => { + const tabs = getBlackBoxList(getState()); + if (tabs.length == 0) { + return; + } + for (const source of sources) { + if (tabs.includes(source.url) && !source.isBlackBoxed) { + dispatch(toggleBlackBox(cx, source)); + } + } + }; +} + +export function newQueuedSources(sourceInfo: Array<QueuedSourceData>) { + return async ({ dispatch }: ThunkArgs) => { + const generated = []; + const original = []; + for (const source of sourceInfo) { + if (source.type === "generated") { + generated.push(source.data); + } else { + original.push(source.data); + } + } + + if (generated.length > 0) { + await dispatch(newGeneratedSources(generated)); + } + if (original.length > 0) { + await dispatch(newOriginalSources(original)); + } + }; +} + +export function newOriginalSource(sourceInfo: OriginalSourceData) { + return async ({ dispatch }: ThunkArgs) => { + const sources = await dispatch(newOriginalSources([sourceInfo])); + return sources[0]; + }; +} +export function newOriginalSources(sourceInfo: Array<OriginalSourceData>) { + return async ({ dispatch, getState }: ThunkArgs) => { + const state = getState(); + const seen: Set<string> = new Set(); + const sources: Array<Source> = []; + + for (const { id, url } of sourceInfo) { + if (seen.has(id) || getSource(state, id)) { + continue; + } + + seen.add(id); + + sources.push({ + id, + url, + relativeUrl: url, + isPrettyPrinted: false, + isWasm: false, + isBlackBoxed: false, + isExtension: false, + extensionName: null, + isOriginal: true, + }); + } + + const cx = getContext(state); + dispatch(addSources(cx, sources)); + + await dispatch(checkNewSources(cx, sources)); + + for (const source of sources) { + dispatch(checkPendingBreakpoints(cx, source.id)); + } + + return sources; + }; +} + +export function newGeneratedSource(sourceInfo: GeneratedSourceData) { + return async ({ dispatch }: ThunkArgs) => { + const sources = await dispatch(newGeneratedSources([sourceInfo])); + return sources[0]; + }; +} +export function newGeneratedSources(sourceInfo: Array<GeneratedSourceData>) { + return async ({ + dispatch, + getState, + client, + }: ThunkArgs): Promise<Array<Source>> => { + if (sourceInfo.length == 0) { + return []; + } + + const resultIds = []; + const newSourcesObj = {}; + const newSourceActors: Array<SourceActor> = []; + + for (const { thread, isServiceWorker, source, id } of sourceInfo) { + const newId = id || makeSourceId(source, isServiceWorker); + + if (!getSource(getState(), newId) && !newSourcesObj[newId]) { + newSourcesObj[newId] = { + id: newId, + url: source.url, + relativeUrl: source.url, + isPrettyPrinted: false, + extensionName: source.extensionName, + isBlackBoxed: false, + isWasm: !!features.wasm && source.introductionType === "wasm", + isExtension: (source.url && isUrlExtension(source.url)) || false, + isOriginal: false, + }; + } + + const actorId = stringToSourceActorId(source.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({ + id: actorId, + actor: source.actor, + thread, + source: newId, + isBlackBoxed: source.isBlackBoxed, + sourceMapBaseURL: source.sourceMapBaseURL, + sourceMapURL: source.sourceMapURL, + url: source.url, + introductionType: source.introductionType, + }); + } + + resultIds.push(newId); + } + + const newSources: Array<Source> = (Object.values(newSourcesObj): any[]); + + const cx = getContext(getState()); + dispatch(addSources(cx, newSources)); + dispatch(insertSourceActors(newSourceActors)); + + for (const newSourceActor of newSourceActors) { + // Fetch breakable lines for new HTML scripts + // when the HTML file has started loading + if ( + isInlineScript(newSourceActor) && + isSourceLoadingOrLoaded(getState(), newSourceActor.source) + ) { + dispatch(setBreakableLines(cx, newSourceActor.source)).catch(error => { + if (!(error instanceof ContextError)) { + throw error; + } + }); + } + } + 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 { source } of newSourceActors) { + dispatch(checkPendingBreakpoints(cx, source)); + } + })(); + + return resultIds.map(id => getSourceFromId(getState(), id)); + }; +} + +function addSources(cx: Context, sources: Array<Source>) { + return ({ dispatch, getState }: ThunkArgs) => { + dispatch({ type: "ADD_SOURCES", cx, sources }); + }; +} + +function checkNewSources(cx: Context, sources: Source[]) { + return async ({ dispatch, getState }: ThunkArgs) => { + for (const source of sources) { + dispatch(checkSelectedSource(cx, source.id)); + } + + 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..ef5036fb8c --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/prettyPrint.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/>. */ + +// @flow +import SourceMaps, { generatedToOriginalId } from "devtools-source-map"; + +import assert from "../../utils/assert"; +import { recordEvent } from "../../utils/telemetry"; +import { remapBreakpoints } from "../breakpoints"; + +import { setSymbols } from "./symbols"; +import { prettyPrint } from "../../workers/pretty-print"; +import { + getPrettySourceURL, + isGenerated, + isJavaScript, +} from "../../utils/source"; +import { loadSourceText } from "./loadSourceText"; +import { mapFrames } from "../pause"; +import { selectSpecificLocation } from "../sources"; + +import { + getSource, + getSourceFromId, + getSourceByURL, + getSelectedLocation, + getThreadContext, +} from "../../selectors"; + +import type { Action, ThunkArgs } from "../types"; +import { selectSource } from "./select"; +import type { + Source, + SourceId, + SourceContent, + SourceActor, + Context, + SourceLocation, +} from "../../types"; + +function getPrettyOriginalSourceURL(generatedSource: Source) { + return getPrettySourceURL(generatedSource.url || generatedSource.id); +} + +export async function prettyPrintSource( + sourceMaps: typeof SourceMaps, + generatedSource: Source, + content: SourceContent, + actors: Array<SourceActor> +) { + if (!isJavaScript(generatedSource, content) || content.type !== "text") { + throw new Error("Can't prettify non-javascript files."); + } + + const url = getPrettyOriginalSourceURL(generatedSource); + const { code, mappings } = await prettyPrint({ + text: content.value, + url, + }); + await sourceMaps.applySourceMap(generatedSource.id, url, code, mappings); + + // The source map URL service used by other devtools listens to changes to + // sources based on their actor IDs, so apply the mapping there too. + for (const { actor } of actors) { + await sourceMaps.applySourceMap(actor, url, code, mappings); + } + return { + text: code, + contentType: "text/javascript", + }; +} + +export function createPrettySource(cx: Context, sourceId: SourceId) { + return async ({ dispatch, getState, sourceMaps }: ThunkArgs) => { + const source = getSourceFromId(getState(), sourceId); + const url = getPrettyOriginalSourceURL(source); + const id = generatedToOriginalId(sourceId, url); + + const prettySource = { + id, + url, + relativeUrl: url, + isBlackBoxed: false, + isPrettyPrinted: true, + isWasm: false, + isExtension: false, + extensionName: null, + isOriginal: true, + }; + + dispatch(({ type: "ADD_SOURCE", cx, source: prettySource }: Action)); + + await dispatch(selectSource(cx, id)); + + return prettySource; + }; +} + +function selectPrettyLocation( + cx: Context, + prettySource: Source, + generatedLocation: ?SourceLocation +) { + return async ({ dispatch, sourceMaps, getState }: ThunkArgs) => { + let location = generatedLocation + ? generatedLocation + : getSelectedLocation(getState()); + + if (location && location.line >= 1) { + location = await sourceMaps.getOriginalLocation(location); + + return dispatch( + selectSpecificLocation(cx, { ...location, sourceId: prettySource.id }) + ); + } + + return dispatch(selectSource(cx, prettySource.id)); + }; +} + +/** + * Toggle the pretty printing of a source's text. All subsequent calls to + * |getText| will return the pretty-toggled text. Nothing will happen for + * non-javascript files. + * + * @memberof actions/sources + * @static + * @param string id The source form from the RDP. + * @returns Promise + * A promise that resolves to [aSource, prettyText] or rejects to + * [aSource, error]. + */ +export function togglePrettyPrint(cx: Context, sourceId: SourceId) { + return async ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => { + const source = getSource(getState(), sourceId); + if (!source) { + return {}; + } + + if (!source.isPrettyPrinted) { + recordEvent("pretty_print"); + } + + await dispatch(loadSourceText({ cx, source })); + assert( + isGenerated(source), + "Pretty-printing only allowed on generated sources" + ); + + const url = getPrettySourceURL(source.url); + const prettySource = getSourceByURL(getState(), url); + + if (prettySource) { + return dispatch(selectPrettyLocation(cx, prettySource)); + } + + const selectedLocation = getSelectedLocation(getState()); + const newPrettySource = await dispatch(createPrettySource(cx, sourceId)); + dispatch(selectPrettyLocation(cx, newPrettySource, selectedLocation)); + + const threadcx = getThreadContext(getState()); + await dispatch(mapFrames(threadcx)); + + await dispatch(setSymbols({ cx, source: newPrettySource })); + + await dispatch(remapBreakpoints(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..802c8cf021 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/select.js @@ -0,0 +1,240 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * Redux actions for the sources state + * @module actions/sources + */ + +import { isOriginalId } from "devtools-source-map"; + +import { getSourceFromId, getSourceWithContent } from "../../reducers/sources"; +import { tabExists } from "../../reducers/tabs"; +import { setSymbols } from "./symbols"; +import { setInScopeLines } from "../ast"; +import { closeActiveSearch, updateActiveFileSearch } from "../ui"; +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 { mapLocation } from "../../utils/source-maps"; + +import { + getSource, + getSourceByURL, + getPrettySource, + getActiveSearch, + getSelectedLocation, + getSelectedSource, + canPrettyPrintSource, +} from "../../selectors"; + +import type { + SourceLocation, + PartialPosition, + SourceId, + Source, + Context, + URL, +} from "../../types"; +import type { ThunkArgs } from "../types"; +import type { SourceAction } from "../types/SourceAction"; + +export const setSelectedLocation = ( + cx: Context, + source: Source, + location: SourceLocation +): SourceAction => ({ + type: "SET_SELECTED_LOCATION", + cx, + source, + location, +}); + +export const setPendingSelectedLocation = ( + cx: Context, + url: URL, + options?: PartialPosition +): SourceAction => ({ + type: "SET_PENDING_SELECTED_LOCATION", + cx, + url, + line: options?.line, + column: options?.column, +}); + +export const clearSelectedLocation = (cx: Context): SourceAction => ({ + 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. + * + * @memberof actions/sources + * @static + */ +export function selectSourceURL( + cx: Context, + url: URL, + options?: PartialPosition +) { + return async ({ dispatch, getState, sourceMaps }: ThunkArgs) => { + const source = getSourceByURL(getState(), url); + if (!source) { + return dispatch(setPendingSelectedLocation(cx, url, options)); + } + + const sourceId = source.id; + const location = createLocation({ ...options, sourceId }); + return dispatch(selectLocation(cx, location)); + }; +} + +/** + * @memberof actions/sources + * @static + */ +export function selectSource( + cx: Context, + sourceId: SourceId, + options: PartialPosition = {} +) { + return async ({ dispatch }: ThunkArgs) => { + const location = createLocation({ ...options, sourceId }); + return dispatch(selectSpecificLocation(cx, location)); + }; +} + +/** + * @memberof actions/sources + * @static + */ +export function selectLocation( + cx: Context, + location: SourceLocation, + { keepContext = true }: Object = {} +) { + return async ({ dispatch, getState, sourceMaps, client }: ThunkArgs) => { + const currentSource = getSelectedSource(getState()); + + 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 = getSource(getState(), location.sourceId); + if (!source) { + // If there is no source we deselect the current selected source + return dispatch(clearSelectedLocation(cx)); + } + + const activeSearch = getActiveSearch(getState()); + if (activeSearch && activeSearch !== "file") { + dispatch(closeActiveSearch()); + } + + // Preserve the current source map context (original / generated) + // when navigting to a new location. + const selectedSource = getSelectedSource(getState()); + if ( + keepContext && + selectedSource && + selectedSource.isOriginal != isOriginalId(location.sourceId) + ) { + location = await mapLocation(getState(), sourceMaps, location); + source = getSourceFromId(getState(), location.sourceId); + } + + if (!tabExists(getState(), source.id)) { + dispatch(addTab(source)); + } + + dispatch(setSelectedLocation(cx, source, location)); + + await dispatch(loadSourceText({ cx, source })); + await dispatch(setBreakableLines(cx, source.id)); + + const loadedSource = getSource(getState(), source.id); + + if (!loadedSource) { + // If there was a navigation while we were loading the loadedSource + return; + } + + const sourceWithContent = getSourceWithContent(getState(), source.id); + + if ( + keepContext && + prefs.autoPrettyPrint && + !getPrettySource(getState(), loadedSource.id) && + canPrettyPrintSource(getState(), loadedSource.id) && + isMinified(sourceWithContent) + ) { + await dispatch(togglePrettyPrint(cx, loadedSource.id)); + dispatch(closeTab(cx, loadedSource)); + } + + await dispatch(setSymbols({ cx, source: loadedSource })); + dispatch(setInScopeLines(cx)); + + if (cx.isPaused) { + await dispatch(mapDisplayNames(cx)); + } + + // If a new source is selected update the file search results + const newSource = getSelectedSource(getState()); + if (currentSource && currentSource !== newSource) { + dispatch(updateActiveFileSearch(cx)); + } + }; +} + +/** + * @memberof actions/sources + * @static + */ +export function selectSpecificLocation(cx: Context, location: SourceLocation) { + return selectLocation(cx, location, { keepContext: false }); +} + +/** + * @memberof actions/sources + * @static + */ +export function jumpToMappedLocation(cx: Context, location: SourceLocation) { + return async function({ dispatch, getState, client, sourceMaps }: ThunkArgs) { + if (!client) { + return; + } + + const pairedLocation = await mapLocation(getState(), sourceMaps, location); + + return dispatch(selectSpecificLocation(cx, { ...pairedLocation })); + }; +} + +export function jumpToMappedSelectedLocation(cx: Context) { + return async function({ dispatch, getState }: ThunkArgs) { + 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..46eec46e2c --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/symbols.js @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { getSymbols } from "../../selectors"; + +import { PROMISE } from "../utils/middleware/promise"; +import { updateTab } from "../tabs"; +import { loadSourceText } from "./loadSourceText"; + +import { + memoizeableAction, + type MemoizedAction, +} from "../../utils/memoizableAction"; +import { fulfilled } from "../../utils/async-value"; + +import type { ThunkArgs } from "../../actions/types"; +import type { Source, Context } from "../../types"; +import type { Symbols } from "../../reducers/types"; + +async function doSetSymbols( + cx: Context, + source: Source, + { dispatch, getState, parser }: ThunkArgs +) { + const sourceId = source.id; + + await dispatch(loadSourceText({ cx, source })); + + await dispatch({ + type: "SET_SYMBOLS", + cx, + sourceId, + [PROMISE]: parser.getSymbols(sourceId), + }); + + const symbols = getSymbols(getState(), source); + if (symbols && symbols.framework) { + dispatch(updateTab(source, symbols.framework)); + } +} + +export const setSymbols: MemoizedAction< + {| cx: Context, source: Source |}, + ?Symbols +> = memoizeableAction("setSymbols", { + getValue: ({ source }, { getState }) => { + if (source.isWasm) { + return fulfilled(null); + } + + const symbols = getSymbols(getState(), source); + if (!symbols || symbols.loading) { + return null; + } + + return fulfilled(symbols); + }, + createKey: ({ source }) => source.id, + action: ({ cx, source }, thunkArgs) => doSetSymbols(cx, source, thunkArgs), +}); diff --git a/devtools/client/debugger/src/actions/sources/tests/__snapshots__/newSources.spec.js.snap b/devtools/client/debugger/src/actions/sources/tests/__snapshots__/newSources.spec.js.snap new file mode 100644 index 0000000000..e888466114 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/__snapshots__/newSources.spec.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`sources - new sources sources - sources with querystrings should find two sources when same source with + querystring 1`] = ` +Array [ + "http://localhost:8000/examples/base.js?v=1", + "http://localhost:8000/examples/base.js?v=2", +] +`; diff --git a/devtools/client/debugger/src/actions/sources/tests/__snapshots__/prettyPrint.spec.js.snap b/devtools/client/debugger/src/actions/sources/tests/__snapshots__/prettyPrint.spec.js.snap new file mode 100644 index 0000000000..5930bf2293 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/__snapshots__/prettyPrint.spec.js.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`sources - pretty print returns a pretty source for a minified file 1`] = ` +Object { + "extensionName": null, + "id": "base.js/originalSource-36c718d4bde9a75edb388ff7733efe7f", + "isBlackBoxed": false, + "isExtension": false, + "isOriginal": true, + "isPrettyPrinted": true, + "isWasm": false, + "relativeUrl": "/examples/base.js:formatted", + "url": "http://localhost:8000/examples/base.js:formatted", +} +`; + +exports[`sources - pretty print returns a pretty source for a minified file 2`] = ` +Object { + "state": "fulfilled", + "value": Object { + "contentType": "text/javascript", + "type": "text", + "value": "function base() { + return base +} +", + }, +} +`; 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..dbe163e3dc --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/blackbox.spec.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/>. */ + +// @flow + +import { + actions, + selectors, + createStore, + makeSource, +} from "../../../utils/test-head"; + +describe("blackbox", () => { + it("should blackbox a source", async () => { + const store = createStore({ + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }); + const { dispatch, getState, cx } = store; + + const foo1Source = await dispatch( + actions.newGeneratedSource(makeSource("foo1")) + ); + await dispatch(actions.toggleBlackBox(cx, foo1Source)); + + const fooSource = selectors.getSource(getState(), "foo1"); + + if (!fooSource) { + throw new Error("foo should exist"); + } + + const displayedSources = selectors.getDisplayedSources(getState()); + expect(displayedSources.FakeThread[fooSource.id].isBlackBoxed).toEqual( + true + ); + expect(fooSource.isBlackBoxed).toEqual(true); + }); +}); 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..de1a0746d3 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js @@ -0,0 +1,275 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + 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"; + +describe("loadSourceText", () => { + it("should load source text", async () => { + const store = createStore(mockCommandClient); + const { dispatch, getState, cx } = store; + + const foo1Source = await dispatch( + actions.newGeneratedSource(makeSource("foo1")) + ); + await dispatch(actions.loadSourceText({ cx, source: foo1Source })); + + const foo1Content = selectors.getSourceContent(getState(), foo1Source.id); + 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")) + ); + await dispatch(actions.loadSourceText({ cx, source: foo2Source })); + + const foo2Content = selectors.getSourceContent(getState(), foo2Source.id); + 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 + ? fooOrigSource1.id + : fooOrigSource2.id, + })), + getOriginalSourceText: async s => ({ + text: fooOrigContent.source, + contentType: fooOrigContent.contentType, + }), + } + ); + const { cx, dispatch, getState } = store; + + const fooGenSource1 = await dispatch( + actions.newGeneratedSource(makeSource("fooGen1")) + ); + const fooOrigSource1 = await dispatch( + actions.newOriginalSource(makeOriginalSource(fooGenSource1)) + ); + const fooGenSource2 = await dispatch( + actions.newGeneratedSource(makeSource("fooGen2")) + ); + const fooOrigSource2 = await dispatch( + actions.newOriginalSource(makeOriginalSource(fooGenSource2)) + ); + + await dispatch(actions.loadSourceText({ cx, source: fooOrigSource1 })); + + await dispatch( + actions.addBreakpoint( + cx, + { + sourceId: fooOrigSource1.id, + line: 1, + column: 0, + }, + {} + ) + ); + + const breakpoint1 = getBreakpointsList(getState())[0]; + expect(breakpoint1.text).toBe(""); + expect(breakpoint1.originalText).toBe("var fooOrig = 42;"); + + await dispatch(actions.loadSourceText({ cx, source: fooGenSource1 })); + + const breakpoint2 = getBreakpointsList(getState())[0]; + expect(breakpoint2.text).toBe("var fooGen = 42;"); + expect(breakpoint2.originalText).toBe("var fooOrig = 42;"); + + await dispatch(actions.loadSourceText({ cx, source: fooGenSource2 })); + + await dispatch( + actions.addBreakpoint( + cx, + { + sourceId: fooGenSource2.id, + line: 1, + column: 0, + }, + {} + ) + ); + + const breakpoint3 = getBreakpointsList(getState())[1]; + expect(breakpoint3.text).toBe("var fooGen = 42;"); + expect(breakpoint3.originalText).toBe(""); + + await dispatch(actions.loadSourceText({ cx, source: fooOrigSource2 })); + + 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"; + + await dispatch(actions.newGeneratedSource(makeSource(id))); + + let source = selectors.getSourceFromId(getState(), id); + dispatch(actions.loadSourceText({ cx, source })); + + source = selectors.getSourceFromId(getState(), id); + const loading = dispatch(actions.loadSourceText({ cx, source })); + + if (!resolve) { + throw new Error("no resolve"); + } + resolve({ source: "yay", contentType: "text/javascript" }); + await loading; + expect(count).toEqual(1); + + const content = selectors.getSourceContent(getState(), id); + 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"; + + await dispatch(actions.newGeneratedSource(makeSource(id))); + let source = selectors.getSourceFromId(getState(), id); + const loading = dispatch(actions.loadSourceText({ cx, source })); + + if (!resolve) { + throw new Error("no resolve"); + } + resolve({ source: "yay", contentType: "text/javascript" }); + await loading; + + source = selectors.getSourceFromId(getState(), id); + await dispatch(actions.loadSourceText({ cx, source })); + expect(count).toEqual(1); + + const content = selectors.getSourceContent(getState(), id); + expect( + content && + isFulfilled(content) && + content.value.type === "text" && + content.value.value + ).toEqual("yay"); + }); + + it("should cache subsequent source text loads", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo1")) + ); + await dispatch(actions.loadSourceText({ cx, source })); + const prevSource = selectors.getSourceFromId(getState(), "foo1"); + + await dispatch(actions.loadSourceText({ cx, source: prevSource })); + const curSource = selectors.getSource(getState(), "foo1"); + + expect(prevSource === curSource).toBeTruthy(); + }); + + it("should indicate a loading source", async () => { + const store = createStore(mockCommandClient); + const { dispatch, cx } = store; + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo2")) + ); + + const wasLoading = watchForState(store, state => { + return !selectors.getSourceContent(state, "foo2"); + }); + + await dispatch(actions.loadSourceText({ cx, source })); + + 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")) + ); + await dispatch(actions.loadSourceText({ cx, source })); + const badSource = selectors.getSource(getState(), "bad-id"); + + const content = badSource + ? selectors.getSourceContent(getState(), badSource.id) + : null; + expect( + content && isRejected(content) && typeof content.value === "string" + ? content.value.indexOf("Unknown source") + : -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..fd534b8052 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/newSources.spec.js @@ -0,0 +1,198 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + 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-source-map"; + +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.newOriginalSource(makeOriginalSource(generated))); + await dispatch(actions.newOriginalSource(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, + } + ); + + 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, + } + ); + + 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, + } + ); + 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"); + }); + + describe("sources - sources with querystrings", () => { + it(`should find two sources when same source with + querystring`, async () => { + const { getSourcesUrlsInSources } = selectors; + const { dispatch, getState } = createStore(mockCommandClient); + await dispatch(actions.newGeneratedSource(makeSource("base.js?v=1"))); + await dispatch(actions.newGeneratedSource(makeSource("base.js?v=2"))); + await dispatch(actions.newGeneratedSource(makeSource("diff.js?v=1"))); + + const base1 = "http://localhost:8000/examples/base.js?v=1"; + const diff1 = "http://localhost:8000/examples/diff.js?v=1"; + const diff2 = "http://localhost:8000/examples/diff.js?v=1"; + + expect(getSourcesUrlsInSources(getState(), base1)).toHaveLength(2); + expect(getSourcesUrlsInSources(getState(), base1)).toMatchSnapshot(); + + expect(getSourcesUrlsInSources(getState(), diff1)).toHaveLength(1); + await dispatch(actions.newGeneratedSource(makeSource("diff.js?v=2"))); + expect(getSourcesUrlsInSources(getState(), diff2)).toHaveLength(2); + expect(getSourcesUrlsInSources(getState(), diff1)).toHaveLength(2); + }); + }); +}); diff --git a/devtools/client/debugger/src/actions/sources/tests/prettyPrint.spec.js b/devtools/client/debugger/src/actions/sources/tests/prettyPrint.spec.js new file mode 100644 index 0000000000..a490949cb6 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/prettyPrint.spec.js @@ -0,0 +1,69 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + actions, + selectors, + createStore, + makeSource, +} from "../../../utils/test-head"; +import { createPrettySource } from "../prettyPrint"; +import { mockCommandClient } from "../../tests/helpers/mockCommandClient"; +import { isFulfilled } from "../../../utils/async-value"; + +describe("sources - pretty print", () => { + it("returns a pretty source for a minified file", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const url = "base.js"; + const source = await dispatch(actions.newGeneratedSource(makeSource(url))); + await dispatch(actions.loadSourceText({ cx, source })); + + await dispatch(createPrettySource(cx, source.id)); + + const prettyURL = `${source.url}:formatted`; + const pretty = selectors.getSourceByURL(getState(), prettyURL); + const content = pretty + ? selectors.getSourceContent(getState(), pretty.id) + : null; + expect(pretty && pretty.url.includes(prettyURL)).toEqual(true); + expect(pretty).toMatchSnapshot(); + + expect( + content && + isFulfilled(content) && + content.value.type === "text" && + content.value.contentType + ).toEqual("text/javascript"); + expect(content).toMatchSnapshot(); + }); + + it("should create a source when first toggling pretty print", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foobar.js")) + ); + await dispatch(actions.loadSourceText({ cx, source })); + + await dispatch(actions.togglePrettyPrint(cx, source.id)); + expect(selectors.getSourceCount(getState())).toEqual(2); + }); + + it("should not make a second source when toggling pretty print", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foobar.js")) + ); + await dispatch(actions.loadSourceText({ cx, source })); + + await dispatch(actions.togglePrettyPrint(cx, source.id)); + expect(selectors.getSourceCount(getState())).toEqual(2); + await dispatch(actions.togglePrettyPrint(cx, source.id)); + expect(selectors.getSourceCount(getState())).toEqual(2); + }); +}); diff --git a/devtools/client/debugger/src/actions/sources/tests/querystrings.spec.js b/devtools/client/debugger/src/actions/sources/tests/querystrings.spec.js new file mode 100644 index 0000000000..a3d43c19c8 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/querystrings.spec.js @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + actions, + selectors, + createStore, + makeSource, +} from "../../../utils/test-head"; +const { getSourcesUrlsInSources } = selectors; + +// eslint-disable-next-line max-len +import { mockCommandClient } from "../../tests/helpers/mockCommandClient"; + +describe("sources - sources with querystrings", () => { + it("should find two sources when same source with querystring", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + await dispatch(actions.newGeneratedSource(makeSource("base.js?v=1"))); + await dispatch(actions.newGeneratedSource(makeSource("base.js?v=2"))); + await dispatch(actions.newGeneratedSource(makeSource("diff.js?v=1"))); + + expect( + getSourcesUrlsInSources( + getState(), + "http://localhost:8000/examples/base.js?v=1" + ) + ).toHaveLength(2); + expect( + getSourcesUrlsInSources( + getState(), + "http://localhost:8000/examples/diff.js?v=1" + ) + ).toHaveLength(1); + }); +}); 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..43101d0019 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/select.spec.js @@ -0,0 +1,307 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { getSymbols } from "../../../reducers/ast"; +import { + actions, + selectors, + createStore, + makeFrame, + makeSource, + makeSourceURL, + waitForState, + makeOriginalSource, +} from "../../../utils/test-head"; +const { + getSource, + getSourceCount, + getSelectedSource, + getSourceTabs, + getSelectedLocation, +} = selectors; + +import { mockCommandClient } from "../../tests/helpers/mockCommandClient"; + +process.on("unhandledRejection", (reason, p) => {}); + +function initialLocation(sourceId) { + return { 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" }); + + 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, { sourceId: "foo1", 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"))); + 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 = ({ test: "testLocation" }: any); + + // set value + dispatch(actions.setSelectedLocation(cx, source, 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")) + ); + + await dispatch( + actions.selectLocation(cx, { sourceId: baseSource.id, line: 1 }) + ); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe(baseSource.id); + await waitForState(store, state => getSymbols(state, baseSource)); + }); + + it("should keep the original the viewing context", async () => { + const { dispatch, getState, cx } = createStore( + mockCommandClient, + {}, + { + getOriginalLocation: async location => ({ ...location, line: 12 }), + getOriginalLocations: async items => items, + getGeneratedLocation: async location => ({ ...location, line: 12 }), + getOriginalSourceText: async () => ({ text: "" }), + getGeneratedRangesForOriginal: async () => [], + } + ); + + const baseSource = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + + const originalBaseSource = await dispatch( + actions.newOriginalSource(makeOriginalSource(baseSource)) + ); + + await dispatch(actions.selectSource(cx, originalBaseSource.id)); + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + await dispatch( + actions.selectLocation(cx, { sourceId: fooSource.id, line: 1 }) + ); + + const selected = getSelectedLocation(getState()); + expect(selected && selected.line).toBe(12); + }); + + 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 baseSource = await dispatch( + actions.newOriginalSource(makeOriginalSource(baseGenSource)) + ); + await dispatch(actions.selectSource(cx, baseSource.id)); + + await dispatch( + actions.selectSpecificLocation(cx, { + sourceId: baseSource.id, + 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..f3464a7bfb --- /dev/null +++ b/devtools/client/debugger/src/actions/tabs.js @@ -0,0 +1,109 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * Redux actions for the editor tabs + * @module actions/tabs + */ + +import { removeDocument } from "../utils/editor"; +import { recordEvent } from "../utils/telemetry"; +import { selectSource } from "./sources"; + +import { + getSourceByURL, + getSourceTabs, + getNewSelectedSourceId, +} from "../selectors"; + +import type { Action, ThunkArgs } from "./types"; +import type { Source, Context, SourceId, URL } from "../types"; + +export function updateTab(source: Source, framework: string): Action { + const { url, id: sourceId, isOriginal } = source; + + return { + type: "UPDATE_TAB", + url, + framework, + isOriginal, + sourceId, + }; +} + +export function addTab(source: Source): Action { + const { url, id: sourceId, isOriginal } = source; + + return { + type: "ADD_TAB", + url, + isOriginal, + sourceId, + }; +} + +export function moveTab(url: URL, tabIndex: number): Action { + return { + type: "MOVE_TAB", + url, + tabIndex, + }; +} + +export function moveTabBySourceId( + sourceId: SourceId, + tabIndex: number +): Action { + return { + type: "MOVE_TAB_BY_SOURCE_ID", + sourceId, + tabIndex, + }; +} + +/** + * @memberof actions/tabs + * @static + */ +export function closeTab( + cx: Context, + source: Source, + reason: string = "click" +) { + return ({ dispatch, getState, client }: ThunkArgs) => { + removeDocument(source.id); + + const tabs = getSourceTabs(getState()); + dispatch(({ type: "CLOSE_TAB", source }: Action)); + + const sourceId = getNewSelectedSourceId(getState(), tabs); + dispatch(selectSource(cx, sourceId)); + + recordEvent("close_source_tab", { + reason, + num_tabs: tabs.length, + }); + }; +} + +/** + * @memberof actions/tabs + * @static + */ +export function closeTabs(cx: Context, urls: string[]) { + return ({ dispatch, getState, client }: ThunkArgs) => { + 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 }: Action)); + + const sourceId = getNewSelectedSourceId(getState(), tabs); + dispatch(selectSource(cx, sourceId)); + }; +} diff --git a/devtools/client/debugger/src/actions/tests/__snapshots__/ast.spec.js.snap b/devtools/client/debugger/src/actions/tests/__snapshots__/ast.spec.js.snap new file mode 100644 index 0000000000..f9f1af276e --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/__snapshots__/ast.spec.js.snap @@ -0,0 +1,83 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ast setSymbols when the source is loaded should be able to set symbols 1`] = ` +Object { + "callExpressions": Array [], + "classes": Array [], + "comments": Array [], + "functions": Array [ + Object { + "identifier": Object { + "end": 13, + "loc": Object { + "end": Object { + "column": 13, + "line": 1, + }, + "identifierName": "base", + "start": Object { + "column": 9, + "line": 1, + }, + }, + "name": "base", + "start": 9, + "type": "Identifier", + }, + "index": 0, + "klass": null, + "location": Object { + "end": Object { + "column": 21, + "line": 1, + }, + "start": Object { + "column": 0, + "line": 1, + }, + }, + "name": "base", + "parameterNames": Array [ + "boo", + ], + }, + ], + "hasJsx": false, + "hasTypes": false, + "identifiers": Array [ + Object { + "expression": "base", + "location": Object { + "end": Object { + "column": 13, + "line": 1, + }, + "start": Object { + "column": 9, + "line": 1, + }, + }, + "name": "base", + }, + Object { + "expression": "boo", + "location": Object { + "end": Object { + "column": 17, + "line": 1, + }, + "start": Object { + "column": 14, + "line": 1, + }, + }, + "name": "boo", + }, + ], + "imports": Array [], + "literals": Array [], + "loading": false, + "memberExpressions": Array [], + "objectProperties": Array [], +} +`; 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..bb87128f1d --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/__snapshots__/pending-breakpoints.spec.js.snap @@ -0,0 +1,112 @@ +// 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, + "sourceUrl": "http://localhost:8000/examples/bar.js", + }, + "location": Object { + "column": 2, + "line": 5, + "sourceId": "", + "sourceUrl": "http://localhost:8000/examples/bar.js", + }, + "options": Object { + "condition": null, + "hidden": false, + }, + }, +} +`; + +exports[`when adding breakpoints a corresponding pending breakpoint should be added 1`] = ` +Object { + "astLocation": Object { + "index": 0, + "name": undefined, + "offset": Object { + "column": 1, + "line": 5, + "sourceId": "foo.js", + "sourceUrl": "http://localhost:8000/examples/foo.js", + }, + }, + "disabled": false, + "generatedLocation": Object { + "column": 1, + "line": 5, + "sourceUrl": "http://localhost:8000/examples/foo.js", + }, + "location": Object { + "column": 1, + "line": 5, + "sourceUrl": "http://localhost:8000/examples/foo.js", + }, + "options": Object {}, +} +`; + +exports[`when adding breakpoints adding and deleting breakpoints add a corresponding pendingBreakpoint for each addition 1`] = ` +Object { + "astLocation": Object { + "index": 0, + "name": undefined, + "offset": Object { + "column": 0, + "line": 5, + "sourceId": "foo", + "sourceUrl": "http://localhost:8000/examples/foo", + }, + }, + "disabled": false, + "generatedLocation": Object { + "column": 0, + "line": 5, + "sourceUrl": "http://localhost:8000/examples/foo", + }, + "location": Object { + "column": 0, + "line": 5, + "sourceUrl": "http://localhost:8000/examples/foo", + }, + "options": Object {}, +} +`; + +exports[`when adding breakpoints adding and deleting breakpoints add a corresponding pendingBreakpoint for each addition 2`] = ` +Object { + "astLocation": Object { + "index": 0, + "name": undefined, + "offset": Object { + "column": 0, + "line": 5, + "sourceId": "foo2", + "sourceUrl": "http://localhost:8000/examples/foo2", + }, + }, + "disabled": false, + "generatedLocation": Object { + "column": 0, + "line": 5, + "sourceUrl": "http://localhost:8000/examples/foo2", + }, + "location": Object { + "column": 0, + "line": 5, + "sourceUrl": "http://localhost:8000/examples/foo2", + }, + "options": Object {}, +} +`; 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/__snapshots__/project-text-search.spec.js.snap b/devtools/client/debugger/src/actions/tests/__snapshots__/project-text-search.spec.js.snap new file mode 100644 index 0000000000..e7f0e40d64 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/__snapshots__/project-text-search.spec.js.snap @@ -0,0 +1,180 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`project text search should clear all the search results 1`] = ` +Array [ + Object { + "filepath": "http://localhost:8000/examples/foo1", + "matches": Array [ + Object { + "column": 9, + "line": 1, + "match": "foo", + "matchIndex": 9, + "sourceId": "foo1", + "type": "MATCH", + "value": "function foo1() {", + }, + Object { + "column": 8, + "line": 2, + "match": "foo", + "matchIndex": 8, + "sourceId": "foo1", + "type": "MATCH", + "value": " const foo = 5; return foo;", + }, + Object { + "column": 24, + "line": 2, + "match": "foo", + "matchIndex": 24, + "sourceId": "foo1", + "type": "MATCH", + "value": " const foo = 5; return foo;", + }, + ], + "sourceId": "foo1", + "type": "RESULT", + }, +] +`; + +exports[`project text search should clear all the search results 2`] = `Array []`; + +exports[`project text search should close project search 1`] = ` +Array [ + Object { + "filepath": "http://localhost:8000/examples/foo1", + "matches": Array [ + Object { + "column": 9, + "line": 1, + "match": "foo", + "matchIndex": 9, + "sourceId": "foo1", + "type": "MATCH", + "value": "function foo1() {", + }, + Object { + "column": 8, + "line": 2, + "match": "foo", + "matchIndex": 8, + "sourceId": "foo1", + "type": "MATCH", + "value": " const foo = 5; return foo;", + }, + Object { + "column": 24, + "line": 2, + "match": "foo", + "matchIndex": 24, + "sourceId": "foo1", + "type": "MATCH", + "value": " const foo = 5; return foo;", + }, + ], + "sourceId": "foo1", + "type": "RESULT", + }, +] +`; + +exports[`project text search should close project search 2`] = `Array []`; + +exports[`project text search should ignore sources with minified versions 1`] = ` +Array [ + Object { + "filepath": "http://localhost:8000/examples/bar:formatted", + "matches": Array [ + Object { + "column": 9, + "line": 1, + "match": "bla", + "matchIndex": 9, + "sourceId": "bar/originalSource-79d3ab91075b948b7044296e606a28c5", + "type": "MATCH", + "value": "function bla(x, y) {", + }, + ], + "sourceId": "bar/originalSource-79d3ab91075b948b7044296e606a28c5", + "type": "RESULT", + }, +] +`; + +exports[`project text search should search a specific source 1`] = ` +Array [ + Object { + "filepath": "http://localhost:8000/examples/bar", + "matches": Array [ + Object { + "column": 9, + "line": 1, + "match": "bla", + "matchIndex": 9, + "sourceId": "bar", + "type": "MATCH", + "value": "function bla(x, y) {", + }, + ], + "sourceId": "bar", + "type": "RESULT", + }, +] +`; + +exports[`project text search should search all the loaded sources based on the query 1`] = ` +Array [ + Object { + "filepath": "http://localhost:8000/examples/foo1", + "matches": Array [ + Object { + "column": 9, + "line": 1, + "match": "foo", + "matchIndex": 9, + "sourceId": "foo1", + "type": "MATCH", + "value": "function foo1() {", + }, + Object { + "column": 8, + "line": 2, + "match": "foo", + "matchIndex": 8, + "sourceId": "foo1", + "type": "MATCH", + "value": " const foo = 5; return foo;", + }, + Object { + "column": 24, + "line": 2, + "match": "foo", + "matchIndex": 24, + "sourceId": "foo1", + "type": "MATCH", + "value": " const foo = 5; return foo;", + }, + ], + "sourceId": "foo1", + "type": "RESULT", + }, + Object { + "filepath": "http://localhost:8000/examples/foo2", + "matches": Array [ + Object { + "column": 9, + "line": 1, + "match": "foo", + "matchIndex": 9, + "sourceId": "foo2", + "type": "MATCH", + "value": "function foo2(x, y) {", + }, + ], + "sourceId": "foo2", + "type": "RESULT", + }, +] +`; diff --git a/devtools/client/debugger/src/actions/tests/ast.spec.js b/devtools/client/debugger/src/actions/tests/ast.spec.js new file mode 100644 index 0000000000..9dde191e77 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/ast.spec.js @@ -0,0 +1,128 @@ +/* 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/>. */ + +// @flow + +import { + createStore, + selectors, + actions, + makeSource, + makeOriginalSource, + waitForState, +} from "../../utils/test-head"; + +import readFixture from "./helpers/readFixture"; +const { getSymbols, isSymbolsLoading, getFramework } = selectors; + +const mockCommandClient = { + sourceContents: async ({ source }) => ({ + source: sourceTexts[source], + contentType: "text/javascript", + }), + getFrameScopes: async () => {}, + evaluate: async expression => ({ result: evaluationResult[expression] }), + evaluateExpressions: async expressions => + expressions.map(expression => ({ result: evaluationResult[expression] })), + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], +}; + +const sourceMaps = { + getOriginalSourceText: async id => ({ + id, + text: sourceTexts[id], + contentType: "text/javascript", + }), + getGeneratedRangesForOriginal: async () => [], + getOriginalLocations: async items => items, +}; + +const sourceTexts = { + "base.js": "function base(boo) {}", + "foo.js": "function base(boo) { return this.bazz; } outOfScope", + "reactComponent.js/originalSource": readFixture("reactComponent.js"), + "reactFuncComponent.js/originalSource": readFixture("reactFuncComponent.js"), +}; + +const evaluationResult = { + "this.bazz": { actor: "bazz", preview: {} }, + this: { actor: "this", preview: {} }, +}; + +describe("ast", () => { + describe("setSymbols", () => { + describe("when the source is loaded", () => { + it("should be able to set symbols", async () => { + const store = createStore(mockCommandClient); + const { dispatch, getState, cx } = store; + const base = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + await dispatch(actions.loadSourceText({ cx, source: base })); + + const loadedSource = selectors.getSourceFromId(getState(), base.id); + await dispatch(actions.setSymbols({ cx, source: loadedSource })); + await waitForState(store, state => !isSymbolsLoading(state, base)); + + const baseSymbols = getSymbols(getState(), base); + expect(baseSymbols).toMatchSnapshot(); + }); + }); + + describe("when the source is not loaded", () => { + it("should return null", async () => { + const { getState, dispatch } = createStore(mockCommandClient); + const base = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + + const baseSymbols = getSymbols(getState(), base); + expect(baseSymbols).toEqual(null); + }); + }); + + describe("when there is no source", () => { + it("should return null", async () => { + const { getState } = createStore(mockCommandClient); + const baseSymbols = getSymbols(getState()); + expect(baseSymbols).toEqual(null); + }); + }); + + describe("frameworks", () => { + it("should detect react components", async () => { + const store = createStore(mockCommandClient, {}, sourceMaps); + const { cx, dispatch, getState } = store; + + const genSource = await dispatch( + actions.newGeneratedSource(makeSource("reactComponent.js")) + ); + + const source = await dispatch( + actions.newOriginalSource(makeOriginalSource(genSource)) + ); + + await dispatch(actions.loadSourceText({ cx, source })); + const loadedSource = selectors.getSourceFromId(getState(), source.id); + await dispatch(actions.setSymbols({ cx, source: loadedSource })); + + expect(getFramework(getState(), source)).toBe("React"); + }); + + it("should not give false positive on non react components", async () => { + const store = createStore(mockCommandClient); + const { cx, dispatch, getState } = store; + const base = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + await dispatch(actions.loadSourceText({ cx, source: base })); + await dispatch(actions.setSymbols({ cx, source: base })); + + expect(getFramework(getState(), base)).toBe(undefined); + }); + }); + }); +}); 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..0a1cffefe7 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/expressions.spec.js @@ -0,0 +1,186 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + actions, + selectors, + createStore, + makeSource, +} from "../../utils/test-head"; + +import { makeMockFrame } from "../../utils/test-mockup"; + +const mockThreadFront = { + evaluateInFrame: (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: any))); + 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/file-search.spec.js b/devtools/client/debugger/src/actions/tests/file-search.spec.js new file mode 100644 index 0000000000..06118edc8d --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/file-search.spec.js @@ -0,0 +1,66 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { createStore, selectors, actions } from "../../utils/test-head"; + +const { + getFileSearchQuery, + getFileSearchModifiers, + getFileSearchResults, +} = selectors; + +describe("file text search", () => { + it("should update search results", () => { + const { dispatch, getState, cx } = createStore(); + expect(getFileSearchResults(getState())).toEqual({ + matches: [], + matchIndex: -1, + index: -1, + count: 0, + }); + + const matches = [ + { line: 1, ch: 3 }, + { line: 3, ch: 2 }, + ]; + dispatch(actions.updateSearchResults(cx, 2, 3, matches)); + + expect(getFileSearchResults(getState())).toEqual({ + count: 2, + index: 2, + matchIndex: 1, + matches, + }); + }); + + it("should update the file search query", () => { + const { dispatch, getState, cx } = createStore(); + let fileSearchQueryState = getFileSearchQuery(getState()); + expect(fileSearchQueryState).toBe(""); + dispatch(actions.setFileSearchQuery(cx, "foobar")); + fileSearchQueryState = getFileSearchQuery(getState()); + expect(fileSearchQueryState).toBe("foobar"); + }); + + it("should toggle a file search modifier", () => { + const { dispatch, getState, cx } = createStore(); + let fileSearchModState = getFileSearchModifiers(getState()); + expect(fileSearchModState.caseSensitive).toBe(false); + dispatch(actions.toggleFileSearchModifier(cx, "caseSensitive")); + fileSearchModState = getFileSearchModifiers(getState()); + expect(fileSearchModState.caseSensitive).toBe(true); + }); + + it("should toggle a file search query cleaning", () => { + const { dispatch, getState, cx } = createStore(); + dispatch(actions.setFileSearchQuery(cx, "foobar")); + let fileSearchQueryState = getFileSearchQuery(getState()); + expect(fileSearchQueryState).toBe("foobar"); + dispatch(actions.setFileSearchQuery(cx, "")); + fileSearchQueryState = getFileSearchQuery(getState()); + expect(fileSearchQueryState).toBe(""); + }); +}); 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..dd4f108d71 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/helpers/breakpoints.js @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +export function mockPendingBreakpoint(overrides: Object = {}) { + const { sourceUrl, line, column, condition, disabled, hidden } = overrides; + return { + location: { + sourceId: "", + sourceUrl: sourceUrl || "http://localhost:8000/examples/bar.js", + line: line || 5, + column: column || 1, + }, + generatedLocation: { + 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: string, + line: number = 5, + column: number = 0 +) { + return { + id: "breakpoint", + originalText: "", + text: "", + location: { + sourceUrl: `http://localhost:8000/examples/${filename}`, + sourceId: `${filename}`, + line, + column, + }, + generatedLocation: { + 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..9980b721f9 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/helpers/mockCommandClient.js @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { SourceActor } from "../../../types"; + +export function createSource(name: string, code?: string) { + 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, + }: SourceActor): Promise<{| source: any, contentType: ?string |}> { + return new Promise((resolve, reject) => { + if (sources.includes(source)) { + resolve(createSource(source)); + } + + reject(`unknown source: ${source}`); + }); + }, + setBreakpoint: async () => {}, + removeBreakpoint: (_id: string) => 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..0206514b00 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/helpers/readFixture.js @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import fs from "fs"; +import path from "path"; + +export default function readFixture(name: string) { + 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..cc8bd8300a --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/navigation.spec.js @@ -0,0 +1,108 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + createStore, + selectors, + actions, + makeSource, +} from "../../utils/test-head"; + +jest.mock("../../utils/editor"); + +const { + getActiveSearch, + getTextSearchQuery, + getTextSearchResults, + getTextSearchStatus, + getFileSearchQuery, + getFileSearchResults, +} = 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 closes project-search", async () => { + const { dispatch, getState, cx } = createStore(threadFront); + const mockQuery = "foo"; + + await dispatch(actions.newGeneratedSource(makeSource("foo1"))); + await dispatch(actions.searchSources(cx, mockQuery)); + + let results = getTextSearchResults(getState()); + expect(results).toHaveLength(1); + expect(selectors.getTextSearchQuery(getState())).toEqual("foo"); + expect(getTextSearchStatus(getState())).toEqual("DONE"); + + await dispatch(actions.willNavigate("will-navigate")); + + results = getTextSearchResults(getState()); + expect(results).toHaveLength(0); + expect(getTextSearchQuery(getState())).toEqual(""); + expect(getTextSearchStatus(getState())).toEqual("INITIAL"); + }); + + it("navigation removes activeSearch 'project' value", async () => { + const { dispatch, getState } = createStore(threadFront); + dispatch(actions.setActiveSearch("project")); + expect(getActiveSearch(getState())).toBe("project"); + + await dispatch(actions.willNavigate("will-navigate")); + expect(getActiveSearch(getState())).toBe(null); + }); + + it("navigation clears the file-search query", async () => { + const { dispatch, getState, cx } = createStore(threadFront); + + dispatch(actions.setFileSearchQuery(cx, "foobar")); + expect(getFileSearchQuery(getState())).toBe("foobar"); + + await dispatch(actions.willNavigate("will-navigate")); + + expect(getFileSearchQuery(getState())).toBe(""); + }); + + it("navigation clears the file-search results", async () => { + const { dispatch, getState, cx } = createStore(threadFront); + + const searchResults = [ + { line: 1, ch: 3 }, + { line: 3, ch: 2 }, + ]; + dispatch(actions.updateSearchResults(cx, 2, 3, searchResults)); + expect(getFileSearchResults(getState())).toEqual({ + count: 2, + index: 2, + matchIndex: 1, + matches: searchResults, + }); + + await dispatch(actions.willNavigate("will-navigate")); + + expect(getFileSearchResults(getState())).toEqual({ + count: 0, + index: -1, + matchIndex: -1, + matches: [], + }); + }); + + 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..f43686d199 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/pending-breakpoints.spec.js @@ -0,0 +1,439 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +// 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 id = makePendingLocationId(mockedPendingBreakpoint.location); + 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 sourceMaps from "devtools-source-map"; + +import { makePendingLocationId } from "../../utils/breakpoint"; +function mockClient(bpPos = {}) { + return { + ...mockCommandClient, + setSkipPausing: jest.fn(), + getSourceActorBreakpointPositions: async () => bpPos, + getSourceActorBreakableLines: async () => [], + }; +} + +function mockSourceMaps() { + return { + ...sourceMaps, + 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")) + ); + await dispatch(actions.newGeneratedSource(makeSource("foo.js"))); + await dispatch(actions.loadSourceText({ cx, source })); + + const bp = generateBreakpoint("foo.js", 5, 1); + const id = makePendingLocationId(bp.location); + + await dispatch(actions.addBreakpoint(cx, bp.location)); + const pendingBps = selectors.getPendingBreakpoints(getState()); + + expect(selectors.getPendingBreakpointList(getState())).toHaveLength(2); + expect(pendingBps[id]).toMatchSnapshot(); + }); + + describe("adding and deleting breakpoints", () => { + let breakpoint1; + let breakpoint2; + let breakpointLocationId1; + let breakpointLocationId2; + + beforeEach(() => { + breakpoint1 = generateBreakpoint("foo"); + breakpoint2 = generateBreakpoint("foo2"); + breakpointLocationId1 = makePendingLocationId(breakpoint1.location); + breakpointLocationId2 = makePendingLocationId(breakpoint2.location); + }); + + it("add a corresponding pendingBreakpoint for each addition", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ "5": [0] }), + loadInitialState(), + mockSourceMaps() + ); + + await dispatch(actions.newGeneratedSource(makeSource("foo"))); + await dispatch(actions.newGeneratedSource(makeSource("foo2"))); + + const source1 = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + const source2 = await dispatch( + actions.newGeneratedSource(makeSource("foo2")) + ); + + await dispatch(actions.loadSourceText({ cx, source: source1 })); + await dispatch(actions.loadSourceText({ cx, source: source2 })); + + await dispatch(actions.addBreakpoint(cx, breakpoint1.location)); + await dispatch(actions.addBreakpoint(cx, breakpoint2.location)); + + const pendingBps = selectors.getPendingBreakpoints(getState()); + + // NOTE the sourceId should be `foo2/originalSource`, but is `foo2` + // because we do not have a real source map for `getOriginalLocation` + // to map. + expect(pendingBps[breakpointLocationId1]).toMatchSnapshot(); + expect(pendingBps[breakpointLocationId2]).toMatchSnapshot(); + }); + + it("hidden breakponts do not create pending bps", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ "5": [0] }), + loadInitialState(), + mockSourceMaps() + ); + + await dispatch(actions.newGeneratedSource(makeSource("foo"))); + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + await dispatch(actions.loadSourceText({ cx, source })); + + await dispatch( + actions.addBreakpoint(cx, breakpoint1.location, { hidden: true }) + ); + const pendingBps = selectors.getPendingBreakpoints(getState()); + + expect(pendingBps[breakpointLocationId1]).toBeUndefined(); + }); + + it("remove a corresponding pending breakpoint when deleting", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ "5": [0] }), + loadInitialState(), + mockSourceMaps() + ); + + await dispatch(actions.newGeneratedSource(makeSource("foo"))); + await dispatch(actions.newGeneratedSource(makeSource("foo2"))); + + const source1 = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + const source2 = await dispatch( + actions.newGeneratedSource(makeSource("foo2")) + ); + + await dispatch(actions.loadSourceText({ cx, source: source1 })); + await dispatch(actions.loadSourceText({ cx, source: source2 })); + + await dispatch(actions.addBreakpoint(cx, breakpoint1.location)); + await dispatch(actions.addBreakpoint(cx, breakpoint2.location)); + await dispatch(actions.removeBreakpoint(cx, breakpoint1)); + + const pendingBps = selectors.getPendingBreakpoints(getState()); + expect(pendingBps.hasOwnProperty(breakpointLocationId1)).toBe(false); + expect(pendingBps.hasOwnProperty(breakpointLocationId2)).toBe(true); + }); + }); +}); + +describe("when changing an existing breakpoint", () => { + it("updates corresponding pendingBreakpoint", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ "5": [0] }), + loadInitialState(), + mockSourceMaps() + ); + const bp = generateBreakpoint("foo"); + const id = makePendingLocationId(bp.location); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + await dispatch(actions.newGeneratedSource(makeSource("foo"))); + await dispatch(actions.loadSourceText({ cx, source })); + + await dispatch(actions.addBreakpoint(cx, bp.location)); + await dispatch( + actions.setBreakpointOptions(cx, bp.location, { condition: "2" }) + ); + const bps = selectors.getPendingBreakpoints(getState()); + const breakpoint = bps[id]; + expect(breakpoint.options.condition).toBe("2"); + }); + + it("if disabled, updates corresponding pendingBreakpoint", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ "5": [0] }), + loadInitialState(), + mockSourceMaps() + ); + const bp = generateBreakpoint("foo"); + const id = makePendingLocationId(bp.location); + + await dispatch(actions.newGeneratedSource(makeSource("foo"))); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + await dispatch(actions.loadSourceText({ cx, source })); + + await dispatch(actions.addBreakpoint(cx, bp.location)); + await dispatch(actions.disableBreakpoint(cx, bp)); + const bps = selectors.getPendingBreakpoints(getState()); + const breakpoint = bps[id]; + expect(breakpoint.disabled).toBe(true); + }); + + it("does not delete the pre-existing pendingBreakpoint", 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")) + ); + await dispatch(actions.newGeneratedSource(makeSource("foo.js"))); + await dispatch(actions.loadSourceText({ cx, source })); + + const id = makePendingLocationId(bp.location); + + await dispatch(actions.addBreakpoint(cx, bp.location)); + await dispatch( + actions.setBreakpointOptions(cx, bp.location, { condition: "2" }) + ); + const bps = selectors.getPendingBreakpoints(getState()); + const breakpoint = bps[id]; + expect(breakpoint.options.condition).toBe("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); + + await dispatch(actions.newGeneratedSource(makeSource("bar.js"))); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + await dispatch(actions.loadSourceText({ cx, source })); + 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")) + ); + await dispatch(actions.newGeneratedSource(makeSource("foo.js"))); + await dispatch(actions.loadSourceText({ cx, source })); + + 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; + + await dispatch(actions.newGeneratedSource(makeSource("bar.js"))); + const source = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + await dispatch(actions.loadSourceText({ cx, source })); + + await waitForState(store, state => { + const bps = selectors.getBreakpointsForSource(state, source.id); + return bps && Object.values(bps).length > 0; + }); + + const bp = selectors.getBreakpointForLocation(getState(), { + line: 5, + column: 2, + sourceUrl: source.url, + 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); + + await dispatch(actions.newGeneratedSource(makeSource("bar.js"))); + const source = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + await dispatch(actions.loadSourceText({ cx, source })); + + 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: sourceMaps.generatedToOriginalId(source.id, sourceURL), + url: sourceURL, + }, + ], + getOriginalSourceText: async () => ({ text: "" }), + getGeneratedLocation: async location => ({ + line: location.line, + column: location.column, + sourceId: location.sourceId, + }), + getOriginalLocation: async location => location, + getGeneratedRangesForOriginal: async () => [ + { start: { line: 0, column: 0 }, end: { line: 10, column: 10 } }, + ], + getOriginalLocations: async items => + items.map(item => ({ + ...item, + sourceId: sourceMaps.generatedToOriginalId(item.sourceId, sourceURL), + })), + }); + + const { getState, dispatch } = store; + + expect(selectors.getBreakpointCount(getState())).toEqual(0); + + await dispatch(actions.newGeneratedSource(makeSource("bar.js"))); + 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); + + await dispatch(actions.newGeneratedSource(makeSource("bar.js"))); + await dispatch(actions.newGeneratedSource(makeSource("foo.js"))); + const [source1, source2] = await dispatch( + actions.newGeneratedSources([makeSource("bar.js"), makeSource("foo.js")]) + ); + await dispatch(actions.loadSourceText({ cx, source: source1 })); + await dispatch(actions.loadSourceText({ cx, source: source2 })); + + 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..4cb2542adf --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/preview.spec.js @@ -0,0 +1,215 @@ +/* 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/>. */ + +// @flow + +import { + createStore, + selectors, + actions, + makeSource, + makeFrame, + waitForState, + waitATick, +} from "../../utils/test-head"; + +function waitForPreview(store, expression) { + return waitForState(store, state => { + const preview = selectors.getPreview(state); + return preview && preview.expression == expression; + }); +} + +function mockThreadFront(overrides) { + return { + evaluateInFrame: 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 } = store; + const base = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + + await dispatch(actions.selectSource(cx, base.id)); + await waitForState(store, state => selectors.hasSymbols(state, base)); + + 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 base = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + + await dispatch(actions.selectSource(cx, base.id)); + await waitForState(store, state => selectors.hasSymbols(state, base)); + 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; + + /* $FlowIgnore[not-a-function] this is guarantied to be initialized because + `new new Promise(foo)` calls foo synchronously */ + resolveFirst(); + waitForPreview(store, "firstSetPreview").then(() => { + fail = true; + }); + + // $FlowIgnore[not-a-function] same as above + 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; + + /* $FlowIgnore[not-a-function] this is guarantied to be initialized because + `new new Promise(foo)` calls foo synchronously */ + resolveSecond(); + await waitForPreview(store, "secondSetPreview"); + + // $FlowIgnore[not-a-function] same as above + 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/project-text-search.spec.js b/devtools/client/debugger/src/actions/tests/project-text-search.spec.js new file mode 100644 index 0000000000..e63714c58c --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/project-text-search.spec.js @@ -0,0 +1,161 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + actions, + createStore, + selectors, + makeSource, +} from "../../utils/test-head"; + +const { + getSource, + getTextSearchQuery, + getTextSearchResults, + getTextSearchStatus, +} = selectors; + +const sources = { + foo1: { + source: "function foo1() {\n const foo = 5; return foo;\n}", + contentType: "text/javascript", + }, + foo2: { + source: "function foo2(x, y) {\n return x + y;\n}", + contentType: "text/javascript", + }, + bar: { + source: "function bla(x, y) {\n const bar = 4; return 2;\n}", + contentType: "text/javascript", + }, + "bar:formatted": { + source: "function bla(x, y) {\n const bar = 4; return 2;\n}", + contentType: "text/javascript", + }, +}; + +const threadFront = { + sourceContents: async ({ source }) => sources[source], + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], +}; + +describe("project text search", () => { + it("should add a project text search query", () => { + const { dispatch, getState, cx } = createStore(); + const mockQuery = "foo"; + + dispatch(actions.addSearchQuery(cx, mockQuery)); + + expect(getTextSearchQuery(getState())).toEqual(mockQuery); + }); + + it("should search all the loaded sources based on the query", async () => { + const { dispatch, getState, cx } = createStore(threadFront); + const mockQuery = "foo"; + + await dispatch(actions.newGeneratedSource(makeSource("foo1"))); + await dispatch(actions.newGeneratedSource(makeSource("foo2"))); + + await dispatch(actions.searchSources(cx, mockQuery)); + + const results = getTextSearchResults(getState()); + expect(results).toMatchSnapshot(); + }); + + it("should ignore sources with minified versions", async () => { + const mockMaps = { + getOriginalSourceText: async () => ({ + source: "function bla(x, y) {\n const bar = 4; return 2;\n}", + contentType: "text/javascript", + }), + applySourceMap: async () => {}, + getGeneratedRangesForOriginal: async () => [], + getOriginalLocations: async items => items, + getOriginalLocation: async loc => loc, + }; + + const { dispatch, getState, cx } = createStore(threadFront, {}, mockMaps); + + const source1 = await dispatch( + actions.newGeneratedSource(makeSource("bar")) + ); + await dispatch(actions.loadSourceText({ cx, source: source1 })); + + await dispatch(actions.togglePrettyPrint(cx, source1.id)); + + await dispatch(actions.searchSources(cx, "bla")); + + const results = getTextSearchResults(getState()); + expect(results).toMatchSnapshot(); + }); + + it("should search a specific source", async () => { + const { dispatch, getState, cx } = createStore(threadFront); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("bar")) + ); + await dispatch(actions.loadSourceText({ cx, source })); + + dispatch(actions.addSearchQuery(cx, "bla")); + + const barSource = getSource(getState(), "bar"); + if (!barSource) { + throw new Error("no barSource"); + } + const sourceId = barSource.id; + + await dispatch(actions.searchSource(cx, sourceId, "bla"), "bla"); + + const results = getTextSearchResults(getState()); + + expect(results).toMatchSnapshot(); + expect(results).toHaveLength(1); + }); + + it("should clear all the search results", async () => { + const { dispatch, getState, cx } = createStore(threadFront); + const mockQuery = "foo"; + + await dispatch(actions.newGeneratedSource(makeSource("foo1"))); + await dispatch(actions.searchSources(cx, mockQuery)); + + expect(getTextSearchResults(getState())).toMatchSnapshot(); + + await dispatch(actions.clearSearchResults(cx)); + + expect(getTextSearchResults(getState())).toMatchSnapshot(); + }); + + it("should set the status properly", () => { + const { dispatch, getState, cx } = createStore(); + const mockStatus = "FETCHING"; + dispatch(actions.updateSearchStatus(cx, mockStatus)); + expect(getTextSearchStatus(getState())).toEqual(mockStatus); + }); + + it("should close project search", async () => { + const { dispatch, getState, cx } = createStore(threadFront); + const mockQuery = "foo"; + + await dispatch(actions.newGeneratedSource(makeSource("foo1"))); + await dispatch(actions.searchSources(cx, mockQuery)); + + expect(getTextSearchResults(getState())).toMatchSnapshot(); + + dispatch(actions.closeProjectSearch(cx)); + + expect(getTextSearchQuery(getState())).toEqual(""); + + const results = getTextSearchResults(getState()); + + expect(results).toMatchSnapshot(); + expect(results).toHaveLength(0); + const status = getTextSearchStatus(getState()); + expect(status).toEqual("INITIAL"); + }); +}); diff --git a/devtools/client/debugger/src/actions/tests/setProjectDirectoryRoot.spec.js b/devtools/client/debugger/src/actions/tests/setProjectDirectoryRoot.spec.js new file mode 100644 index 0000000000..b8d2fb2906 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/setProjectDirectoryRoot.spec.js @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + createStore, + selectors, + actions, + makeSource, +} from "../../utils/test-head"; + +const { + getProjectDirectoryRoot, + getProjectDirectoryRootName, + getDisplayedSources, +} = selectors; + +describe("setProjectDirectoryRoot", () => { + it("should set domain directory as root", async () => { + const { dispatch, getState, cx } = createStore(); + dispatch(actions.setProjectDirectoryRoot(cx, "example.com", "foo")); + expect(getProjectDirectoryRoot(getState())).toBe("example.com"); + }); + + it("should set a directory as root directory", async () => { + const { dispatch, getState, cx } = createStore(); + dispatch(actions.setProjectDirectoryRoot(cx, "/example.com/foo", "foo")); + expect(getProjectDirectoryRoot(getState())).toBe("/example.com/foo"); + }); + + it("should add to the directory ", () => { + const { dispatch, getState, cx } = createStore(); + dispatch(actions.setProjectDirectoryRoot(cx, "/example.com/foo", "foo")); + dispatch(actions.setProjectDirectoryRoot(cx, "/foo/bar", "foo")); + expect(getProjectDirectoryRoot(getState())).toBe("/example.com/foo/bar"); + }); + + it("should update the directory ", () => { + const { dispatch, getState, cx } = createStore(); + dispatch(actions.setProjectDirectoryRoot(cx, "/example.com/foo", "foo")); + dispatch(actions.clearProjectDirectoryRoot(cx)); + dispatch(actions.setProjectDirectoryRoot(cx, "/example.com/bar", "foo")); + expect(getProjectDirectoryRoot(getState())).toBe("/example.com/bar"); + }); + + it("should filter sources", async () => { + const store = createStore({ + getSourceActorBreakableLines: async () => [], + }); + const { dispatch, getState, cx } = store; + await dispatch(actions.newGeneratedSource(makeSource("js/scopes.js"))); + await dispatch(actions.newGeneratedSource(makeSource("lib/vendor.js"))); + + dispatch( + actions.setProjectDirectoryRoot(cx, "localhost:8000/examples/js", "foo") + ); + + const filteredSourcesByThread = getDisplayedSources(getState()); + const filteredSources = (Object.values( + filteredSourcesByThread.FakeThread + ): any)[0]; + + expect(filteredSources.url).toEqual( + "http://localhost:8000/examples/js/scopes.js" + ); + + expect(filteredSources.relativeUrl).toEqual("scopes.js"); + }); + + it("should update the child directory ", () => { + const { dispatch, getState, cx } = createStore({ + getSourceActorBreakableLines: async () => [], + }); + dispatch(actions.setProjectDirectoryRoot(cx, "example.com", "foo")); + dispatch(actions.setProjectDirectoryRoot(cx, "example.com/foo/bar", "foo")); + expect(getProjectDirectoryRoot(getState())).toBe("example.com/foo/bar"); + }); + + it("should update the child directory when domain name is Webpack://", () => { + const { dispatch, getState, cx } = createStore({ + getSourceActorBreakableLines: async () => [], + }); + dispatch(actions.setProjectDirectoryRoot(cx, "webpack://", "foo")); + dispatch(actions.setProjectDirectoryRoot(cx, "webpack:///app", "foo")); + expect(getProjectDirectoryRoot(getState())).toBe("webpack:///app"); + }); + + it("should set the name of the root directory", () => { + const { dispatch, getState, cx } = createStore(); + dispatch(actions.setProjectDirectoryRoot(cx, "foo", "example.com")); + expect(getProjectDirectoryRootName(getState())).toBe("example.com"); + }); +}); diff --git a/devtools/client/debugger/src/actions/tests/source-tree.spec.js b/devtools/client/debugger/src/actions/tests/source-tree.spec.js new file mode 100644 index 0000000000..fcbd56da33 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/source-tree.spec.js @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { 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..210cc0373a --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/tabs.spec.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/>. */ + +// @flow + +import { + actions, + selectors, + createStore, + makeSource, +} from "../../utils/test-head"; +const { getSelectedSource, getSourceTabs } = selectors; + +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, { sourceId: "foo.js", 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")) + ); + await dispatch(actions.newGeneratedSource(makeSource("bar.js"))); + await dispatch(actions.selectLocation(cx, { sourceId: "foo.js", line: 1 })); + await dispatch(actions.selectLocation(cx, { sourceId: "bar.js", 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, { sourceId: "foo.js", 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); + + await dispatch(actions.newGeneratedSource(makeSource("foo.js"))); + const barSource = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + await dispatch(actions.selectLocation(cx, { sourceId: "foo.js", line: 1 })); + await dispatch(actions.selectLocation(cx, { sourceId: "bar.js", 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); + + await dispatch(actions.newGeneratedSource(makeSource("foo.js"))); + await dispatch(actions.newGeneratedSource(makeSource("bar.js"))); + await dispatch(actions.newGeneratedSource(makeSource("bazz.js"))); + await dispatch(actions.selectLocation(cx, { sourceId: "foo.js", line: 1 })); + await dispatch(actions.selectLocation(cx, { sourceId: "bar.js", line: 1 })); + await dispatch( + actions.selectLocation(cx, { sourceId: "bazz.js", 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); + + await dispatch(actions.newGeneratedSource(makeSource("foo.js"))); + await dispatch(actions.newGeneratedSource(makeSource("bar.js"))); + await dispatch(actions.newGeneratedSource(makeSource("bazz.js"))); + await dispatch(actions.selectLocation(cx, { sourceId: "foo.js", line: 1 })); + await dispatch(actions.selectLocation(cx, { sourceId: "bar.js", line: 1 })); + await dispatch( + actions.selectLocation(cx, { sourceId: "bazz.js", 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); + + await dispatch(actions.newGeneratedSource(makeSource("foo.js"))); + await dispatch(actions.newGeneratedSource(makeSource("bar.js"))); + await dispatch(actions.selectLocation(cx, { sourceId: "foo.js", line: 1 })); + await dispatch(actions.selectLocation(cx, { sourceId: "bar.js", 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..e0cce0225a --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/ui.spec.js @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { createStore, selectors, actions } from "../../utils/test-head"; + +const { + getActiveSearch, + getFrameworkGroupingState, + getPaneCollapse, + getHighlightedLineRange, +} = selectors; + +describe("ui", () => { + it("should toggle the visible state of project search", () => { + const { dispatch, getState } = createStore(); + expect(getActiveSearch(getState())).toBe(null); + dispatch(actions.setActiveSearch("project")); + expect(getActiveSearch(getState())).toBe("project"); + }); + + it("should close project search", () => { + const { dispatch, getState } = createStore(); + expect(getActiveSearch(getState())).toBe(null); + dispatch(actions.setActiveSearch("project")); + dispatch(actions.closeActiveSearch()); + expect(getActiveSearch(getState())).toBe(null); + }); + + 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", () => { + const { dispatch, getState } = createStore(); + const range = { start: 3, end: 5, sourceId: "2" }; + dispatch(actions.highlightLineRange(range)); + expect(getHighlightedLineRange(getState())).toEqual(range); + }); + + it("should clear highlight lines", () => { + const { dispatch, getState } = createStore(); + const range = { start: 3, end: 5, sourceId: "2" }; + dispatch(actions.highlightLineRange(range)); + dispatch(actions.clearHighlightLineRange()); + expect(getHighlightedLineRange(getState())).toEqual({}); + }); +}); diff --git a/devtools/client/debugger/src/actions/threads.js b/devtools/client/debugger/src/actions/threads.js new file mode 100644 index 0000000000..0fdcd16eca --- /dev/null +++ b/devtools/client/debugger/src/actions/threads.js @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { Target } from "../client/firefox/types"; +import type { Action, ThunkArgs } from "./types"; +import { removeSourceActors } from "./source-actors"; +import { validateContext } from "../utils/context"; + +import { getContext, getThread, getSourceActorsForThread } from "../selectors"; + +export function addTarget(targetFront: Target) { + return async function(args: ThunkArgs) { + const { client, getState, dispatch } = args; + const cx = getContext(getState()); + const thread = await client.addThread(targetFront); + validateContext(getState(), cx); + + dispatch(({ type: "INSERT_THREAD", cx, newThread: thread }: Action)); + }; +} + +export function removeTarget(targetFront: Target) { + return async function(args: ThunkArgs) { + const { getState, client, dispatch } = args; + const cx = getContext(getState()); + const thread = getThread(getState(), targetFront.targetForm.threadActor); + + if (!thread) { + return; + } + + client.removeThread(thread); + const sourceActors = getSourceActorsForThread(getState(), thread.actor); + dispatch(removeSourceActors(sourceActors)); + dispatch( + ({ + type: "REMOVE_THREAD", + cx, + oldThread: thread, + }: Action) + ); + }; +} + +export function toggleJavaScriptEnabled(enabled: Boolean) { + return async ({ panel, dispatch, client }: ThunkArgs) => { + 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..5d59344721 --- /dev/null +++ b/devtools/client/debugger/src/actions/toolbox.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/>. */ + +// @flow + +import type { ThunkArgs } from "./types"; +import type { Grip, URL } from "../types"; + +/** + * @memberof actions/toolbox + * @static + */ +export function openLink(url: URL) { + return async function({ panel }: ThunkArgs) { + return panel.openLink(url); + }; +} + +export function evaluateInConsole(inputString: string) { + return async ({ panel }: ThunkArgs) => { + return panel.openConsoleAndEvaluate(inputString); + }; +} + +export function openElementInInspectorCommand(grip: Grip) { + return async ({ panel }: ThunkArgs) => { + return panel.openElementInInspector(grip); + }; +} + +export function openInspector(grip: Grip) { + return async ({ panel }: ThunkArgs) => { + return panel.openInspector(); + }; +} + +export function highlightDomElement(grip: Grip) { + return async ({ panel }: ThunkArgs) => { + return panel.highlightDomElement(grip); + }; +} + +export function unHighlightDomElement(grip: Grip) { + return async ({ panel }: ThunkArgs) => { + return panel.unHighlightDomElement(grip); + }; +} diff --git a/devtools/client/debugger/src/actions/types/ASTAction.js b/devtools/client/debugger/src/actions/types/ASTAction.js new file mode 100644 index 0000000000..4edb39ef55 --- /dev/null +++ b/devtools/client/debugger/src/actions/types/ASTAction.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { SymbolDeclarations } from "../../workers/parser"; +import type { PromiseAction } from "../utils/middleware/promise"; +import type { Context, SourceLocation, SourceId } from "../../types"; + +export type ASTAction = + | PromiseAction< + {| + +type: "SET_SYMBOLS", + +cx: Context, + +sourceId: SourceId, + |}, + SymbolDeclarations + > + | {| + +type: "IN_SCOPE_LINES", + +cx: Context, + location: SourceLocation, + lines: Array<number>, + |}; diff --git a/devtools/client/debugger/src/actions/types/BreakpointAction.js b/devtools/client/debugger/src/actions/types/BreakpointAction.js new file mode 100644 index 0000000000..18cf1c33a3 --- /dev/null +++ b/devtools/client/debugger/src/actions/types/BreakpointAction.js @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { + Breakpoint, + SourceLocation, + XHRBreakpoint, + Source, + BreakpointPositions, + PendingLocation, + Context, +} from "../../types"; + +import type { PromiseAction } from "../utils/middleware/promise"; + +export type BreakpointAction = + | PromiseAction<{| + +type: "SET_XHR_BREAKPOINT", + +breakpoint: XHRBreakpoint, + |}> + | PromiseAction<{| + +type: "ENABLE_XHR_BREAKPOINT", + +breakpoint: XHRBreakpoint, + +index: number, + |}> + | PromiseAction<{| + +type: "UPDATE_XHR_BREAKPOINT", + +breakpoint: XHRBreakpoint, + +index: number, + |}> + | PromiseAction<{| + +type: "DISABLE_XHR_BREAKPOINT", + +breakpoint: XHRBreakpoint, + +index: number, + |}> + | PromiseAction<{| + +type: "REMOVE_XHR_BREAKPOINT", + +index: number, + +breakpoint: XHRBreakpoint, + |}> + | PromiseAction<{| + +type: "SET_BREAKPOINT", + +cx: Context, + +breakpoint: Breakpoint, + |}> + | PromiseAction<{| + +type: "REMOVE_BREAKPOINT", + +cx: Context, + +location: SourceLocation, + |}> + | PromiseAction<{| +type: "REMOVE_BREAKPOINTS" |}> + | {| + +type: "REMOVE_PENDING_BREAKPOINT", + +cx: Context, + +location: PendingLocation, + |} + | {| + type: "ADD_BREAKPOINT_POSITIONS", + +cx: Context, + positions: BreakpointPositions, + source: Source, + |}; diff --git a/devtools/client/debugger/src/actions/types/PauseAction.js b/devtools/client/debugger/src/actions/types/PauseAction.js new file mode 100644 index 0000000000..f4c0c96a21 --- /dev/null +++ b/devtools/client/debugger/src/actions/types/PauseAction.js @@ -0,0 +1,181 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { Command } from "../../reducers/types"; +import type { + Expression, + Frame, + Scope, + Why, + ThreadContext, + Previews, + HighlightedCalls, +} from "../../types"; + +import type { PromiseAction } from "../utils/middleware/promise"; + +export type PauseAction = + | {| + +type: "BREAK_ON_NEXT", + +cx: ThreadContext, + +thread: string, + +value: boolean, + |} + | {| + // Note: Do not include cx, as this action is triggered by the server. + +type: "RESUME", + +thread: string, + +value: void, + +wasStepping: boolean, + |} + | {| + // Note: Do not include cx, as this action is triggered by the server. + +type: "PAUSED", + +thread: string, + +why: Why, + +scopes: Scope, + +frame: Frame, + |} + | {| + type: "FETCHED_FRAMES", + frames: Frame[], + cx: ThreadContext, + thread: string, + |} + | {| + +type: "PAUSE_ON_EXCEPTIONS", + +shouldPauseOnExceptions: boolean, + +shouldPauseOnCaughtExceptions: boolean, + |} + | PromiseAction<{| + +type: "COMMAND", + +cx: ThreadContext, + +thread: string, + +command: Command, + |}> + | {| + +type: "SELECT_FRAME", + +cx: ThreadContext, + +thread: string, + +frame: Frame, + |} + | {| + +type: "SELECT_COMPONENT", + +thread: string, + +componentIndex: number, + |} + | {| + +type: "ADD_EXPRESSION", + +cx: ThreadContext, + +thread: string, + +id: number, + +input: string, + +value: string, + +expressionError: ?string, + |} + | PromiseAction< + {| + +type: "EVALUATE_EXPRESSION", + +cx: ThreadContext, + +thread: string, + +input: string, + |}, + Object + > + | PromiseAction<{| + +type: "EVALUATE_EXPRESSIONS", + +cx: ThreadContext, + +results: Expression[], + +inputs: string[], + |}> + | {| + +type: "UPDATE_EXPRESSION", + +cx: ThreadContext, + +expression: Expression, + +input: string, + +expressionError: ?string, + |} + | {| + +type: "DELETE_EXPRESSION", + +input: string, + |} + | {| + +type: "CLEAR_AUTOCOMPLETE", + |} + | {| + +type: "CLEAR_EXPRESSION_ERROR", + |} + | {| + +type: "AUTOCOMPLETE", + +cx: ThreadContext, + +input: string, + +result: Object, + |} + | PromiseAction< + {| + +type: "MAP_SCOPES", + +cx: ThreadContext, + +thread: string, + +frame: Frame, + |}, + { + scope: Scope, + mappings: { + [string]: string | null, + }, + } + > + | {| + +type: "MAP_FRAMES", + +cx: ThreadContext, + +thread: string, + +frames: Frame[], + +selectedFrameId: string, + |} + | {| + +type: "MAP_FRAME_DISPLAY_NAMES", + +cx: ThreadContext, + +thread: string, + +frames: Frame[], + |} + | PromiseAction< + {| + +type: "ADD_SCOPES", + +cx: ThreadContext, + +thread: string, + +frame: Frame, + |}, + Scope + > + | {| + +type: "TOGGLE_SKIP_PAUSING", + +thread: string, + skipPausing: boolean, + |} + | {| + +type: "TOGGLE_MAP_SCOPES", + +mapScopes: boolean, + |} + | {| + +type: "SET_EXPANDED_SCOPE", + +cx: ThreadContext, + +thread: string, + +path: string, + +expanded: boolean, + |} + | {| + +type: "ADD_INLINE_PREVIEW", + +thread: string, + +frame: Frame, + +previews: Previews, + |} + | {| + +type: "HIGHLIGHT_CALLS", + +highlightedCalls: HighlightedCalls, + |} + | {| + +type: "UNHIGHLIGHT_CALLS", + |}; diff --git a/devtools/client/debugger/src/actions/types/PreviewAction.js b/devtools/client/debugger/src/actions/types/PreviewAction.js new file mode 100644 index 0000000000..7b59897a15 --- /dev/null +++ b/devtools/client/debugger/src/actions/types/PreviewAction.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/>. */ + +// @flow + +import type { Context } from "../../types"; +import type { Preview } from "../../reducers/types"; + +export type PreviewAction = + | {| + +type: "SET_PREVIEW", + +cx: Context, + value: Preview, + |} + | {| + +type: "CLEAR_PREVIEW", + +cx: Context, + |} + | {| + +type: "START_PREVIEW", + |}; diff --git a/devtools/client/debugger/src/actions/types/SourceAction.js b/devtools/client/debugger/src/actions/types/SourceAction.js new file mode 100644 index 0000000000..0cf19e7c32 --- /dev/null +++ b/devtools/client/debugger/src/actions/types/SourceAction.js @@ -0,0 +1,103 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { + Source, + SourceId, + SourceLocation, + Context, + URL, + Exception, +} from "../../types"; +import type { PromiseAction } from "../utils/middleware/promise"; +import type { SourceBase } from "../../reducers/sources"; + +export type LoadSourceAction = PromiseAction< + {| + +type: "LOAD_SOURCE_TEXT", + +cx: Context, + +sourceId: SourceId, + +epoch: number, + |}, + { + text: string | {| binary: Object |}, + contentType: string | void, + } +>; +export type SourceAction = + | LoadSourceAction + | {| + +type: "ADD_SOURCE", + +cx: Context, + +source: SourceBase, + |} + | {| + +type: "ADD_SOURCES", + +cx: Context, + +sources: Array<SourceBase>, + |} + | {| + +type: "SET_SELECTED_LOCATION", + +cx: Context, + +source: Source, + +location?: SourceLocation, + |} + | {| + +type: "SET_PENDING_SELECTED_LOCATION", + +cx: Context, + +url: URL, + +line?: number, + +column?: number, + |} + | {| type: "CLEAR_SELECTED_LOCATION", +cx: Context |} + | PromiseAction< + {| + +type: "BLACKBOX", + +cx: Context, + +source: Source, + |}, + {| + +isBlackBoxed: boolean, + |} + > + | PromiseAction< + {| + +type: "BLACKBOX_SOURCES", + +cx: Context, + +shouldBlackBox: boolean, + |}, + {| + +sources: Source[], + |} + > + | {| + +type: "MOVE_TAB", + +url: URL, + +tabIndex: number, + |} + | {| + +type: "MOVE_TAB_BY_SOURCE_ID", + +sourceId: SourceId, + +tabIndex: number, + |} + | {| + +type: "CLOSE_TAB", + +source: Source, + |} + | {| + +type: "CLOSE_TABS", + +sources: Array<Source>, + |} + | {| + type: "SET_ORIGINAL_BREAKABLE_LINES", + +cx: Context, + breakableLines: number[], + sourceId: SourceId, + |} + | {| + type: "ADD_EXCEPTION", + exception: Exception, + |}; diff --git a/devtools/client/debugger/src/actions/types/SourceActorAction.js b/devtools/client/debugger/src/actions/types/SourceActorAction.js new file mode 100644 index 0000000000..a848a7972a --- /dev/null +++ b/devtools/client/debugger/src/actions/types/SourceActorAction.js @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { Context } from "../../types"; +import { type PromiseAction } from "../utils/middleware/promise"; +import type { + SourceActorId, + SourceActor, +} from "../../reducers/source-actors.js"; + +export type SourceActorsInsertAction = {| + type: "INSERT_SOURCE_ACTORS", + items: Array<SourceActor>, +|}; +export type SourceActorsRemoveAction = {| + type: "REMOVE_SOURCE_ACTORS", + items: Array<SourceActor>, +|}; +export type SourceActorClearMapAction = {| + +type: "CLEAR_SOURCE_ACTOR_MAP_URL", + +cx: Context, + +id: SourceActorId, +|}; + +export type SourceActorBreakpointColumnsAction = PromiseAction< + {| + type: "SET_SOURCE_ACTOR_BREAKPOINT_COLUMNS", + sourceId: SourceActorId, + line: number, + |}, + Array<number> +>; + +export type SourceActorBreakableLinesAction = PromiseAction< + {| + type: "SET_SOURCE_ACTOR_BREAKABLE_LINES", + sourceId: SourceActorId, + |}, + Array<number> +>; + +export type SourceActorAction = + | SourceActorsInsertAction + | SourceActorsRemoveAction + | SourceActorBreakpointColumnsAction + | SourceActorBreakableLinesAction + | SourceActorClearMapAction; diff --git a/devtools/client/debugger/src/actions/types/UIAction.js b/devtools/client/debugger/src/actions/types/UIAction.js new file mode 100644 index 0000000000..23165e07c5 --- /dev/null +++ b/devtools/client/debugger/src/actions/types/UIAction.js @@ -0,0 +1,101 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { Source, Range, SourceLocation, Context, URL } from "../../types"; + +import type { + ActiveSearchType, + OrientationType, + SelectedPrimaryPaneTabType, +} from "../../reducers/ui"; + +export type panelPositionType = "start" | "end"; + +export type UIAction = + | {| + +type: "TOGGLE_ACTIVE_SEARCH", + +value: ?ActiveSearchType, + |} + | {| + +type: "OPEN_QUICK_OPEN", + +query?: string, + |} + | {| + +type: "CLOSE_QUICK_OPEN", + |} + | {| + +type: "TOGGLE_FRAMEWORK_GROUPING", + +value: boolean, + |} + | {| + +type: "TOGGLE_INLINE_PREVIEW", + +value: boolean, + |} + | {| + +type: "TOGGLE_EDITOR_WRAPPING", + +value: boolean, + |} + | {| + +type: "TOGGLE_SOURCE_MAPS_ENABLED", + +value: boolean, + |} + | {| + +type: "TOGGLE_JAVASCRIPT_ENABLED", + +value: boolean, + |} + | {| + +type: "SHOW_SOURCE", + +source: Source, + |} + | {| + +type: "TOGGLE_PANE", + +position: panelPositionType, + +paneCollapsed: boolean, + |} + | {| + +type: "SET_ORIENTATION", + +orientation: OrientationType, + |} + | {| + +type: "HIGHLIGHT_LINES", + +location: { + start: number, + end: number, + sourceId: number, + }, + |} + | {| + +type: "CLEAR_HIGHLIGHT_LINES", + |} + | {| + +type: "OPEN_CONDITIONAL_PANEL", + +location: SourceLocation, + +log: boolean, + |} + | {| + +type: "CLOSE_CONDITIONAL_PANEL", + |} + | {| + +type: "SET_PROJECT_DIRECTORY_ROOT", + +cx: Context, + +url: URL, + +name: string, + |} + | {| + +type: "SET_PRIMARY_PANE_TAB", + +tabName: SelectedPrimaryPaneTabType, + |} + | {| + +type: "CLOSE_PROJECT_SEARCH", + |} + | {| + +type: "SET_VIEWPORT", + +viewport: Range, + |} + | {| + +type: "SET_CURSOR_POSITION", + +cursorPosition: SourceLocation, + |}; diff --git a/devtools/client/debugger/src/actions/types/index.js b/devtools/client/debugger/src/actions/types/index.js new file mode 100644 index 0000000000..1e2d927f9f --- /dev/null +++ b/devtools/client/debugger/src/actions/types/index.js @@ -0,0 +1,238 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import typeof SourceMaps from "devtools-source-map"; +import type { + Thread, + Context, + ThreadId, + SourceId, + SourceLocation, + URL, +} from "../../types"; +import type { State } from "../../reducers/types"; +import type { MatchedLocations } from "../../reducers/file-search"; +import type { TreeNode } from "../../utils/sources-tree/types"; +import type { + SearchOperation, + StatusType, +} from "../../reducers/project-text-search"; + +import type { BreakpointAction } from "./BreakpointAction"; +import type { SourceAction } from "./SourceAction"; +import type { SourceActorAction } from "./SourceActorAction"; +import type { UIAction } from "./UIAction"; +import type { PauseAction } from "./PauseAction"; +import type { PreviewAction } from "./PreviewAction"; +import type { ASTAction } from "./ASTAction"; +import { clientCommands } from "../../client/firefox"; +import type { Panel } from "../../client/firefox/types"; +import type { ParserDispatcher } from "../../workers/parser"; + +/** + * Flow types + * @module actions/types + */ + +/** + * Argument parameters via Thunk middleware for {@link https://github.com/gaearon/redux-thunk|Redux Thunk} + * + * @memberof actions/breakpoints + * @static + * @typedef {Object} ThunkArgs + */ +export type ThunkArgs = { + dispatch: (action: any) => Promise<any>, + forkedDispatch: (action: any) => Promise<any>, + getState: () => State, + client: typeof clientCommands, + sourceMaps: SourceMaps, + parser: ParserDispatcher, + evaluationsParser: ParserDispatcher, + panel: Panel, +}; + +export type Thunk = ThunkArgs => any; + +export type ActionType = Object | Function; + +type ProjectTextSearchResult = { + sourceId: SourceId, + filepath: string, + matches: MatchedLocations[], +}; + +type AddTabAction = {| + +type: "ADD_TAB", + +url: URL, + +framework?: string, + +isOriginal?: boolean, + +sourceId: SourceId, +|}; + +type UpdateTabAction = {| + +type: "UPDATE_TAB", + +url: URL, + +framework?: string, + +isOriginal?: boolean, + +sourceId: SourceId, +|}; + +type NavigateAction = + | {| + +type: "CONNECT", + +traits: Object, + +isWebExtension: boolean, + +mainThreadActorID: ThreadId, + |} + | {| +type: "NAVIGATE", +mainThread: Thread |}; + +export type FocusItem = TreeNode; + +export type SourceTreeAction = + | {| +type: "SET_EXPANDED_STATE", +thread: string, +expanded: any |} + | {| +type: "SET_FOCUSED_SOURCE_ITEM", +cx: Context, item: FocusItem |}; + +export type ProjectTextSearchAction = + | {| +type: "ADD_QUERY", +cx: Context, +query: string |} + | {| + +type: "ADD_SEARCH_RESULT", + +cx: Context, + +result: ProjectTextSearchResult, + |} + | {| +type: "UPDATE_STATUS", +cx: Context, +status: StatusType |} + | {| +type: "CLEAR_SEARCH_RESULTS", +cx: Context |} + | {| + +type: "ADD_ONGOING_SEARCH", + +cx: Context, + +ongoingSearch: SearchOperation, + |} + | {| +type: "CLEAR_SEARCH", +cx: Context |}; + +export type FileTextSearchModifier = + | "caseSensitive" + | "wholeWord" + | "regexMatch"; + +export type FileTextSearchAction = + | {| + +type: "TOGGLE_FILE_SEARCH_MODIFIER", + +cx: Context, + +modifier: FileTextSearchModifier, + |} + | {| + +type: "UPDATE_FILE_SEARCH_QUERY", + +cx: Context, + +query: string, + |} + | {| + +type: "UPDATE_SEARCH_RESULTS", + +cx: Context, + +results: { + matches: MatchedLocations[], + matchIndex: number, + count: number, + index: number, + }, + |}; + +export type QuickOpenAction = + | {| +type: "SET_QUICK_OPEN_QUERY", +query: string |} + | {| +type: "OPEN_QUICK_OPEN", +query?: string |} + | {| +type: "CLOSE_QUICK_OPEN" |}; + +export type ThreadsAction = + | {| + +type: "INSERT_THREAD", + +cx: Context, + +newThread: Thread, + |} + | {| + +type: "REMOVE_THREAD", + +cx: Context, + +oldThread: Thread, + |} + | {| + +type: "UPDATE_SERVICE_WORKER_STATUS", + +cx: Context, + +thread: string, + +status: string, + |} + | {| + +type: "SELECT_THREAD", + +cx: Context, + +thread: ThreadId, + |} + | {| + +type: "PREVIEW_PAUSED_LOCATION", + +location: SourceLocation, + |} + | {| + +type: "CLEAR_PREVIEW_PAUSED_LOCATION", + |}; + +export type { + StartPromiseAction, + DonePromiseAction, + ErrorPromiseAction, +} from "../utils/middleware/promise"; + +export type { panelPositionType } from "./UIAction"; + +export type { ASTAction } from "./ASTAction"; + +type ActiveEventListener = string; +export type EventListenerEvent = { name: string, id: ActiveEventListener }; +export type EventListenerCategory = { + name: string, + events: EventListenerEvent[], +}; + +export type EventListenerActiveList = ActiveEventListener[]; +export type EventListenerCategoryList = EventListenerCategory[]; +export type EventListenerExpandedList = string[]; + +export type EventListenerAction = + | {| + +type: "UPDATE_EVENT_LISTENERS", + +active: EventListenerActiveList, + |} + | {| + +type: "RECEIVE_EVENT_LISTENER_TYPES", + +categories: EventListenerCategoryList, + |} + | {| + +type: "UPDATE_EVENT_LISTENER_EXPANDED", + +expanded: EventListenerExpandedList, + |} + | {| + +type: "TOGGLE_EVENT_LISTENERS", + +logEventBreakpoints: boolean, + |}; + +/** + * Actions: Source, Breakpoint, and Navigation + * + * @memberof actions/types + * @static + */ +export type Action = + | AddTabAction + | UpdateTabAction + | SourceActorAction + | SourceAction + | BreakpointAction + | PauseAction + | NavigateAction + | UIAction + | ASTAction + | PreviewAction + | QuickOpenAction + | FileTextSearchAction + | ProjectTextSearchAction + | ThreadsAction + | SourceTreeAction + | EventListenerAction; diff --git a/devtools/client/debugger/src/actions/ui.js b/devtools/client/debugger/src/actions/ui.js new file mode 100644 index 0000000000..acf63446ae --- /dev/null +++ b/devtools/client/debugger/src/actions/ui.js @@ -0,0 +1,282 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + getActiveSearch, + getPaneCollapse, + getQuickOpenEnabled, + getSource, + getSourceContent, + startsWithThreadActor, + getFileSearchQuery, + getProjectDirectoryRoot, +} from "../selectors"; +import { selectSource } from "../actions/sources/select"; +import type { ThunkArgs, panelPositionType } from "./types"; +import { + getEditor, + getLocationsInViewport, + updateDocuments, +} from "../utils/editor"; +import { searchContents } from "./file-search"; +import { copyToTheClipboard } from "../utils/clipboard"; +import { isFulfilled } from "../utils/async-value"; + +import type { SourceLocation, Context, Source, SourceId } from "../types"; +import type { + ActiveSearchType, + OrientationType, + SelectedPrimaryPaneTabType, +} from "../reducers/ui"; +import type { UIAction } from "./types/UIAction"; + +export function setPrimaryPaneTab( + tabName: SelectedPrimaryPaneTabType +): UIAction { + return { type: "SET_PRIMARY_PANE_TAB", tabName }; +} + +export function closeActiveSearch(): UIAction { + return { + type: "TOGGLE_ACTIVE_SEARCH", + value: null, + }; +} + +export function setActiveSearch(activeSearch?: ActiveSearchType) { + return ({ dispatch, getState }: ThunkArgs) => { + const activeSearchState = getActiveSearch(getState()); + if (activeSearchState === activeSearch) { + return; + } + + if (getQuickOpenEnabled(getState())) { + dispatch({ type: "CLOSE_QUICK_OPEN" }); + } + + dispatch({ + type: "TOGGLE_ACTIVE_SEARCH", + value: activeSearch, + }); + }; +} + +export function updateActiveFileSearch(cx: Context) { + return ({ dispatch, getState }: ThunkArgs) => { + const isFileSearchOpen = getActiveSearch(getState()) === "file"; + const fileSearchQuery = getFileSearchQuery(getState()); + if (isFileSearchOpen && fileSearchQuery) { + const editor = getEditor(); + dispatch(searchContents(cx, fileSearchQuery, editor, false)); + } + }; +} + +export function toggleFrameworkGrouping(toggleValue: boolean) { + return ({ dispatch, getState }: ThunkArgs) => { + dispatch({ + type: "TOGGLE_FRAMEWORK_GROUPING", + value: toggleValue, + }); + }; +} + +export function toggleInlinePreview(toggleValue: boolean) { + return ({ dispatch, getState }: ThunkArgs) => { + dispatch({ + type: "TOGGLE_INLINE_PREVIEW", + value: toggleValue, + }); + }; +} + +export function toggleEditorWrapping(toggleValue: boolean) { + return ({ dispatch, getState }: ThunkArgs) => { + updateDocuments(doc => doc.cm.setOption("lineWrapping", toggleValue)); + + dispatch({ + type: "TOGGLE_EDITOR_WRAPPING", + value: toggleValue, + }); + }; +} + +export function toggleSourceMapsEnabled(toggleValue: boolean) { + return ({ dispatch, getState }: ThunkArgs) => { + dispatch({ + type: "TOGGLE_SOURCE_MAPS_ENABLED", + value: toggleValue, + }); + }; +} + +export function showSource(cx: Context, sourceId: SourceId) { + return ({ dispatch, getState }: ThunkArgs) => { + const source = getSource(getState(), sourceId); + if (!source) { + return; + } + + if (getPaneCollapse(getState(), "start")) { + dispatch({ + type: "TOGGLE_PANE", + position: "start", + paneCollapsed: false, + }); + } + + dispatch(setPrimaryPaneTab("sources")); + + dispatch({ type: "SHOW_SOURCE", source: null }); + dispatch(selectSource(cx, source.id)); + dispatch({ type: "SHOW_SOURCE", source }); + }; +} + +export function togglePaneCollapse( + position: panelPositionType, + paneCollapsed: boolean +) { + return ({ dispatch, getState }: ThunkArgs) => { + const prevPaneCollapse = getPaneCollapse(getState(), position); + if (prevPaneCollapse === paneCollapsed) { + return; + } + + dispatch({ + type: "TOGGLE_PANE", + position, + paneCollapsed, + }); + }; +} + +/** + * @memberof actions/sources + * @static + */ +export function highlightLineRange(location: { + start: number, + end: number, + sourceId: SourceId, +}) { + return { + type: "HIGHLIGHT_LINES", + location, + }; +} + +export function flashLineRange(location: { + start: number, + end: number, + sourceId: SourceId, +}) { + return ({ dispatch }: ThunkArgs) => { + dispatch(highlightLineRange(location)); + setTimeout(() => dispatch(clearHighlightLineRange()), 200); + }; +} + +/** + * @memberof actions/sources + * @static + */ +export function clearHighlightLineRange(): UIAction { + return { + type: "CLEAR_HIGHLIGHT_LINES", + }; +} + +export function openConditionalPanel( + location: ?SourceLocation, + log: boolean = false +): ?UIAction { + if (!location) { + return; + } + + return { + type: "OPEN_CONDITIONAL_PANEL", + location, + log, + }; +} + +export function closeConditionalPanel(): UIAction { + return { + type: "CLOSE_CONDITIONAL_PANEL", + }; +} + +export function clearProjectDirectoryRoot(cx: Context): UIAction { + return { + type: "SET_PROJECT_DIRECTORY_ROOT", + cx, + url: "", + name: "", + }; +} + +export function setProjectDirectoryRoot( + cx: Context, + newRoot: string, + newName: string +) { + return ({ dispatch, getState }: ThunkArgs) => { + const threadActor = startsWithThreadActor(getState(), newRoot); + + let curRoot = getProjectDirectoryRoot(getState()); + + // Remove the thread actor ID from the root path + if (threadActor) { + newRoot = newRoot.slice(threadActor.length + 1); + curRoot = curRoot.slice(threadActor.length + 1); + } + + if (newRoot && curRoot) { + const newRootArr = newRoot.replace(/\/+/g, "/").split("/"); + const curRootArr = curRoot + .replace(/^\//, "") + .replace(/\/+/g, "/") + .split("/"); + if (newRootArr[0] !== curRootArr[0]) { + newRootArr.splice(0, 2); + newRoot = `${curRoot}/${newRootArr.join("/")}`; + } + } + + dispatch({ + type: "SET_PROJECT_DIRECTORY_ROOT", + cx, + url: newRoot, + name: newName, + }); + }; +} + +export function updateViewport(): UIAction { + return { + type: "SET_VIEWPORT", + viewport: getLocationsInViewport(getEditor()), + }; +} + +export function updateCursorPosition(cursorPosition: SourceLocation): UIAction { + return { type: "SET_CURSOR_POSITION", cursorPosition }; +} + +export function setOrientation(orientation: OrientationType): UIAction { + return { type: "SET_ORIENTATION", orientation }; +} + +export function copyToClipboard(source: Source) { + return ({ dispatch, getState }: ThunkArgs) => { + const content = getSourceContent(getState(), source.id); + if (content && isFulfilled(content) && content.value.type === "text") { + copyToTheClipboard(content.value.value); + } + }; +} 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..fbfe7b9d3c --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/create-store.js @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/* global window */ + +/** + * Redux store utils + * @module utils/create-store + */ + +import { createStore, applyMiddleware, type StoreCreator } 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 + */ +type ReduxStoreOptions = { + makeThunkArgs?: Function, + history?: Array<Object>, + middleware?: Function[], + log?: boolean, + timing?: boolean, +}; + +/** + * 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: ReduxStoreOptions = {} +): StoreCreator<any, any, any> => { + const middleware: any = [ + 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..4a9ee09024 --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/context.js @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + validateNavigateContext, + validateContext, +} from "../../../utils/context"; + +import type { ThunkArgs } from "../../types"; + +function validateActionContext(getState, action): void { + 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 }: ThunkArgs) { + return (next: Function) => (action: Object) => { + 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..3b688c090f --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/log.js @@ -0,0 +1,116 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +// $FlowIgnore +import flags from "devtools/shared/flags"; +import type { ThunkArgs } from "../../types"; +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: any) { + 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 }: ThunkArgs) { + return (next: any) => (action: any) => { + const asyncMsg = !action.status ? "" : `[${action.status}]`; + + if (prefs.logActions) { + if (flags.testing) { + // $FlowIgnore + 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..46c73c7325 --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/promise.js @@ -0,0 +1,122 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { fromPairs, toPairs } from "lodash"; +import { executeSoon } from "../../../utils/DevToolsUtils"; +import type { ThunkArgs } from "../../types"; + +type BasePromiseAction = {| + +"@@dispatch/promise": Promise<mixed>, +|}; + +export type StartPromiseAction = {| + ...BasePromiseAction, + +status: "start", +|}; + +export type DonePromiseAction = {| + ...BasePromiseAction, + +status: "done", + +value: any, +|}; + +export type ErrorPromiseAction = {| + ...BasePromiseAction, + +status: "error", + +error: any, +|}; + +import { + pending, + rejected, + fulfilled, + type AsyncValue, +} from "../../../utils/async-value"; +export function asyncActionAsValue<T>( + action: PromiseAction<mixed, T> +): AsyncValue<T> { + if (action.status === "start") { + return pending(); + } + if (action.status === "error") { + return rejected(action.error); + } + return fulfilled(action.value); +} + +export type PromiseAction<+Action, Value = any> = + // | {| ...Action, "@@dispatch/promise": Promise<Object> |} + | {| + ...BasePromiseAction, + ...Action, + +status: "start", + value: void, + |} + | {| + ...BasePromiseAction, + ...Action, + +status: "done", + +value: Value, + |} + | {| + ...BasePromiseAction, + ...Action, + +status: "error", + +error?: any, + value: void, + |}; + +let seqIdVal = 1; + +function seqIdGen() { + return seqIdVal++; +} + +function filterAction(action: Object): Object { + return fromPairs(toPairs(action).filter(pair => pair[0] !== PROMISE)); +} + +function promiseMiddleware({ + dispatch, + getState, +}: ThunkArgs): Function | Promise<mixed> { + return (next: Function) => (action: Object) => { + if (!(PROMISE in action)) { + return next(action); + } + + const promiseInst = action[PROMISE]; + const seqId = seqIdGen().toString(); + + // Create a new action that doesn't have the promise field and has + // the `seqId` field that represents the sequence id + action = { ...filterAction(action), 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..a4d63f80e7 --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/thunk.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/>. */ + +// @flow + +import type { ThunkArgs, ActionType } from "../../types"; + +/** + * 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: any) { + return ({ dispatch, getState }: ThunkArgs) => { + const args = { dispatch, getState }; + + return (next: Function) => (action: ActionType) => { + 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..84661efb57 --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/timing.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/>. */ + +// @flow + +/** + * 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: any) { + return (next: any) => (action: any) => { + 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..0d83eee91c --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/wait-service.js @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * 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"; +import type { ThunkArgs } from "../../types"; + +export function waitUntilService({ dispatch, getState }: ThunkArgs) { + 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: Function) => (action: Object) => { + 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", +) |