summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/actions/sources
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/debugger/src/actions/sources')
-rw-r--r--devtools/client/debugger/src/actions/sources/blackbox.js223
-rw-r--r--devtools/client/debugger/src/actions/sources/breakableLines.js73
-rw-r--r--devtools/client/debugger/src/actions/sources/index.js42
-rw-r--r--devtools/client/debugger/src/actions/sources/loadSourceText.js256
-rw-r--r--devtools/client/debugger/src/actions/sources/moz.build17
-rw-r--r--devtools/client/debugger/src/actions/sources/newSources.js367
-rw-r--r--devtools/client/debugger/src/actions/sources/prettyPrint.js339
-rw-r--r--devtools/client/debugger/src/actions/sources/select.js264
-rw-r--r--devtools/client/debugger/src/actions/sources/symbols.js44
-rw-r--r--devtools/client/debugger/src/actions/sources/tests/blackbox.spec.js249
-rw-r--r--devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js363
-rw-r--r--devtools/client/debugger/src/actions/sources/tests/newSources.spec.js172
-rw-r--r--devtools/client/debugger/src/actions/sources/tests/select.spec.js288
13 files changed, 2697 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/actions/sources/blackbox.js b/devtools/client/debugger/src/actions/sources/blackbox.js
new file mode 100644
index 0000000000..6821a0e140
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/blackbox.js
@@ -0,0 +1,223 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Redux actions for the sources state
+ * @module actions/sources
+ */
+
+import {
+ isOriginalId,
+ originalToGeneratedId,
+} from "devtools/client/shared/source-map-loader/index";
+import { recordEvent } from "../../utils/telemetry";
+import { toggleBreakpoints } from "../breakpoints";
+import {
+ getSourceActorsForSource,
+ isSourceBlackBoxed,
+ getBlackBoxRanges,
+ getBreakpointsForSource,
+} from "../../selectors";
+
+export async function blackboxSourceActorsForSource(
+ thunkArgs,
+ source,
+ shouldBlackBox,
+ ranges = []
+) {
+ const { getState, client, sourceMapLoader } = thunkArgs;
+ let sourceId = source.id;
+ // If the source is the original, then get the source id of its generated file
+ // and the range for where the original is represented in the generated file
+ // (which might be a bundle including other files).
+ if (isOriginalId(source.id)) {
+ sourceId = originalToGeneratedId(source.id);
+ const range = await sourceMapLoader.getFileGeneratedRange(source.id);
+ ranges = [];
+ if (range) {
+ ranges.push(range);
+ // TODO bug 1752108: Investigate blackboxing lines in original files,
+ // there is likely to be issues as the whole genrated file
+ // representing the original file will always be blackboxed.
+ console.warn(
+ "The might be unxpected issues when ignoring lines in an original file. " +
+ "The whole original source is being blackboxed."
+ );
+ } else {
+ throw new Error(
+ `Unable to retrieve generated ranges for original source ${source.url}`
+ );
+ }
+ }
+
+ for (const actor of getSourceActorsForSource(getState(), sourceId)) {
+ await client.blackBox(actor, shouldBlackBox, ranges);
+ }
+}
+
+/**
+ * Toggle blackboxing for the whole source or for specific lines in a source
+ *
+ * @param {Object} cx
+ * @param {Object} source - The source to be blackboxed/unblackboxed.
+ * @param {Boolean} [shouldBlackBox] - Specifies if the source should be blackboxed (true
+ * or unblackboxed (false). When this is not provided
+ * option is decided based on the blackboxed state
+ * of the source.
+ * @param {Array} [ranges] - List of line/column offsets to blackbox, these
+ * are provided only when blackboxing lines.
+ * The range structure:
+ * const range = {
+ * start: { line: 1, column: 5 },
+ * end: { line: 3, column: 4 },
+ * }
+ */
+export function toggleBlackBox(cx, source, shouldBlackBox, ranges = []) {
+ return async thunkArgs => {
+ const { dispatch, getState } = thunkArgs;
+
+ shouldBlackBox =
+ typeof shouldBlackBox == "boolean"
+ ? shouldBlackBox
+ : !isSourceBlackBoxed(getState(), source);
+
+ await blackboxSourceActorsForSource(
+ thunkArgs,
+ source,
+ shouldBlackBox,
+ ranges
+ );
+
+ if (shouldBlackBox) {
+ recordEvent("blackbox");
+ // If ranges is an empty array, it would mean we are blackboxing the whole
+ // source. To do that lets reset the content to an empty array.
+ if (!ranges.length) {
+ dispatch({ type: "BLACKBOX_WHOLE_SOURCES", sources: [source] });
+ await toggleBreakpointsInBlackboxedSources({
+ thunkArgs,
+ cx,
+ shouldDisable: true,
+ sources: [source],
+ });
+ } else {
+ const currentRanges = getBlackBoxRanges(getState())[source.url] || [];
+ ranges = ranges.filter(newRange => {
+ // To avoid adding duplicate ranges make sure
+ // no range already exists with same start and end lines.
+ const duplicate = currentRanges.findIndex(
+ r =>
+ r.start.line == newRange.start.line &&
+ r.end.line == newRange.end.line
+ );
+ return duplicate == -1;
+ });
+ dispatch({ type: "BLACKBOX_SOURCE_RANGES", source, ranges });
+ await toggleBreakpointsInRangesForBlackboxedSource({
+ thunkArgs,
+ cx,
+ shouldDisable: true,
+ source,
+ ranges,
+ });
+ }
+ } else {
+ // if there are no ranges to blackbox, then we are unblackboxing
+ // the whole source
+ // eslint-disable-next-line no-lonely-if
+ if (!ranges.length) {
+ dispatch({ type: "UNBLACKBOX_WHOLE_SOURCES", sources: [source] });
+ toggleBreakpointsInBlackboxedSources({
+ thunkArgs,
+ cx,
+ shouldDisable: false,
+ sources: [source],
+ });
+ } else {
+ dispatch({ type: "UNBLACKBOX_SOURCE_RANGES", source, ranges });
+ const blackboxRanges = getBlackBoxRanges(getState());
+ if (!blackboxRanges[source.url].length) {
+ dispatch({ type: "UNBLACKBOX_WHOLE_SOURCES", sources: [source] });
+ }
+ await toggleBreakpointsInRangesForBlackboxedSource({
+ thunkArgs,
+ cx,
+ shouldDisable: false,
+ source,
+ ranges,
+ });
+ }
+ }
+ };
+}
+
+async function toggleBreakpointsInRangesForBlackboxedSource({
+ thunkArgs,
+ cx,
+ shouldDisable,
+ source,
+ ranges,
+}) {
+ const { dispatch, getState } = thunkArgs;
+ for (const range of ranges) {
+ const breakpoints = getBreakpointsForSource(getState(), source.id, range);
+ await dispatch(toggleBreakpoints(cx, shouldDisable, breakpoints));
+ }
+}
+
+async function toggleBreakpointsInBlackboxedSources({
+ thunkArgs,
+ cx,
+ shouldDisable,
+ sources,
+}) {
+ const { dispatch, getState } = thunkArgs;
+ for (const source of sources) {
+ const breakpoints = getBreakpointsForSource(getState(), source.id);
+ await dispatch(toggleBreakpoints(cx, shouldDisable, breakpoints));
+ }
+}
+
+/*
+ * Blackboxes a group of sources together
+ *
+ * @param {Object} cx
+ * @param {Array} sourcesToBlackBox - The list of sources to blackbox
+ * @param {Boolean} shouldBlackbox - Specifies if the sources should blackboxed (true)
+ * or unblackboxed (false).
+ */
+export function blackBoxSources(cx, sourcesToBlackBox, shouldBlackBox) {
+ return async thunkArgs => {
+ const { dispatch, getState } = thunkArgs;
+
+ const sources = sourcesToBlackBox.filter(
+ source => isSourceBlackBoxed(getState(), source) !== shouldBlackBox
+ );
+
+ if (!sources.length) {
+ return;
+ }
+
+ for (const source of sources) {
+ await blackboxSourceActorsForSource(thunkArgs, source, shouldBlackBox);
+ }
+
+ if (shouldBlackBox) {
+ recordEvent("blackbox");
+ }
+
+ dispatch({
+ type: shouldBlackBox
+ ? "BLACKBOX_WHOLE_SOURCES"
+ : "UNBLACKBOX_WHOLE_SOURCES",
+ sources,
+ });
+ await toggleBreakpointsInBlackboxedSources({
+ thunkArgs,
+ cx,
+ shouldDisable: shouldBlackBox,
+ sources,
+ });
+ };
+}
diff --git a/devtools/client/debugger/src/actions/sources/breakableLines.js b/devtools/client/debugger/src/actions/sources/breakableLines.js
new file mode 100644
index 0000000000..d028d480c0
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/breakableLines.js
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { isOriginalId } from "devtools/client/shared/source-map-loader/index";
+import {
+ getBreakableLines,
+ getSourceActorBreakableLines,
+} from "../../selectors";
+import { setBreakpointPositions } from "../breakpoints/breakpointPositions";
+
+function calculateBreakableLines(positions) {
+ const lines = [];
+ for (const line in positions) {
+ if (positions[line].length) {
+ lines.push(Number(line));
+ }
+ }
+
+ return lines;
+}
+
+/**
+ * Ensure that breakable lines for a given source are fetched.
+ *
+ * @param Object cx
+ * @param Object location
+ */
+export function setBreakableLines(cx, location) {
+ return async ({ getState, dispatch, client }) => {
+ let breakableLines;
+ if (isOriginalId(location.source.id)) {
+ const positions = await dispatch(
+ setBreakpointPositions({ cx, location })
+ );
+ breakableLines = calculateBreakableLines(positions);
+
+ const existingBreakableLines = getBreakableLines(
+ getState(),
+ location.source.id
+ );
+ if (existingBreakableLines) {
+ breakableLines = [
+ ...new Set([...existingBreakableLines, ...breakableLines]),
+ ];
+ }
+
+ dispatch({
+ type: "SET_ORIGINAL_BREAKABLE_LINES",
+ cx,
+ sourceId: location.source.id,
+ breakableLines,
+ });
+ } else {
+ // Ignore re-fetching the breakable lines for source actor we already fetched
+ breakableLines = getSourceActorBreakableLines(
+ getState(),
+ location.sourceActor.id
+ );
+ if (breakableLines) {
+ return;
+ }
+ breakableLines = await client.getSourceActorBreakableLines(
+ location.sourceActor
+ );
+ dispatch({
+ type: "SET_SOURCE_ACTOR_BREAKABLE_LINES",
+ sourceActorId: location.sourceActor.id,
+ breakableLines,
+ });
+ }
+ };
+}
diff --git a/devtools/client/debugger/src/actions/sources/index.js b/devtools/client/debugger/src/actions/sources/index.js
new file mode 100644
index 0000000000..813f50262b
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/index.js
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+export * from "./blackbox";
+export * from "./breakableLines";
+export * from "./loadSourceText";
+export * from "./newSources";
+export * from "./prettyPrint";
+export * from "./select";
+export { setSymbols } from "./symbols";
+
+export function setOverrideSource(cx, source, path) {
+ return ({ client, dispatch }) => {
+ if (!source || !source.url) {
+ return;
+ }
+ const { url } = source;
+ client.setOverride(url, path);
+ dispatch({
+ type: "SET_OVERRIDE",
+ cx,
+ url,
+ path,
+ });
+ };
+}
+
+export function removeOverrideSource(cx, source) {
+ return ({ client, dispatch }) => {
+ if (!source || !source.url) {
+ return;
+ }
+ const { url } = source;
+ client.removeOverride(url);
+ dispatch({
+ type: "REMOVE_OVERRIDE",
+ cx,
+ url,
+ });
+ };
+}
diff --git a/devtools/client/debugger/src/actions/sources/loadSourceText.js b/devtools/client/debugger/src/actions/sources/loadSourceText.js
new file mode 100644
index 0000000000..8210b07a97
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/loadSourceText.js
@@ -0,0 +1,256 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { PROMISE } from "../utils/middleware/promise";
+import {
+ getSourceTextContent,
+ getSettledSourceTextContent,
+ getGeneratedSource,
+ getSourcesEpoch,
+ getBreakpointsForSource,
+ getSourceActorsForSource,
+ getFirstSourceActorForGeneratedSource,
+} from "../../selectors";
+import { addBreakpoint } from "../breakpoints";
+
+import { prettyPrintSource } from "./prettyPrint";
+import { isFulfilled, fulfilled } from "../../utils/async-value";
+
+import { isPretty } from "../../utils/source";
+import { createLocation } from "../../utils/location";
+import { memoizeableAction } from "../../utils/memoizableAction";
+
+async function loadGeneratedSource(sourceActor, { client }) {
+ // If no source actor can be found then the text for the
+ // source cannot be loaded.
+ if (!sourceActor) {
+ throw new Error("Source actor is null or not defined");
+ }
+
+ let response;
+ try {
+ response = await client.sourceContents(sourceActor);
+ } catch (e) {
+ throw new Error(`sourceContents failed: ${e}`);
+ }
+
+ return {
+ text: response.source,
+ contentType: response.contentType || "text/javascript",
+ };
+}
+
+async function loadOriginalSource(
+ source,
+ { getState, client, sourceMapLoader, prettyPrintWorker }
+) {
+ if (isPretty(source)) {
+ const generatedSource = getGeneratedSource(getState(), source);
+ if (!generatedSource) {
+ throw new Error("Unable to find minified original.");
+ }
+
+ const content = getSettledSourceTextContent(
+ getState(),
+ createLocation({
+ source: generatedSource,
+ })
+ );
+
+ return prettyPrintSource(
+ sourceMapLoader,
+ prettyPrintWorker,
+ generatedSource,
+ content,
+ getSourceActorsForSource(getState(), generatedSource.id)
+ );
+ }
+
+ const result = await sourceMapLoader.getOriginalSourceText(source.id);
+ if (!result) {
+ // The way we currently try to load and select a pending
+ // selected location, it is possible that we will try to fetch the
+ // original source text right after the source map has been cleared
+ // after a navigation event.
+ throw new Error("Original source text unavailable");
+ }
+ return result;
+}
+
+async function loadGeneratedSourceTextPromise(cx, sourceActor, thunkArgs) {
+ const { dispatch, getState } = thunkArgs;
+ const epoch = getSourcesEpoch(getState());
+
+ await dispatch({
+ type: "LOAD_GENERATED_SOURCE_TEXT",
+ sourceActorId: sourceActor.actor,
+ epoch,
+ [PROMISE]: loadGeneratedSource(sourceActor, thunkArgs),
+ });
+
+ await onSourceTextContentAvailable(
+ cx,
+ sourceActor.sourceObject,
+ sourceActor,
+ thunkArgs
+ );
+}
+
+async function loadOriginalSourceTextPromise(cx, source, thunkArgs) {
+ const { dispatch, getState } = thunkArgs;
+ const epoch = getSourcesEpoch(getState());
+ await dispatch({
+ type: "LOAD_ORIGINAL_SOURCE_TEXT",
+ sourceId: source.id,
+ epoch,
+ [PROMISE]: loadOriginalSource(source, thunkArgs),
+ });
+
+ await onSourceTextContentAvailable(cx, source, null, thunkArgs);
+}
+
+/**
+ * Function called everytime a new original or generated source gets its text content
+ * fetched from the server and registered in the reducer.
+ *
+ * @param {Object} cx
+ * @param {Object} source
+ * @param {Object} sourceActor (optional)
+ * If this is a generated source, we expect a precise source actor.
+ * @param {Object} thunkArgs
+ */
+async function onSourceTextContentAvailable(
+ cx,
+ source,
+ sourceActor,
+ { dispatch, getState, parserWorker }
+) {
+ const location = createLocation({
+ source,
+ sourceActor,
+ });
+ const content = getSettledSourceTextContent(getState(), location);
+ if (!content) {
+ return;
+ }
+
+ if (parserWorker.isLocationSupported(location)) {
+ parserWorker.setSource(
+ source.id,
+ isFulfilled(content)
+ ? content.value
+ : { type: "text", value: "", contentType: undefined }
+ );
+ }
+
+ // Update the text in any breakpoints for this source by re-adding them.
+ const breakpoints = getBreakpointsForSource(getState(), source.id);
+ for (const breakpoint of breakpoints) {
+ await dispatch(
+ addBreakpoint(
+ cx,
+ breakpoint.location,
+ breakpoint.options,
+ breakpoint.disabled
+ )
+ );
+ }
+}
+
+/**
+ * Loads the source text for the generated source based of the source actor
+ * @param {Object} sourceActor
+ * There can be more than one source actor per source
+ * so the source actor needs to be specified. This is
+ * required for generated sources but will be null for
+ * original/pretty printed sources.
+ */
+export const loadGeneratedSourceText = memoizeableAction(
+ "loadGeneratedSourceText",
+ {
+ getValue: ({ sourceActor }, { getState }) => {
+ if (!sourceActor) {
+ return null;
+ }
+
+ const sourceTextContent = getSourceTextContent(
+ getState(),
+ createLocation({
+ source: sourceActor.sourceObject,
+ sourceActor,
+ })
+ );
+
+ if (!sourceTextContent || sourceTextContent.state === "pending") {
+ return sourceTextContent;
+ }
+
+ // This currently swallows source-load-failure since we return fulfilled
+ // here when content.state === "rejected". In an ideal world we should
+ // propagate that error upward.
+ return fulfilled(sourceTextContent);
+ },
+ createKey: ({ sourceActor }, { getState }) => {
+ const epoch = getSourcesEpoch(getState());
+ return `${epoch}:${sourceActor.actor}`;
+ },
+ action: ({ cx, sourceActor }, thunkArgs) =>
+ loadGeneratedSourceTextPromise(cx, sourceActor, thunkArgs),
+ }
+);
+
+/**
+ * Loads the source text for an original source and source actor
+ * @param {Object} source
+ * The original source to load the source text
+ */
+export const loadOriginalSourceText = memoizeableAction(
+ "loadOriginalSourceText",
+ {
+ getValue: ({ source }, { getState }) => {
+ if (!source) {
+ return null;
+ }
+
+ const sourceTextContent = getSourceTextContent(
+ getState(),
+ createLocation({
+ source,
+ })
+ );
+ if (!sourceTextContent || sourceTextContent.state === "pending") {
+ return sourceTextContent;
+ }
+
+ // This currently swallows source-load-failure since we return fulfilled
+ // here when content.state === "rejected". In an ideal world we should
+ // propagate that error upward.
+ return fulfilled(sourceTextContent);
+ },
+ createKey: ({ source }, { getState }) => {
+ const epoch = getSourcesEpoch(getState());
+ return `${epoch}:${source.id}`;
+ },
+ action: ({ cx, source }, thunkArgs) =>
+ loadOriginalSourceTextPromise(cx, source, thunkArgs),
+ }
+);
+
+export function loadSourceText(cx, source, sourceActor) {
+ return async ({ dispatch, getState }) => {
+ if (!source) {
+ return null;
+ }
+ if (source.isOriginal) {
+ return dispatch(loadOriginalSourceText({ cx, source }));
+ }
+ if (!sourceActor) {
+ sourceActor = getFirstSourceActorForGeneratedSource(
+ getState(),
+ source.id
+ );
+ }
+ return dispatch(loadGeneratedSourceText({ cx, sourceActor }));
+ };
+}
diff --git a/devtools/client/debugger/src/actions/sources/moz.build b/devtools/client/debugger/src/actions/sources/moz.build
new file mode 100644
index 0000000000..9972e9f09b
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/moz.build
@@ -0,0 +1,17 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += []
+
+CompiledModules(
+ "blackbox.js",
+ "breakableLines.js",
+ "index.js",
+ "loadSourceText.js",
+ "newSources.js",
+ "prettyPrint.js",
+ "select.js",
+ "symbols.js",
+)
diff --git a/devtools/client/debugger/src/actions/sources/newSources.js b/devtools/client/debugger/src/actions/sources/newSources.js
new file mode 100644
index 0000000000..1e95c6d79d
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/newSources.js
@@ -0,0 +1,367 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Redux actions for the sources state
+ * @module actions/sources
+ */
+import { PROMISE } from "../utils/middleware/promise";
+import { insertSourceActors } from "../../actions/source-actors";
+import {
+ makeSourceId,
+ createGeneratedSource,
+ createSourceMapOriginalSource,
+ createSourceActor,
+} from "../../client/firefox/create";
+import { toggleBlackBox } from "./blackbox";
+import { syncPendingBreakpoint } from "../breakpoints";
+import { loadSourceText } from "./loadSourceText";
+import { togglePrettyPrint } from "./prettyPrint";
+import { toggleSourceMapIgnoreList } from "../ui";
+import { selectLocation, setBreakableLines } from "../sources";
+
+import { getRawSourceURL, isPrettyURL } from "../../utils/source";
+import { createLocation } from "../../utils/location";
+import {
+ getBlackBoxRanges,
+ getSource,
+ getSourceFromId,
+ hasSourceActor,
+ getSourceByActorId,
+ getPendingSelectedLocation,
+ getPendingBreakpointsForSource,
+ getContext,
+} from "../../selectors";
+
+import { prefs } from "../../utils/prefs";
+import sourceQueue from "../../utils/source-queue";
+import { validateNavigateContext, ContextError } from "../../utils/context";
+
+function loadSourceMaps(cx, sources) {
+ return async function ({ dispatch }) {
+ try {
+ const sourceList = await Promise.all(
+ sources.map(async sourceActor => {
+ const originalSourcesInfo = await dispatch(
+ loadSourceMap(cx, sourceActor)
+ );
+ originalSourcesInfo.forEach(
+ sourcesInfo => (sourcesInfo.sourceActor = sourceActor)
+ );
+ sourceQueue.queueOriginalSources(originalSourcesInfo);
+ return originalSourcesInfo;
+ })
+ );
+
+ await sourceQueue.flush();
+ return sourceList.flat();
+ } catch (error) {
+ if (!(error instanceof ContextError)) {
+ throw error;
+ }
+ }
+ return [];
+ };
+}
+
+/**
+ * @memberof actions/sources
+ * @static
+ */
+function loadSourceMap(cx, sourceActor) {
+ return async function ({ dispatch, getState, sourceMapLoader }) {
+ if (!prefs.clientSourceMapsEnabled || !sourceActor.sourceMapURL) {
+ return [];
+ }
+
+ let data = null;
+ try {
+ // Ignore sourceMapURL on scripts that are part of HTML files, since
+ // we currently treat sourcemaps as Source-wide, not SourceActor-specific.
+ const source = getSourceByActorId(getState(), sourceActor.id);
+ if (source) {
+ data = await sourceMapLoader.getOriginalURLs({
+ // Using source ID here is historical and eventually we'll want to
+ // switch to all of this being per-source-actor.
+ id: source.id,
+ url: sourceActor.url || "",
+ sourceMapBaseURL: sourceActor.sourceMapBaseURL || "",
+ sourceMapURL: sourceActor.sourceMapURL || "",
+ isWasm: sourceActor.introductionType === "wasm",
+ });
+ dispatch({
+ type: "ADD_SOURCEMAP_IGNORE_LIST_SOURCES",
+ [PROMISE]: sourceMapLoader.getSourceMapIgnoreList(source.id),
+ });
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ if (!data || !data.length) {
+ // If this source doesn't have a sourcemap or there are no original files
+ // existing, enable it for pretty printing
+ dispatch({
+ type: "CLEAR_SOURCE_ACTOR_MAP_URL",
+ cx,
+ sourceActorId: sourceActor.id,
+ });
+ return [];
+ }
+
+ validateNavigateContext(getState(), cx);
+ return data;
+ };
+}
+
+// If a request has been made to show this source, go ahead and
+// select it.
+function checkSelectedSource(cx, sourceId) {
+ return async ({ dispatch, getState }) => {
+ const state = getState();
+ const pendingLocation = getPendingSelectedLocation(state);
+
+ if (!pendingLocation || !pendingLocation.url) {
+ return;
+ }
+
+ const source = getSource(state, sourceId);
+
+ if (!source || !source.url) {
+ return;
+ }
+
+ const pendingUrl = pendingLocation.url;
+ const rawPendingUrl = getRawSourceURL(pendingUrl);
+
+ if (rawPendingUrl === source.url) {
+ if (isPrettyURL(pendingUrl)) {
+ const prettySource = await dispatch(togglePrettyPrint(cx, source.id));
+ dispatch(checkPendingBreakpoints(cx, prettySource, null));
+ return;
+ }
+
+ await dispatch(
+ selectLocation(
+ cx,
+ createLocation({
+ source,
+ line:
+ typeof pendingLocation.line === "number"
+ ? pendingLocation.line
+ : 0,
+ column: pendingLocation.column,
+ })
+ )
+ );
+ }
+ };
+}
+
+function checkPendingBreakpoints(cx, source, sourceActor) {
+ return async ({ dispatch, getState }) => {
+ const pendingBreakpoints = getPendingBreakpointsForSource(
+ getState(),
+ source
+ );
+
+ if (pendingBreakpoints.length === 0) {
+ return;
+ }
+
+ // load the source text if there is a pending breakpoint for it
+ await dispatch(loadSourceText(cx, source, sourceActor));
+ await dispatch(
+ setBreakableLines(cx, createLocation({ source, sourceActor }))
+ );
+
+ await Promise.all(
+ pendingBreakpoints.map(pendingBp => {
+ return dispatch(syncPendingBreakpoint(cx, source.id, pendingBp));
+ })
+ );
+ };
+}
+
+function restoreBlackBoxedSources(cx, sources) {
+ return async ({ dispatch, getState }) => {
+ const currentRanges = getBlackBoxRanges(getState());
+
+ if (!Object.keys(currentRanges).length) {
+ return;
+ }
+
+ for (const source of sources) {
+ const ranges = currentRanges[source.url];
+ if (ranges) {
+ // If the ranges is an empty then the whole source was blackboxed.
+ await dispatch(toggleBlackBox(cx, source, true, ranges));
+ }
+ }
+
+ if (prefs.sourceMapIgnoreListEnabled) {
+ await dispatch(toggleSourceMapIgnoreList(cx, true));
+ }
+ };
+}
+
+export function newOriginalSources(originalSourcesInfo) {
+ return async ({ dispatch, getState }) => {
+ const state = getState();
+ const seen = new Set();
+
+ const actors = [];
+ const actorsSources = {};
+
+ for (const { id, url, sourceActor } of originalSourcesInfo) {
+ if (seen.has(id) || getSource(state, id)) {
+ continue;
+ }
+ seen.add(id);
+
+ if (!actorsSources[sourceActor.actor]) {
+ actors.push(sourceActor);
+ actorsSources[sourceActor.actor] = [];
+ }
+
+ actorsSources[sourceActor.actor].push(
+ createSourceMapOriginalSource(id, url)
+ );
+ }
+
+ const cx = getContext(state);
+
+ // Add the original sources per the generated source actors that
+ // they are primarily from.
+ actors.forEach(sourceActor => {
+ dispatch({
+ type: "ADD_ORIGINAL_SOURCES",
+ cx,
+ originalSources: actorsSources[sourceActor.actor],
+ generatedSourceActor: sourceActor,
+ });
+ });
+
+ // Accumulate the sources back into one list
+ const actorsSourcesValues = Object.values(actorsSources);
+ let sources = [];
+ if (actorsSourcesValues.length) {
+ sources = actorsSourcesValues.reduce((acc, sourceList) =>
+ acc.concat(sourceList)
+ );
+ }
+
+ await dispatch(checkNewSources(cx, sources));
+
+ for (const source of sources) {
+ dispatch(checkPendingBreakpoints(cx, source, null));
+ }
+
+ return sources;
+ };
+}
+
+// Wrapper around newGeneratedSources, only used by tests
+export function newGeneratedSource(sourceInfo) {
+ return async ({ dispatch }) => {
+ const sources = await dispatch(newGeneratedSources([sourceInfo]));
+ return sources[0];
+ };
+}
+
+export function newGeneratedSources(sourceResources) {
+ return async ({ dispatch, getState, client }) => {
+ if (!sourceResources.length) {
+ return [];
+ }
+
+ const resultIds = [];
+ const newSourcesObj = {};
+ const newSourceActors = [];
+
+ for (const sourceResource of sourceResources) {
+ // By the time we process the sources, the related target
+ // might already have been destroyed. It means that the sources
+ // are also about to be destroyed, so ignore them.
+ // (This is covered by browser_toolbox_backward_forward_navigation.js)
+ if (sourceResource.targetFront.isDestroyed()) {
+ continue;
+ }
+ const id = makeSourceId(sourceResource);
+
+ if (!getSource(getState(), id) && !newSourcesObj[id]) {
+ newSourcesObj[id] = createGeneratedSource(sourceResource);
+ }
+
+ const actorId = sourceResource.actor;
+
+ // We are sometimes notified about a new source multiple times if we
+ // request a new source list and also get a source event from the server.
+ if (!hasSourceActor(getState(), actorId)) {
+ newSourceActors.push(
+ createSourceActor(
+ sourceResource,
+ getSource(getState(), id) || newSourcesObj[id]
+ )
+ );
+ }
+
+ resultIds.push(id);
+ }
+
+ const newSources = Object.values(newSourcesObj);
+
+ const cx = getContext(getState());
+ dispatch(addSources(cx, newSources));
+ dispatch(insertSourceActors(newSourceActors));
+
+ await dispatch(checkNewSources(cx, newSources));
+
+ (async () => {
+ await dispatch(loadSourceMaps(cx, newSourceActors));
+
+ // We would like to sync breakpoints after we are done
+ // loading source maps as sometimes generated and original
+ // files share the same paths.
+ for (const sourceActor of newSourceActors) {
+ // For HTML pages, we fetch all new incoming inline script,
+ // which will be related to one dedicated source actor.
+ // Whereas, for regular sources, if we have many source actors,
+ // this is for the same URL. And code expecting to have breakable lines
+ // will request breakable lines for that particular source actor.
+ if (sourceActor.sourceObject.isHTML) {
+ await dispatch(
+ setBreakableLines(
+ cx,
+ createLocation({ source: sourceActor.sourceObject, sourceActor })
+ )
+ );
+ }
+ dispatch(
+ checkPendingBreakpoints(cx, sourceActor.sourceObject, sourceActor)
+ );
+ }
+ })();
+
+ return resultIds.map(id => getSourceFromId(getState(), id));
+ };
+}
+
+function addSources(cx, sources) {
+ return ({ dispatch, getState }) => {
+ dispatch({ type: "ADD_SOURCES", cx, sources });
+ };
+}
+
+function checkNewSources(cx, sources) {
+ return async ({ dispatch, getState }) => {
+ for (const source of sources) {
+ dispatch(checkSelectedSource(cx, source.id));
+ }
+
+ await dispatch(restoreBlackBoxedSources(cx, sources));
+
+ return sources;
+ };
+}
diff --git a/devtools/client/debugger/src/actions/sources/prettyPrint.js b/devtools/client/debugger/src/actions/sources/prettyPrint.js
new file mode 100644
index 0000000000..66e3f4129b
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/prettyPrint.js
@@ -0,0 +1,339 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import {
+ generatedToOriginalId,
+ originalToGeneratedId,
+} from "devtools/client/shared/source-map-loader/index";
+
+import assert from "../../utils/assert";
+import { recordEvent } from "../../utils/telemetry";
+import { updateBreakpointsForNewPrettyPrintedSource } from "../breakpoints";
+import { createLocation } from "../../utils/location";
+
+import {
+ getPrettySourceURL,
+ isGenerated,
+ isJavaScript,
+} from "../../utils/source";
+import { isFulfilled } from "../../utils/async-value";
+import { getOriginalLocation } from "../../utils/source-maps";
+import { prefs } from "../../utils/prefs";
+import {
+ loadGeneratedSourceText,
+ loadOriginalSourceText,
+} from "./loadSourceText";
+import { mapFrames } from "../pause";
+import { selectSpecificLocation } from "../sources";
+import { createPrettyPrintOriginalSource } from "../../client/firefox/create";
+
+import {
+ getSource,
+ getFirstSourceActorForGeneratedSource,
+ getSourceByURL,
+ getSelectedLocation,
+ getThreadContext,
+} from "../../selectors";
+
+import { selectSource } from "./select";
+
+import DevToolsUtils from "devtools/shared/DevToolsUtils";
+
+const LINE_BREAK_REGEX = /\r\n?|\n|\u2028|\u2029/g;
+function matchAllLineBreaks(str) {
+ return Array.from(str.matchAll(LINE_BREAK_REGEX));
+}
+
+function getPrettyOriginalSourceURL(generatedSource) {
+ return getPrettySourceURL(generatedSource.url || generatedSource.id);
+}
+
+export async function prettyPrintSource(
+ sourceMapLoader,
+ prettyPrintWorker,
+ generatedSource,
+ content,
+ actors
+) {
+ if (!content || !isFulfilled(content)) {
+ throw new Error("Cannot pretty-print a file that has not loaded");
+ }
+
+ const contentValue = content.value;
+ if (
+ (!isJavaScript(generatedSource, contentValue) && !generatedSource.isHTML) ||
+ contentValue.type !== "text"
+ ) {
+ throw new Error(
+ `Can't prettify ${contentValue.contentType} files, only HTML and Javascript.`
+ );
+ }
+
+ const url = getPrettyOriginalSourceURL(generatedSource);
+
+ let prettyPrintWorkerResult;
+ if (generatedSource.isHTML) {
+ prettyPrintWorkerResult = await prettyPrintHtmlFile({
+ prettyPrintWorker,
+ generatedSource,
+ content,
+ actors,
+ });
+ } else {
+ prettyPrintWorkerResult = await prettyPrintWorker.prettyPrint({
+ sourceText: contentValue.value,
+ indent: " ".repeat(prefs.indentSize),
+ url,
+ });
+ }
+
+ // The source map URL service used by other devtools listens to changes to
+ // sources based on their actor IDs, so apply the sourceMap there too.
+ const generatedSourceIds = [
+ generatedSource.id,
+ ...actors.map(item => item.actor),
+ ];
+ await sourceMapLoader.setSourceMapForGeneratedSources(
+ generatedSourceIds,
+ prettyPrintWorkerResult.sourceMap
+ );
+
+ return {
+ text: prettyPrintWorkerResult.code,
+ contentType: contentValue.contentType,
+ };
+}
+
+/**
+ * Pretty print inline script inside an HTML file
+ *
+ * @param {Object} options
+ * @param {PrettyPrintDispatcher} options.prettyPrintWorker: The prettyPrint worker
+ * @param {Object} options.generatedSource: The HTML source we want to pretty print
+ * @param {Object} options.content
+ * @param {Array} options.actors: An array of the HTML file inline script sources data
+ *
+ * @returns Promise<Object> A promise that resolves with an object of the following shape:
+ * - {String} code: The prettified HTML text
+ * - {Object} sourceMap: The sourceMap object
+ */
+async function prettyPrintHtmlFile({
+ prettyPrintWorker,
+ generatedSource,
+ content,
+ actors,
+}) {
+ const url = getPrettyOriginalSourceURL(generatedSource);
+ const contentValue = content.value;
+ const htmlFileText = contentValue.value;
+ const prettyPrintWorkerResult = { code: htmlFileText };
+
+ const allLineBreaks = matchAllLineBreaks(htmlFileText);
+ let lineCountDelta = 0;
+
+ // Sort inline script actors so they are in the same order as in the html document.
+ actors.sort((a, b) => {
+ if (a.sourceStartLine === b.sourceStartLine) {
+ return a.sourceStartColumn > b.sourceStartColumn;
+ }
+ return a.sourceStartLine > b.sourceStartLine;
+ });
+
+ const prettyPrintTaskId = generatedSource.id;
+
+ // We don't want to replace part of the HTML document in the loop since it would require
+ // to account for modified lines for each iteration.
+ // Instead, we'll put each sections to replace in this array, where elements will be
+ // objects of the following shape:
+ // {Integer} startIndex: The start index in htmlFileText of the section we want to replace
+ // {Integer} endIndex: The end index in htmlFileText of the section we want to replace
+ // {String} prettyText: The pretty text we'll replace the original section with
+ // Once we iterated over all the inline scripts, we'll do the replacements (on the html
+ // file text) in reverse order, so we don't need have to care about the modified lines
+ // for each iteration.
+ const replacements = [];
+
+ const seenLocations = new Set();
+
+ for (const sourceInfo of actors) {
+ // We can get duplicate source actors representing the same inline script which will
+ // cause trouble in the pretty printing here. This should be fixed on the server (see
+ // Bug 1824979), but in the meantime let's not handle the same location twice so the
+ // pretty printing is not impacted.
+ const location = `${sourceInfo.sourceStartLine}:${sourceInfo.sourceStartColumn}`;
+ if (!sourceInfo.sourceLength || seenLocations.has(location)) {
+ continue;
+ }
+ seenLocations.add(location);
+ // Here we want to get the index of the last line break before the script tag.
+ // In allLineBreaks, this would be the item at (script tag line - 1)
+ // Since sourceInfo.sourceStartLine is 1-based, we need to get the item at (sourceStartLine - 2)
+ const indexAfterPreviousLineBreakInHtml =
+ sourceInfo.sourceStartLine > 1
+ ? allLineBreaks[sourceInfo.sourceStartLine - 2].index + 1
+ : 0;
+ const startIndex =
+ indexAfterPreviousLineBreakInHtml + sourceInfo.sourceStartColumn;
+ const endIndex = startIndex + sourceInfo.sourceLength;
+ const scriptText = htmlFileText.substring(startIndex, endIndex);
+ DevToolsUtils.assert(
+ scriptText.length == sourceInfo.sourceLength,
+ "script text has expected length"
+ );
+
+ // Here we're going to pretty print each inline script content.
+ // Since we want to have a sourceMap that we'll apply to the whole HTML file,
+ // we'll only collect the sourceMap once we handled all inline scripts.
+ // `taskId` allows us to signal to the worker that all those calls are part of the
+ // same bigger file, and we'll use it later to get the sourceMap.
+ const prettyText = await prettyPrintWorker.prettyPrintInlineScript({
+ taskId: prettyPrintTaskId,
+ sourceText: scriptText,
+ indent: " ".repeat(prefs.indentSize),
+ url,
+ originalStartLine: sourceInfo.sourceStartLine,
+ originalStartColumn: sourceInfo.sourceStartColumn,
+ // The generated line will be impacted by the previous inline scripts that were
+ // pretty printed, which is why we offset with lineCountDelta
+ generatedStartLine: sourceInfo.sourceStartLine + lineCountDelta,
+ generatedStartColumn: sourceInfo.sourceStartColumn,
+ lineCountDelta,
+ });
+
+ // We need to keep track of the line added/removed in order to properly offset
+ // the mapping of the pretty-print text
+ lineCountDelta +=
+ matchAllLineBreaks(prettyText).length -
+ matchAllLineBreaks(scriptText).length;
+
+ replacements.push({
+ startIndex,
+ endIndex,
+ prettyText,
+ });
+ }
+
+ // `getSourceMap` allow us to collect the computed source map resulting of the calls
+ // to `prettyPrint` with the same taskId.
+ prettyPrintWorkerResult.sourceMap = await prettyPrintWorker.getSourceMap(
+ prettyPrintTaskId
+ );
+
+ // Sort replacement in reverse order so we can replace code in the HTML file more easily
+ replacements.sort((a, b) => a.startIndex < b.startIndex);
+ for (const { startIndex, endIndex, prettyText } of replacements) {
+ prettyPrintWorkerResult.code =
+ prettyPrintWorkerResult.code.substring(0, startIndex) +
+ prettyText +
+ prettyPrintWorkerResult.code.substring(endIndex);
+ }
+
+ return prettyPrintWorkerResult;
+}
+
+function createPrettySource(cx, source) {
+ return async ({ dispatch, sourceMapLoader, getState }) => {
+ const url = getPrettyOriginalSourceURL(source);
+ const id = generatedToOriginalId(source.id, url);
+ const prettySource = createPrettyPrintOriginalSource(id, url);
+
+ dispatch({
+ type: "ADD_ORIGINAL_SOURCES",
+ cx,
+ originalSources: [prettySource],
+ });
+ return prettySource;
+ };
+}
+
+function selectPrettyLocation(cx, prettySource) {
+ return async thunkArgs => {
+ const { dispatch, getState } = thunkArgs;
+ let location = getSelectedLocation(getState());
+
+ // If we were selecting a particular line in the minified/generated source,
+ // try to select the matching line in the prettified/original source.
+ if (
+ location &&
+ location.line >= 1 &&
+ location.sourceId == originalToGeneratedId(prettySource.id)
+ ) {
+ location = await getOriginalLocation(location, thunkArgs);
+
+ return dispatch(
+ selectSpecificLocation(
+ cx,
+ createLocation({ ...location, source: prettySource })
+ )
+ );
+ }
+
+ return dispatch(selectSource(cx, prettySource));
+ };
+}
+
+/**
+ * Toggle the pretty printing of a source's text.
+ * Nothing will happen for non-javascript files.
+ *
+ * @param Object cx
+ * @param String sourceId
+ * The source ID for the minified/generated source object.
+ * @returns Promise
+ * A promise that resolves to the Pretty print/original source object.
+ */
+export function togglePrettyPrint(cx, sourceId) {
+ return async ({ dispatch, getState }) => {
+ const source = getSource(getState(), sourceId);
+ if (!source) {
+ return {};
+ }
+
+ if (!source.isPrettyPrinted) {
+ recordEvent("pretty_print");
+ }
+
+ assert(
+ isGenerated(source),
+ "Pretty-printing only allowed on generated sources"
+ );
+
+ const sourceActor = getFirstSourceActorForGeneratedSource(
+ getState(),
+ source.id
+ );
+
+ await dispatch(loadGeneratedSourceText({ cx, sourceActor }));
+
+ const url = getPrettySourceURL(source.url);
+ const prettySource = getSourceByURL(getState(), url);
+
+ if (prettySource) {
+ return dispatch(selectPrettyLocation(cx, prettySource));
+ }
+
+ const newPrettySource = await dispatch(createPrettySource(cx, source));
+
+ // Force loading the pretty source/original text.
+ // This will end up calling prettyPrintSource() of this module, and
+ // more importantly, will populate the sourceMapLoader, which is used by selectPrettyLocation.
+ await dispatch(loadOriginalSourceText({ cx, source: newPrettySource }));
+ // Select the pretty/original source based on the location we may
+ // have had against the minified/generated source.
+ // This uses source map to map locations.
+ // Also note that selecting a location force many things:
+ // * opening tabs
+ // * fetching symbols/inline scope
+ // * fetching breakable lines
+ await dispatch(selectPrettyLocation(cx, newPrettySource));
+
+ const threadcx = getThreadContext(getState());
+ // Update frames to the new pretty/original source (in case we were paused)
+ await dispatch(mapFrames(threadcx));
+ // Update breakpoints locations to the new pretty/original source
+ await dispatch(updateBreakpointsForNewPrettyPrintedSource(cx, sourceId));
+
+ return newPrettySource;
+ };
+}
diff --git a/devtools/client/debugger/src/actions/sources/select.js b/devtools/client/debugger/src/actions/sources/select.js
new file mode 100644
index 0000000000..c4443432a0
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/select.js
@@ -0,0 +1,264 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Redux actions for the sources state
+ * @module actions/sources
+ */
+
+import { isOriginalId } from "devtools/client/shared/source-map-loader/index";
+
+import { setSymbols } from "./symbols";
+import { setInScopeLines } from "../ast";
+import { togglePrettyPrint } from "./prettyPrint";
+import { addTab, closeTab } from "../tabs";
+import { loadSourceText } from "./loadSourceText";
+import { mapDisplayNames } from "../pause";
+import { setBreakableLines } from ".";
+
+import { prefs } from "../../utils/prefs";
+import { isMinified } from "../../utils/source";
+import { createLocation } from "../../utils/location";
+import { getRelatedMapLocation } from "../../utils/source-maps";
+
+import {
+ getSource,
+ getFirstSourceActorForGeneratedSource,
+ getSourceByURL,
+ getPrettySource,
+ getSelectedLocation,
+ getShouldSelectOriginalLocation,
+ canPrettyPrintSource,
+ getIsCurrentThreadPaused,
+ getSourceTextContent,
+ tabExists,
+} from "../../selectors";
+
+// This is only used by jest tests (and within this module)
+export const setSelectedLocation = (
+ cx,
+ location,
+ shouldSelectOriginalLocation
+) => ({
+ type: "SET_SELECTED_LOCATION",
+ cx,
+ location,
+ shouldSelectOriginalLocation,
+});
+
+// This is only used by jest tests (and within this module)
+export const setPendingSelectedLocation = (cx, url, options) => ({
+ type: "SET_PENDING_SELECTED_LOCATION",
+ cx,
+ url,
+ line: options?.line,
+ column: options?.column,
+});
+
+// This is only used by jest tests (and within this module)
+export const clearSelectedLocation = cx => ({
+ type: "CLEAR_SELECTED_LOCATION",
+ cx,
+});
+
+/**
+ * Deterministically select a source that has a given URL. This will
+ * work regardless of the connection status or if the source exists
+ * yet.
+ *
+ * This exists mostly for external things to interact with the
+ * debugger.
+ */
+export function selectSourceURL(cx, url, options) {
+ return async ({ dispatch, getState }) => {
+ const source = getSourceByURL(getState(), url);
+ if (!source) {
+ return dispatch(setPendingSelectedLocation(cx, url, options));
+ }
+
+ const location = createLocation({ ...options, source });
+ return dispatch(selectLocation(cx, location));
+ };
+}
+
+/**
+ * Wrapper around selectLocation, which creates the location object for us.
+ * Note that it ignores the currently selected source and will select
+ * the precise generated/original source passed as argument.
+ *
+ * @param {Object} cx
+ * @param {String} source
+ * The precise source to select.
+ * @param {String} sourceActor
+ * The specific source actor of the source to
+ * select the source text. This is optional.
+ */
+export function selectSource(cx, source, sourceActor) {
+ return async ({ dispatch }) => {
+ // `createLocation` requires a source object, but we may use selectSource to close the last tab,
+ // where source will be null and the location will be an empty object.
+ const location = source ? createLocation({ source, sourceActor }) : {};
+
+ return dispatch(selectSpecificLocation(cx, location));
+ };
+}
+
+/**
+ * Select a new location.
+ * This will automatically select the source in the source tree (if visible)
+ * and open the source (a new tab and the source editor)
+ * as well as highlight a precise line in the editor.
+ *
+ * Note that by default, this may map your passed location to the original
+ * or generated location based on the selected source state. (see keepContext)
+ *
+ * @param {Object} cx
+ * @param {Object} location
+ * @param {Object} options
+ * @param {boolean} options.keepContext
+ * If false, this will ignore the currently selected source
+ * and select the generated or original location, even if we
+ * were currently selecting the other source type.
+ */
+export function selectLocation(cx, location, { keepContext = true } = {}) {
+ return async thunkArgs => {
+ const { dispatch, getState, client } = thunkArgs;
+
+ if (!client) {
+ // No connection, do nothing. This happens when the debugger is
+ // shut down too fast and it tries to display a default source.
+ return;
+ }
+
+ let source = location.source;
+
+ if (!source) {
+ // If there is no source we deselect the current selected source
+ dispatch(clearSelectedLocation(cx));
+ return;
+ }
+
+ // Preserve the current source map context (original / generated)
+ // when navigating to a new location.
+ // i.e. if keepContext isn't manually overriden to false,
+ // we will convert the source we want to select to either
+ // original/generated in order to match the currently selected one.
+ // If the currently selected source is original, we will
+ // automatically map `location` to refer to the original source,
+ // even if that used to refer only to the generated source.
+ let shouldSelectOriginalLocation = getShouldSelectOriginalLocation(
+ getState()
+ );
+ if (keepContext) {
+ if (shouldSelectOriginalLocation != isOriginalId(location.sourceId)) {
+ // getRelatedMapLocation will convert to the related generated/original location.
+ // i.e if the original location is passed, the related generated location will be returned and vice versa.
+ location = await getRelatedMapLocation(location, thunkArgs);
+ // Note that getRelatedMapLocation may return the exact same location.
+ // For example, if the source-map is half broken, it may return a generated location
+ // while we were selecting original locations. So we may be seeing bundles intermittently
+ // when stepping through broken source maps. And we will see original sources when stepping
+ // through functional original sources.
+
+ source = location.source;
+ }
+ } else {
+ shouldSelectOriginalLocation = isOriginalId(location.sourceId);
+ }
+
+ let sourceActor = location.sourceActor;
+ if (!sourceActor) {
+ sourceActor = getFirstSourceActorForGeneratedSource(
+ getState(),
+ source.id
+ );
+ location = createLocation({ ...location, sourceActor });
+ }
+
+ if (!tabExists(getState(), source.id)) {
+ dispatch(addTab(source, sourceActor));
+ }
+
+ dispatch(setSelectedLocation(cx, location, shouldSelectOriginalLocation));
+
+ await dispatch(loadSourceText(cx, source, sourceActor));
+
+ await dispatch(setBreakableLines(cx, location));
+
+ const loadedSource = getSource(getState(), source.id);
+
+ if (!loadedSource) {
+ // If there was a navigation while we were loading the loadedSource
+ return;
+ }
+
+ const sourceTextContent = getSourceTextContent(getState(), location);
+
+ if (
+ keepContext &&
+ prefs.autoPrettyPrint &&
+ !getPrettySource(getState(), loadedSource.id) &&
+ canPrettyPrintSource(getState(), location) &&
+ isMinified(source, sourceTextContent)
+ ) {
+ await dispatch(togglePrettyPrint(cx, loadedSource.id));
+ dispatch(closeTab(cx, loadedSource));
+ }
+
+ await dispatch(setSymbols({ cx, location }));
+ dispatch(setInScopeLines(cx));
+
+ if (getIsCurrentThreadPaused(getState())) {
+ await dispatch(mapDisplayNames(cx));
+ }
+ };
+}
+
+/**
+ * Select a location while ignoring the currently selected source.
+ * This will select the generated location even if the currently
+ * select source is an original source. And the other way around.
+ *
+ * @param {Object} cx
+ * @param {Object} location
+ * The location to select, object which includes enough
+ * information to specify a precise source, line and column.
+ */
+export function selectSpecificLocation(cx, location) {
+ return selectLocation(cx, location, { keepContext: false });
+}
+
+/**
+ * Select the "mapped location".
+ *
+ * If the passed location is on a generated source, select the
+ * related location in the original source.
+ * If the passed location is on an original source, select the
+ * related location in the generated source.
+ */
+export function jumpToMappedLocation(cx, location) {
+ return async function (thunkArgs) {
+ const { client, dispatch } = thunkArgs;
+ if (!client) {
+ return null;
+ }
+
+ // Map to either an original or a generated source location
+ const pairedLocation = await getRelatedMapLocation(location, thunkArgs);
+
+ return dispatch(selectSpecificLocation(cx, pairedLocation));
+ };
+}
+
+// This is only used by tests
+export function jumpToMappedSelectedLocation(cx) {
+ return async function ({ dispatch, getState }) {
+ const location = getSelectedLocation(getState());
+ if (!location) {
+ return;
+ }
+
+ await dispatch(jumpToMappedLocation(cx, location));
+ };
+}
diff --git a/devtools/client/debugger/src/actions/sources/symbols.js b/devtools/client/debugger/src/actions/sources/symbols.js
new file mode 100644
index 0000000000..5a1fb1f967
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/symbols.js
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { getSymbols } from "../../selectors";
+
+import { PROMISE } from "../utils/middleware/promise";
+import { loadSourceText } from "./loadSourceText";
+
+import { memoizeableAction } from "../../utils/memoizableAction";
+import { fulfilled } from "../../utils/async-value";
+
+async function doSetSymbols(
+ cx,
+ location,
+ { dispatch, getState, parserWorker }
+) {
+ await dispatch(loadSourceText(cx, location.source, location.sourceActor));
+
+ await dispatch({
+ type: "SET_SYMBOLS",
+ cx,
+ location,
+ [PROMISE]: parserWorker.getSymbols(location.sourceId),
+ });
+}
+
+export const setSymbols = memoizeableAction("setSymbols", {
+ getValue: ({ location }, { getState, parserWorker }) => {
+ if (!parserWorker.isLocationSupported(location)) {
+ return fulfilled(null);
+ }
+
+ const symbols = getSymbols(getState(), location);
+ if (!symbols) {
+ return null;
+ }
+
+ return fulfilled(symbols);
+ },
+ createKey: ({ location }) => location.sourceId,
+ action: ({ cx, location }, thunkArgs) =>
+ doSetSymbols(cx, location, thunkArgs),
+});
diff --git a/devtools/client/debugger/src/actions/sources/tests/blackbox.spec.js b/devtools/client/debugger/src/actions/sources/tests/blackbox.spec.js
new file mode 100644
index 0000000000..2ff8420b23
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/tests/blackbox.spec.js
@@ -0,0 +1,249 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import {
+ actions,
+ selectors,
+ createStore,
+ makeSource,
+} from "../../../utils/test-head";
+
+import { initialSourceBlackBoxState } from "../../../reducers/source-blackbox";
+
+describe("blackbox", () => {
+ it("should blackbox and unblackbox a source based on the current state of the source ", async () => {
+ const store = createStore({
+ blackBox: async () => true,
+ getSourceActorBreakableLines: async () => [],
+ });
+ const { dispatch, getState, cx } = store;
+
+ const fooSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo"))
+ );
+ await dispatch(actions.toggleBlackBox(cx, fooSource));
+
+ expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true);
+
+ let blackboxRanges = selectors.getBlackBoxRanges(getState());
+ expect(blackboxRanges[fooSource.url]).toEqual([]);
+
+ await dispatch(actions.toggleBlackBox(cx, fooSource));
+
+ expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false);
+
+ blackboxRanges = selectors.getBlackBoxRanges(getState());
+ expect(blackboxRanges[fooSource.url]).toEqual(undefined);
+ });
+
+ it("should blackbox and unblackbox a source when explicilty specified", async () => {
+ const store = createStore({
+ blackBox: async () => true,
+ getSourceActorBreakableLines: async () => [],
+ });
+ const { dispatch, getState, cx } = store;
+
+ const fooSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo"))
+ );
+
+ // check the state before trying to blackbox
+ expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false);
+
+ let blackboxRanges = selectors.getBlackBoxRanges(getState());
+ expect(blackboxRanges[fooSource.url]).toEqual(undefined);
+
+ // should blackbox the whole source
+ await dispatch(actions.toggleBlackBox(cx, fooSource, true, []));
+
+ expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true);
+
+ blackboxRanges = selectors.getBlackBoxRanges(getState());
+ expect(blackboxRanges[fooSource.url]).toEqual([]);
+
+ // should unblackbox the whole source
+ await dispatch(actions.toggleBlackBox(cx, fooSource, false, []));
+
+ expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false);
+
+ blackboxRanges = selectors.getBlackBoxRanges(getState());
+ expect(blackboxRanges[fooSource.url]).toEqual(undefined);
+ });
+
+ it("should blackbox and unblackbox lines in a source", async () => {
+ const store = createStore({
+ blackBox: async () => true,
+ getSourceActorBreakableLines: async () => [],
+ });
+ const { dispatch, getState, cx } = store;
+
+ const fooSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo"))
+ );
+
+ const range1 = {
+ start: { line: 10, column: 3 },
+ end: { line: 15, column: 4 },
+ };
+
+ const range2 = {
+ start: { line: 5, column: 3 },
+ end: { line: 7, column: 6 },
+ };
+
+ await dispatch(actions.toggleBlackBox(cx, fooSource, true, [range1]));
+
+ expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true);
+
+ let blackboxRanges = selectors.getBlackBoxRanges(getState());
+ expect(blackboxRanges[fooSource.url]).toEqual([range1]);
+
+ // add new blackbox lines in the second range
+ await dispatch(actions.toggleBlackBox(cx, fooSource, true, [range2]));
+
+ expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true);
+
+ blackboxRanges = selectors.getBlackBoxRanges(getState());
+ // ranges are stored asc order
+ expect(blackboxRanges[fooSource.url]).toEqual([range2, range1]);
+
+ // un-blackbox lines in the first range
+ await dispatch(actions.toggleBlackBox(cx, fooSource, false, [range1]));
+
+ expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true);
+
+ blackboxRanges = selectors.getBlackBoxRanges(getState());
+ expect(blackboxRanges[fooSource.url]).toEqual([range2]);
+
+ // un-blackbox lines in the second range
+ await dispatch(actions.toggleBlackBox(cx, fooSource, false, [range2]));
+
+ expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false);
+
+ blackboxRanges = selectors.getBlackBoxRanges(getState());
+ expect(blackboxRanges[fooSource.url]).toEqual(undefined);
+ });
+
+ it("should undo blackboxed lines when whole source unblackboxed", async () => {
+ const store = createStore({
+ blackBox: async () => true,
+ getSourceActorBreakableLines: async () => [],
+ });
+ const { dispatch, getState, cx } = store;
+
+ const fooSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo"))
+ );
+
+ const range1 = {
+ start: { line: 1, column: 5 },
+ end: { line: 3, column: 4 },
+ };
+
+ const range2 = {
+ start: { line: 5, column: 3 },
+ end: { line: 7, column: 6 },
+ };
+
+ await dispatch(
+ actions.toggleBlackBox(cx, fooSource, true, [range1, range2])
+ );
+
+ expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true);
+
+ let blackboxRanges = selectors.getBlackBoxRanges(getState());
+ // The ranges are ordered in based on the lines & cols in ascending
+ expect(blackboxRanges[fooSource.url]).toEqual([range2, range1]);
+
+ // un-blackbox the whole source
+ await dispatch(actions.toggleBlackBox(cx, fooSource));
+
+ expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false);
+
+ blackboxRanges = selectors.getBlackBoxRanges(getState());
+ expect(blackboxRanges[fooSource.url]).toEqual(undefined);
+ });
+
+ it("should restore the blackboxed state correctly debugger load", async () => {
+ const mockAsyncStoreBlackBoxedRanges = {
+ "http://localhost:8000/examples/foo": [
+ {
+ start: { line: 1, column: 5 },
+ end: { line: 3, column: 4 },
+ },
+ ],
+ };
+
+ function loadInitialState() {
+ const blackboxedRanges = mockAsyncStoreBlackBoxedRanges;
+ return {
+ sourceBlackBox: initialSourceBlackBoxState({ blackboxedRanges }),
+ };
+ }
+ const store = createStore(
+ {
+ blackBox: async () => true,
+ getSourceActorBreakableLines: async () => [],
+ },
+ loadInitialState()
+ );
+ const { dispatch, getState } = store;
+
+ const fooSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo"))
+ );
+
+ expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true);
+
+ const blackboxRanges = selectors.getBlackBoxRanges(getState());
+ const mockFooSourceRange = mockAsyncStoreBlackBoxedRanges[fooSource.url];
+ expect(blackboxRanges[fooSource.url]).toEqual(mockFooSourceRange);
+ });
+
+ it("should unblackbox lines after blackboxed state has been restored", async () => {
+ const mockAsyncStoreBlackBoxedRanges = {
+ "http://localhost:8000/examples/foo": [
+ {
+ start: { line: 1, column: 5 },
+ end: { line: 3, column: 4 },
+ },
+ ],
+ };
+
+ function loadInitialState() {
+ const blackboxedRanges = mockAsyncStoreBlackBoxedRanges;
+ return {
+ sourceBlackBox: initialSourceBlackBoxState({ blackboxedRanges }),
+ };
+ }
+ const store = createStore(
+ {
+ blackBox: async () => true,
+ getSourceActorBreakableLines: async () => [],
+ },
+ loadInitialState()
+ );
+ const { dispatch, getState, cx } = store;
+
+ const fooSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo"))
+ );
+
+ expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true);
+
+ let blackboxRanges = selectors.getBlackBoxRanges(getState());
+ const mockFooSourceRange = mockAsyncStoreBlackBoxedRanges[fooSource.url];
+ expect(blackboxRanges[fooSource.url]).toEqual(mockFooSourceRange);
+
+ //unblackbox the blackboxed line
+ await dispatch(
+ actions.toggleBlackBox(cx, fooSource, false, mockFooSourceRange)
+ );
+
+ expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false);
+
+ blackboxRanges = selectors.getBlackBoxRanges(getState());
+ expect(blackboxRanges[fooSource.url]).toEqual(undefined);
+ });
+});
diff --git a/devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js b/devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js
new file mode 100644
index 0000000000..f81fc856dd
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js
@@ -0,0 +1,363 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import {
+ actions,
+ selectors,
+ watchForState,
+ createStore,
+ makeOriginalSource,
+ makeSource,
+} from "../../../utils/test-head";
+import {
+ createSource,
+ mockCommandClient,
+} from "../../tests/helpers/mockCommandClient";
+import { getBreakpointsList } from "../../../selectors";
+import { isFulfilled, isRejected } from "../../../utils/async-value";
+import { createLocation } from "../../../utils/location";
+
+describe("loadGeneratedSourceText", () => {
+ it("should load source text", async () => {
+ const store = createStore(mockCommandClient);
+ const { dispatch, getState, cx } = store;
+
+ const foo1Source = await dispatch(
+ actions.newGeneratedSource(makeSource("foo1"))
+ );
+ const foo1SourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ foo1Source.id
+ );
+ await dispatch(
+ actions.loadGeneratedSourceText({
+ cx,
+ sourceActor: foo1SourceActor,
+ })
+ );
+
+ const foo1Content = selectors.getSettledSourceTextContent(
+ getState(),
+ createLocation({
+ source: foo1Source,
+ sourceActor: foo1SourceActor,
+ })
+ );
+
+ expect(
+ foo1Content &&
+ isFulfilled(foo1Content) &&
+ foo1Content.value.type === "text"
+ ? foo1Content.value.value.indexOf("return foo1")
+ : -1
+ ).not.toBe(-1);
+
+ const foo2Source = await dispatch(
+ actions.newGeneratedSource(makeSource("foo2"))
+ );
+ const foo2SourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ foo2Source.id
+ );
+
+ await dispatch(
+ actions.loadGeneratedSourceText({
+ cx,
+ sourceActor: foo2SourceActor,
+ })
+ );
+
+ const foo2Content = selectors.getSettledSourceTextContent(
+ getState(),
+ createLocation({
+ source: foo2Source,
+ sourceActor: foo2SourceActor,
+ })
+ );
+
+ expect(
+ foo2Content &&
+ isFulfilled(foo2Content) &&
+ foo2Content.value.type === "text"
+ ? foo2Content.value.value.indexOf("return foo2")
+ : -1
+ ).not.toBe(-1);
+ });
+
+ it("should update breakpoint text when a source loads", async () => {
+ const fooOrigContent = createSource("fooOrig", "var fooOrig = 42;");
+ const fooGenContent = createSource("fooGen", "var fooGen = 42;");
+
+ const store = createStore(
+ {
+ ...mockCommandClient,
+ sourceContents: async () => fooGenContent,
+ getSourceActorBreakpointPositions: async () => ({ 1: [0] }),
+ getSourceActorBreakableLines: async () => [],
+ },
+ {},
+ {
+ getGeneratedRangesForOriginal: async () => [
+ { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } },
+ ],
+ getOriginalLocations: async items =>
+ items.map(item => ({
+ ...item,
+ sourceId:
+ item.sourceId === fooGenSource1.id
+ ? fooOrigSources1[0].id
+ : fooOrigSources2[0].id,
+ })),
+ getOriginalSourceText: async s => ({
+ text: fooOrigContent.source,
+ contentType: fooOrigContent.contentType,
+ }),
+ }
+ );
+ const { cx, dispatch, getState } = store;
+
+ const fooGenSource1 = await dispatch(
+ actions.newGeneratedSource(makeSource("fooGen1"))
+ );
+
+ const fooOrigSources1 = await dispatch(
+ actions.newOriginalSources([makeOriginalSource(fooGenSource1)])
+ );
+ const fooGenSource2 = await dispatch(
+ actions.newGeneratedSource(makeSource("fooGen2"))
+ );
+
+ const fooOrigSources2 = await dispatch(
+ actions.newOriginalSources([makeOriginalSource(fooGenSource2)])
+ );
+
+ await dispatch(
+ actions.loadOriginalSourceText({
+ cx,
+ source: fooOrigSources1[0],
+ })
+ );
+
+ await dispatch(
+ actions.addBreakpoint(
+ cx,
+ createLocation({
+ source: fooOrigSources1[0],
+ line: 1,
+ column: 0,
+ }),
+ {}
+ )
+ );
+
+ const breakpoint1 = getBreakpointsList(getState())[0];
+ expect(breakpoint1.text).toBe("");
+ expect(breakpoint1.originalText).toBe("var fooOrig = 42;");
+
+ const fooGenSource1SourceActor =
+ selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ fooGenSource1.id
+ );
+
+ await dispatch(
+ actions.loadGeneratedSourceText({
+ cx,
+ sourceActor: fooGenSource1SourceActor,
+ })
+ );
+
+ const breakpoint2 = getBreakpointsList(getState())[0];
+ expect(breakpoint2.text).toBe("var fooGen = 42;");
+ expect(breakpoint2.originalText).toBe("var fooOrig = 42;");
+
+ const fooGenSource2SourceActor =
+ selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ fooGenSource2.id
+ );
+
+ await dispatch(
+ actions.loadGeneratedSourceText({
+ cx,
+ sourceActor: fooGenSource2SourceActor,
+ })
+ );
+
+ await dispatch(
+ actions.addBreakpoint(
+ cx,
+ createLocation({
+ source: fooGenSource2,
+ line: 1,
+ column: 0,
+ }),
+ {}
+ )
+ );
+
+ const breakpoint3 = getBreakpointsList(getState())[1];
+ expect(breakpoint3.text).toBe("var fooGen = 42;");
+ expect(breakpoint3.originalText).toBe("");
+
+ await dispatch(
+ actions.loadOriginalSourceText({
+ cx,
+ source: fooOrigSources2[0],
+ })
+ );
+
+ const breakpoint4 = getBreakpointsList(getState())[1];
+ expect(breakpoint4.text).toBe("var fooGen = 42;");
+ expect(breakpoint4.originalText).toBe("var fooOrig = 42;");
+ });
+
+ it("loads two sources w/ one request", async () => {
+ let resolve;
+ let count = 0;
+ const { dispatch, getState, cx } = createStore({
+ sourceContents: () =>
+ new Promise(r => {
+ count++;
+ resolve = r;
+ }),
+ getSourceActorBreakpointPositions: async () => ({}),
+ getSourceActorBreakableLines: async () => [],
+ });
+ const id = "foo";
+
+ const source = await dispatch(actions.newGeneratedSource(makeSource(id)));
+ const sourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ source.id
+ );
+
+ dispatch(actions.loadGeneratedSourceText({ cx, sourceActor }));
+
+ const loading = dispatch(
+ actions.loadGeneratedSourceText({ cx, sourceActor })
+ );
+
+ if (!resolve) {
+ throw new Error("no resolve");
+ }
+ resolve({ source: "yay", contentType: "text/javascript" });
+ await loading;
+ expect(count).toEqual(1);
+
+ const content = selectors.getSettledSourceTextContent(
+ getState(),
+ createLocation({
+ source,
+ sourceActor,
+ })
+ );
+ expect(
+ content &&
+ isFulfilled(content) &&
+ content.value.type === "text" &&
+ content.value.value
+ ).toEqual("yay");
+ });
+
+ it("doesn't re-load loaded sources", async () => {
+ let resolve;
+ let count = 0;
+ const { dispatch, getState, cx } = createStore({
+ sourceContents: () =>
+ new Promise(r => {
+ count++;
+ resolve = r;
+ }),
+ getSourceActorBreakpointPositions: async () => ({}),
+ getSourceActorBreakableLines: async () => [],
+ });
+ const id = "foo";
+
+ const source = await dispatch(actions.newGeneratedSource(makeSource(id)));
+ const sourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ source.id
+ );
+ const loading = dispatch(
+ actions.loadGeneratedSourceText({ cx, sourceActor })
+ );
+
+ if (!resolve) {
+ throw new Error("no resolve");
+ }
+ resolve({ source: "yay", contentType: "text/javascript" });
+ await loading;
+
+ await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor }));
+ expect(count).toEqual(1);
+
+ const content = selectors.getSettledSourceTextContent(
+ getState(),
+ createLocation({
+ source,
+ sourceActor,
+ })
+ );
+ expect(
+ content &&
+ isFulfilled(content) &&
+ content.value.type === "text" &&
+ content.value.value
+ ).toEqual("yay");
+ });
+
+ it("should indicate a loading source", async () => {
+ const store = createStore(mockCommandClient);
+ const { dispatch, cx, getState } = store;
+
+ const source = await dispatch(
+ actions.newGeneratedSource(makeSource("foo2"))
+ );
+
+ const sourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ source.id
+ );
+
+ const wasLoading = watchForState(store, state => {
+ return !selectors.getSettledSourceTextContent(
+ state,
+ createLocation({
+ source,
+ sourceActor,
+ })
+ );
+ });
+ await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor }));
+
+ expect(wasLoading()).toBe(true);
+ });
+
+ it("should indicate an errored source text", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+
+ const source = await dispatch(
+ actions.newGeneratedSource(makeSource("bad-id"))
+ );
+ const sourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ source.id
+ );
+ await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor }));
+
+ const content = selectors.getSettledSourceTextContent(
+ getState(),
+ createLocation({
+ source,
+ sourceActor,
+ })
+ );
+ expect(
+ content && isRejected(content) && typeof content.value === "string"
+ ? content.value.indexOf("sourceContents failed")
+ : -1
+ ).not.toBe(-1);
+ });
+});
diff --git a/devtools/client/debugger/src/actions/sources/tests/newSources.spec.js b/devtools/client/debugger/src/actions/sources/tests/newSources.spec.js
new file mode 100644
index 0000000000..730c5b32eb
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/tests/newSources.spec.js
@@ -0,0 +1,172 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import {
+ actions,
+ selectors,
+ createStore,
+ makeSource,
+ makeSourceURL,
+ makeOriginalSource,
+ waitForState,
+} from "../../../utils/test-head";
+const { getSource, getSourceCount, getSelectedSource, getSourceByURL } =
+ selectors;
+import sourceQueue from "../../../utils/source-queue";
+import { generatedToOriginalId } from "devtools/client/shared/source-map-loader/index";
+
+import { mockCommandClient } from "../../tests/helpers/mockCommandClient";
+
+describe("sources - new sources", () => {
+ it("should add sources to state", async () => {
+ const { dispatch, getState } = createStore(mockCommandClient);
+ await dispatch(actions.newGeneratedSource(makeSource("base.js")));
+ await dispatch(actions.newGeneratedSource(makeSource("jquery.js")));
+
+ expect(getSourceCount(getState())).toEqual(2);
+ const base = getSource(getState(), "base.js");
+ const jquery = getSource(getState(), "jquery.js");
+ expect(base && base.id).toEqual("base.js");
+ expect(jquery && jquery.id).toEqual("jquery.js");
+ });
+
+ it("should not add multiple identical generated sources", async () => {
+ const { dispatch, getState } = createStore(mockCommandClient);
+
+ const generated = await dispatch(
+ actions.newGeneratedSource(makeSource("base.js"))
+ );
+
+ await dispatch(actions.newOriginalSources([makeOriginalSource(generated)]));
+ await dispatch(actions.newOriginalSources([makeOriginalSource(generated)]));
+
+ expect(getSourceCount(getState())).toEqual(2);
+ });
+
+ it("should not add multiple identical original sources", async () => {
+ const { dispatch, getState } = createStore(mockCommandClient);
+
+ await dispatch(actions.newGeneratedSource(makeSource("base.js")));
+ await dispatch(actions.newGeneratedSource(makeSource("base.js")));
+
+ expect(getSourceCount(getState())).toEqual(1);
+ });
+
+ it("should automatically select a pending source", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+ const baseSourceURL = makeSourceURL("base.js");
+ await dispatch(actions.selectSourceURL(cx, baseSourceURL));
+
+ expect(getSelectedSource(getState())).toBe(undefined);
+ const baseSource = await dispatch(
+ actions.newGeneratedSource(makeSource("base.js"))
+ );
+
+ const selected = getSelectedSource(getState());
+ expect(selected && selected.url).toBe(baseSource.url);
+ });
+
+ it("should add original sources", async () => {
+ const { dispatch, getState } = createStore(
+ mockCommandClient,
+ {},
+ {
+ getOriginalURLs: async source => [
+ {
+ id: generatedToOriginalId(source.id, "magic.js"),
+ url: "magic.js",
+ },
+ ],
+ getOriginalLocations: async items => items,
+ getOriginalLocation: location => location,
+ }
+ );
+
+ await dispatch(
+ actions.newGeneratedSource(
+ makeSource("base.js", { sourceMapURL: "base.js.map" })
+ )
+ );
+ const magic = getSourceByURL(getState(), "magic.js");
+ expect(magic && magic.url).toEqual("magic.js");
+ });
+
+ // eslint-disable-next-line
+ it("should not attempt to fetch original sources if it's missing a source map url", async () => {
+ const getOriginalURLs = jest.fn();
+ const { dispatch } = createStore(
+ mockCommandClient,
+ {},
+ {
+ getOriginalURLs,
+ getOriginalLocations: async items => items,
+ getOriginalLocation: location => location,
+ }
+ );
+
+ await dispatch(actions.newGeneratedSource(makeSource("base.js")));
+ expect(getOriginalURLs).not.toHaveBeenCalled();
+ });
+
+ // eslint-disable-next-line
+ it("should process new sources immediately, without waiting for source maps to be fetched first", async () => {
+ const { dispatch, getState } = createStore(
+ mockCommandClient,
+ {},
+ {
+ getOriginalURLs: async () => new Promise(_ => {}),
+ getOriginalLocations: async items => items,
+ getOriginalLocation: location => location,
+ }
+ );
+ await dispatch(
+ actions.newGeneratedSource(
+ makeSource("base.js", { sourceMapURL: "base.js.map" })
+ )
+ );
+ expect(getSourceCount(getState())).toEqual(1);
+ const base = getSource(getState(), "base.js");
+ expect(base && base.id).toEqual("base.js");
+ });
+
+ // eslint-disable-next-line
+ it("shouldn't let one slow loading source map delay all the other source maps", async () => {
+ const dbg = createStore(
+ mockCommandClient,
+ {},
+ {
+ getOriginalURLs: async source => {
+ if (source.id == "foo.js") {
+ // simulate a hang loading foo.js.map
+ return new Promise(_ => {});
+ }
+ const url = source.id.replace(".js", ".cljs");
+ return [
+ {
+ id: generatedToOriginalId(source.id, url),
+ url,
+ },
+ ];
+ },
+ getOriginalLocations: async items => items,
+ getGeneratedLocation: location => location,
+ }
+ );
+ const { dispatch, getState } = dbg;
+ await dispatch(
+ actions.newGeneratedSources([
+ makeSource("foo.js", { sourceMapURL: "foo.js.map" }),
+ makeSource("bar.js", { sourceMapURL: "bar.js.map" }),
+ makeSource("bazz.js", { sourceMapURL: "bazz.js.map" }),
+ ])
+ );
+ await sourceQueue.flush();
+ await waitForState(dbg, state => getSourceCount(state) == 5);
+ expect(getSourceCount(getState())).toEqual(5);
+ const barCljs = getSourceByURL(getState(), "bar.cljs");
+ expect(barCljs && barCljs.url).toEqual("bar.cljs");
+ const bazzCljs = getSourceByURL(getState(), "bazz.cljs");
+ expect(bazzCljs && bazzCljs.url).toEqual("bazz.cljs");
+ });
+});
diff --git a/devtools/client/debugger/src/actions/sources/tests/select.spec.js b/devtools/client/debugger/src/actions/sources/tests/select.spec.js
new file mode 100644
index 0000000000..3fcf24f2b7
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/tests/select.spec.js
@@ -0,0 +1,288 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import {
+ actions,
+ selectors,
+ createStore,
+ createSourceObject,
+ makeFrame,
+ makeSource,
+ makeSourceURL,
+ waitForState,
+ makeOriginalSource,
+} from "../../../utils/test-head";
+import {
+ getSource,
+ getSourceCount,
+ getSelectedSource,
+ getSourceTabs,
+ getSelectedLocation,
+ getSymbols,
+} from "../../../selectors/";
+import { createLocation } from "../../../utils/location";
+
+import { mockCommandClient } from "../../tests/helpers/mockCommandClient";
+
+process.on("unhandledRejection", (reason, p) => {});
+
+function initialLocation(sourceId) {
+ return createLocation({ source: createSourceObject(sourceId), line: 1 });
+}
+
+describe("sources", () => {
+ it("should select a source", async () => {
+ // Note that we pass an empty client in because the action checks
+ // if it exists.
+ const store = createStore(mockCommandClient);
+ const { dispatch, getState } = store;
+
+ const frame = makeFrame({ id: "1", sourceId: "foo1" });
+
+ const baseSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo1"))
+ );
+ await dispatch(
+ actions.paused({
+ thread: "FakeThread",
+ why: { type: "debuggerStatement" },
+ frame,
+ frames: [frame],
+ })
+ );
+
+ const cx = selectors.getThreadContext(getState());
+ await dispatch(
+ actions.selectLocation(
+ cx,
+ createLocation({ source: baseSource, line: 1, column: 5 })
+ )
+ );
+
+ const selectedSource = getSelectedSource(getState());
+ if (!selectedSource) {
+ throw new Error("bad selectedSource");
+ }
+ expect(selectedSource.id).toEqual("foo1");
+
+ const source = getSource(getState(), selectedSource.id);
+ if (!source) {
+ throw new Error("bad source");
+ }
+ expect(source.id).toEqual("foo1");
+ });
+
+ it("should select next tab on tab closed if no previous tab", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+
+ const fooSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo.js"))
+ );
+ await dispatch(actions.newGeneratedSource(makeSource("bar.js")));
+ await dispatch(actions.newGeneratedSource(makeSource("baz.js")));
+
+ // 3rd tab
+ await dispatch(actions.selectLocation(cx, initialLocation("foo.js")));
+
+ // 2nd tab
+ await dispatch(actions.selectLocation(cx, initialLocation("bar.js")));
+
+ // 1st tab
+ await dispatch(actions.selectLocation(cx, initialLocation("baz.js")));
+
+ // 3rd tab is reselected
+ await dispatch(actions.selectLocation(cx, initialLocation("foo.js")));
+
+ // closes the 1st tab, which should have no previous tab
+ await dispatch(actions.closeTab(cx, fooSource));
+
+ const selected = getSelectedSource(getState());
+ expect(selected && selected.id).toBe("bar.js");
+ expect(getSourceTabs(getState())).toHaveLength(2);
+ });
+
+ it("should open a tab for the source", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+ await dispatch(actions.newGeneratedSource(makeSource("foo.js")));
+ await dispatch(actions.selectLocation(cx, initialLocation("foo.js")));
+
+ const tabs = getSourceTabs(getState());
+ expect(tabs).toHaveLength(1);
+ expect(tabs[0].url).toEqual("http://localhost:8000/examples/foo.js");
+ });
+
+ it("should select previous tab on tab closed", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+ await dispatch(actions.newGeneratedSource(makeSource("foo.js")));
+ await dispatch(actions.newGeneratedSource(makeSource("bar.js")));
+
+ const bazSource = await dispatch(
+ actions.newGeneratedSource(makeSource("baz.js"))
+ );
+
+ await dispatch(actions.selectLocation(cx, initialLocation("foo.js")));
+ await dispatch(actions.selectLocation(cx, initialLocation("bar.js")));
+ await dispatch(actions.selectLocation(cx, initialLocation("baz.js")));
+ await dispatch(actions.closeTab(cx, bazSource));
+
+ const selected = getSelectedSource(getState());
+ expect(selected && selected.id).toBe("bar.js");
+ expect(getSourceTabs(getState())).toHaveLength(2);
+ });
+
+ it("should keep the selected source when other tab closed", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+
+ await dispatch(actions.newGeneratedSource(makeSource("foo.js")));
+ await dispatch(actions.newGeneratedSource(makeSource("bar.js")));
+ const bazSource = await dispatch(
+ actions.newGeneratedSource(makeSource("baz.js"))
+ );
+
+ // 3rd tab
+ await dispatch(actions.selectLocation(cx, initialLocation("foo.js")));
+
+ // 2nd tab
+ await dispatch(actions.selectLocation(cx, initialLocation("bar.js")));
+
+ // 1st tab
+ await dispatch(actions.selectLocation(cx, initialLocation("baz.js")));
+
+ // 3rd tab is reselected
+ await dispatch(actions.selectLocation(cx, initialLocation("foo.js")));
+ await dispatch(actions.closeTab(cx, bazSource));
+
+ const selected = getSelectedSource(getState());
+ expect(selected && selected.id).toBe("foo.js");
+ expect(getSourceTabs(getState())).toHaveLength(2);
+ });
+
+ it("should not select new sources that lack a URL", async () => {
+ const { dispatch, getState } = createStore(mockCommandClient);
+
+ await dispatch(
+ actions.newGeneratedSource({
+ ...makeSource("foo"),
+ url: "",
+ })
+ );
+
+ expect(getSourceCount(getState())).toEqual(1);
+ const selectedLocation = getSelectedLocation(getState());
+ expect(selectedLocation).toEqual(undefined);
+ });
+
+ it("sets and clears selected location correctly", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+ const source = await dispatch(
+ actions.newGeneratedSource(makeSource("testSource"))
+ );
+ const location = createLocation({ source });
+
+ // set value
+ dispatch(actions.setSelectedLocation(cx, location));
+ expect(getSelectedLocation(getState())).toEqual({
+ sourceId: source.id,
+ ...location,
+ });
+
+ // clear value
+ dispatch(actions.clearSelectedLocation(cx));
+ expect(getSelectedLocation(getState())).toEqual(null);
+ });
+
+ it("sets and clears pending selected location correctly", () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+ const url = "testURL";
+ const options = { line: "testLine", column: "testColumn" };
+
+ // set value
+ dispatch(actions.setPendingSelectedLocation(cx, url, options));
+ const setResult = getState().sources.pendingSelectedLocation;
+ expect(setResult).toEqual({
+ url,
+ line: options.line,
+ column: options.column,
+ });
+
+ // clear value
+ dispatch(actions.clearSelectedLocation(cx));
+ const clearResult = getState().sources.pendingSelectedLocation;
+ expect(clearResult).toEqual({ url: "" });
+ });
+
+ it("should keep the generated the viewing context", async () => {
+ const store = createStore(mockCommandClient);
+ const { dispatch, getState, cx } = store;
+ const baseSource = await dispatch(
+ actions.newGeneratedSource(makeSource("base.js"))
+ );
+ const sourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ baseSource.id
+ );
+
+ const location = createLocation({
+ source: baseSource,
+ line: 1,
+ sourceActor,
+ });
+ await dispatch(actions.selectLocation(cx, location));
+
+ const selected = getSelectedSource(getState());
+ expect(selected && selected.id).toBe(baseSource.id);
+ await waitForState(store, state => getSymbols(state, location));
+ });
+
+ it("should change the original the viewing context", async () => {
+ const { dispatch, getState, cx } = createStore(
+ mockCommandClient,
+ {},
+ {
+ getOriginalLocation: async location => ({ ...location, line: 12 }),
+ getOriginalLocations: async items => items,
+ getGeneratedRangesForOriginal: async () => [],
+ getOriginalSourceText: async () => ({ text: "" }),
+ }
+ );
+
+ const baseGenSource = await dispatch(
+ actions.newGeneratedSource(makeSource("base.js"))
+ );
+
+ const baseSources = await dispatch(
+ actions.newOriginalSources([makeOriginalSource(baseGenSource)])
+ );
+ await dispatch(actions.selectSource(cx, baseSources[0]));
+
+ await dispatch(
+ actions.selectSpecificLocation(
+ cx,
+ createLocation({
+ source: baseSources[0],
+ line: 1,
+ })
+ )
+ );
+
+ const selected = getSelectedLocation(getState());
+ expect(selected && selected.line).toBe(1);
+ });
+
+ describe("selectSourceURL", () => {
+ it("should automatically select a pending source", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+ const baseSourceURL = makeSourceURL("base.js");
+ await dispatch(actions.selectSourceURL(cx, baseSourceURL));
+
+ expect(getSelectedSource(getState())).toBe(undefined);
+ const baseSource = await dispatch(
+ actions.newGeneratedSource(makeSource("base.js"))
+ );
+
+ const selected = getSelectedSource(getState());
+ expect(selected && selected.url).toBe(baseSource.url);
+ });
+ });
+});