summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/utils
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--devtools/client/debugger/src/utils/DevToolsUtils.js16
-rw-r--r--devtools/client/debugger/src/utils/assert.js22
-rw-r--r--devtools/client/debugger/src/utils/ast.js97
-rw-r--r--devtools/client/debugger/src/utils/async-value.js27
-rw-r--r--devtools/client/debugger/src/utils/bootstrap.js135
-rw-r--r--devtools/client/debugger/src/utils/breakpoint/breakpointPositions.js20
-rw-r--r--devtools/client/debugger/src/utils/breakpoint/index.js72
-rw-r--r--devtools/client/debugger/src/utils/breakpoint/moz.build11
-rw-r--r--devtools/client/debugger/src/utils/breakpoint/tests/index.spec.js28
-rw-r--r--devtools/client/debugger/src/utils/build-query.js80
-rw-r--r--devtools/client/debugger/src/utils/clipboard.js19
-rw-r--r--devtools/client/debugger/src/utils/connect.js7
-rw-r--r--devtools/client/debugger/src/utils/context.js63
-rw-r--r--devtools/client/debugger/src/utils/dbg.js100
-rw-r--r--devtools/client/debugger/src/utils/editor/create-editor.js44
-rw-r--r--devtools/client/debugger/src/utils/editor/get-expression.js54
-rw-r--r--devtools/client/debugger/src/utils/editor/get-token-location.js16
-rw-r--r--devtools/client/debugger/src/utils/editor/index.js230
-rw-r--r--devtools/client/debugger/src/utils/editor/moz.build17
-rw-r--r--devtools/client/debugger/src/utils/editor/source-documents.js249
-rw-r--r--devtools/client/debugger/src/utils/editor/source-editor.css271
-rw-r--r--devtools/client/debugger/src/utils/editor/source-editor.js145
-rw-r--r--devtools/client/debugger/src/utils/editor/source-search.js327
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/__snapshots__/create-editor.spec.js.snap60
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/create-editor.spec.js25
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/editor.spec.js203
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/get-expression.spec.js160
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/get-token-location.spec.js31
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/source-documents.spec.js215
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/source-search.spec.js182
-rw-r--r--devtools/client/debugger/src/utils/editor/token-events.js95
-rw-r--r--devtools/client/debugger/src/utils/environment.js15
-rw-r--r--devtools/client/debugger/src/utils/evaluation-result.js19
-rw-r--r--devtools/client/debugger/src/utils/expressions.js67
-rw-r--r--devtools/client/debugger/src/utils/function.js37
-rw-r--r--devtools/client/debugger/src/utils/indentation.js40
-rw-r--r--devtools/client/debugger/src/utils/isMinified.js58
-rw-r--r--devtools/client/debugger/src/utils/location.js134
-rw-r--r--devtools/client/debugger/src/utils/log.js30
-rw-r--r--devtools/client/debugger/src/utils/memoizableAction.js75
-rw-r--r--devtools/client/debugger/src/utils/memoize.js63
-rw-r--r--devtools/client/debugger/src/utils/memoizeLast.js27
-rw-r--r--devtools/client/debugger/src/utils/moz.build54
-rw-r--r--devtools/client/debugger/src/utils/path.js24
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/annotateFrames.js73
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/collapseFrames.js61
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/displayName.js97
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/getFrameUrl.js7
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/getLibraryFromUrl.js144
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/index.js9
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/moz.build15
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/tests/__snapshots__/collapseFrames.spec.js.snap57
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/tests/annotateFrames.spec.js22
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/tests/collapseFrames.spec.js37
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/tests/displayName.spec.js129
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/tests/getLibraryFromUrl.spec.js127
-rw-r--r--devtools/client/debugger/src/utils/pause/index.js5
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/README.md191
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/buildGeneratedBindingList.js141
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/filtering.js45
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/findGeneratedBindingFromPosition.js305
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/getApplicableBindingsForOriginalPosition.js112
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/index.js583
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/locColumn.js13
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/mappingContains.js12
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/moz.build19
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/optimizedOut.js15
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/positionCmp.js24
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/rangeMetadata.js117
-rw-r--r--devtools/client/debugger/src/utils/pause/moz.build15
-rw-r--r--devtools/client/debugger/src/utils/pause/scopes/getScope.js101
-rw-r--r--devtools/client/debugger/src/utils/pause/scopes/getVariables.js32
-rw-r--r--devtools/client/debugger/src/utils/pause/scopes/index.js48
-rw-r--r--devtools/client/debugger/src/utils/pause/scopes/moz.build13
-rw-r--r--devtools/client/debugger/src/utils/pause/scopes/tests/getFramePopVariables.spec.js114
-rw-r--r--devtools/client/debugger/src/utils/pause/scopes/tests/scopes.spec.js134
-rw-r--r--devtools/client/debugger/src/utils/pause/scopes/utils.js55
-rw-r--r--devtools/client/debugger/src/utils/pause/why.js40
-rw-r--r--devtools/client/debugger/src/utils/prefs.js153
-rw-r--r--devtools/client/debugger/src/utils/preview.js7
-rw-r--r--devtools/client/debugger/src/utils/quick-open.js123
-rw-r--r--devtools/client/debugger/src/utils/result-list.js26
-rw-r--r--devtools/client/debugger/src/utils/selected-location.js16
-rw-r--r--devtools/client/debugger/src/utils/shallow-equal.js51
-rw-r--r--devtools/client/debugger/src/utils/source-maps.js122
-rw-r--r--devtools/client/debugger/src/utils/source-queue.js37
-rw-r--r--devtools/client/debugger/src/utils/source.js536
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/getURL.js180
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/moz.build9
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/tests/getUrl.spec.js50
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/utils.js44
-rw-r--r--devtools/client/debugger/src/utils/tabs.js121
-rw-r--r--devtools/client/debugger/src/utils/task.js44
-rw-r--r--devtools/client/debugger/src/utils/telemetry.js72
-rw-r--r--devtools/client/debugger/src/utils/test-head.js289
-rw-r--r--devtools/client/debugger/src/utils/test-mockup.js270
-rw-r--r--devtools/client/debugger/src/utils/tests/DevToolsUtils.spec.js41
-rw-r--r--devtools/client/debugger/src/utils/tests/__snapshots__/ast.spec.js.snap53
-rw-r--r--devtools/client/debugger/src/utils/tests/__snapshots__/expressions.spec.js.snap25
-rw-r--r--devtools/client/debugger/src/utils/tests/__snapshots__/function.spec.js.snap25
-rw-r--r--devtools/client/debugger/src/utils/tests/__snapshots__/indentation.spec.js.snap27
-rw-r--r--devtools/client/debugger/src/utils/tests/assert.spec.js30
-rw-r--r--devtools/client/debugger/src/utils/tests/ast.spec.js34
-rw-r--r--devtools/client/debugger/src/utils/tests/build-query.spec.js256
-rw-r--r--devtools/client/debugger/src/utils/tests/clipboard.spec.js45
-rw-r--r--devtools/client/debugger/src/utils/tests/expressions.spec.js67
-rw-r--r--devtools/client/debugger/src/utils/tests/function.spec.js61
-rw-r--r--devtools/client/debugger/src/utils/tests/indentation.spec.js61
-rw-r--r--devtools/client/debugger/src/utils/tests/isMinified.spec.js18
-rw-r--r--devtools/client/debugger/src/utils/tests/location.spec.js31
-rw-r--r--devtools/client/debugger/src/utils/tests/log.spec.js35
-rw-r--r--devtools/client/debugger/src/utils/tests/memoize.spec.js48
-rw-r--r--devtools/client/debugger/src/utils/tests/memoizeLast.spec.js31
-rw-r--r--devtools/client/debugger/src/utils/tests/path.spec.js49
-rw-r--r--devtools/client/debugger/src/utils/tests/quick-open.spec.js35
-rw-r--r--devtools/client/debugger/src/utils/tests/result-list.spec.js32
-rw-r--r--devtools/client/debugger/src/utils/tests/source.spec.js367
-rw-r--r--devtools/client/debugger/src/utils/tests/telemetry.spec.js13
-rw-r--r--devtools/client/debugger/src/utils/tests/text.spec.js20
-rw-r--r--devtools/client/debugger/src/utils/tests/ui.spec.js15
-rw-r--r--devtools/client/debugger/src/utils/tests/url.spec.js89
-rw-r--r--devtools/client/debugger/src/utils/tests/utils.spec.js87
-rw-r--r--devtools/client/debugger/src/utils/tests/wasm.spec.js96
-rw-r--r--devtools/client/debugger/src/utils/text.js58
-rw-r--r--devtools/client/debugger/src/utils/ui.js48
-rw-r--r--devtools/client/debugger/src/utils/url.js75
-rw-r--r--devtools/client/debugger/src/utils/utils.js59
-rw-r--r--devtools/client/debugger/src/utils/wasm.js168
-rw-r--r--devtools/client/debugger/src/utils/worker.js49
129 files changed, 10874 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/utils/DevToolsUtils.js b/devtools/client/debugger/src/utils/DevToolsUtils.js
new file mode 100644
index 0000000000..b2e01f5502
--- /dev/null
+++ b/devtools/client/debugger/src/utils/DevToolsUtils.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/>. */
+
+import assert from "./assert";
+
+export function reportException(who, exception) {
+ const msg = `${who} threw an exception: `;
+ console.error(msg, exception);
+}
+
+export function executeSoon(fn) {
+ setTimeout(fn, 0);
+}
+
+export default assert;
diff --git a/devtools/client/debugger/src/utils/assert.js b/devtools/client/debugger/src/utils/assert.js
new file mode 100644
index 0000000000..2be4f3c7f1
--- /dev/null
+++ b/devtools/client/debugger/src/utils/assert.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/>. */
+
+import { isNodeTest } from "./environment";
+
+let assert;
+// TODO: try to enable these assertions on mochitest by also enabling it on:
+// import flags from "devtools/shared/flags";
+// if (flags.testing)
+// Unfortunately it throws a lot on mochitests...
+
+if (isNodeTest()) {
+ assert = function (condition, message) {
+ if (!condition) {
+ throw new Error(`Assertion failure: ${message}`);
+ }
+ };
+} else {
+ assert = function () {};
+}
+export default assert;
diff --git a/devtools/client/debugger/src/utils/ast.js b/devtools/client/debugger/src/utils/ast.js
new file mode 100644
index 0000000000..cbdd13aa83
--- /dev/null
+++ b/devtools/client/debugger/src/utils/ast.js
@@ -0,0 +1,97 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+export function findBestMatchExpression(symbols, tokenPos) {
+ if (!symbols) {
+ return null;
+ }
+
+ const { line, column } = tokenPos;
+ const { memberExpressions, identifiers, literals } = symbols;
+ const members = memberExpressions.filter(({ computed }) => !computed);
+
+ return []
+ .concat(identifiers, members, literals)
+ .reduce((found, expression) => {
+ const overlaps =
+ expression.location.start.line == line &&
+ expression.location.start.column <= column &&
+ expression.location.end.column >= column;
+
+ if (overlaps) {
+ return expression;
+ }
+
+ return found;
+ }, null);
+}
+
+// Check whether location A starts after location B
+export function positionAfter(a, b) {
+ return (
+ a.start.line > b.start.line ||
+ (a.start.line === b.start.line && a.start.column > b.start.column)
+ );
+}
+
+export function containsPosition(a, b) {
+ const bColumn = b.column || 0;
+ const startsBefore =
+ a.start.line < b.line ||
+ (a.start.line === b.line && a.start.column <= bColumn);
+ const endsAfter =
+ a.end.line > b.line || (a.end.line === b.line && a.end.column >= bColumn);
+
+ return startsBefore && endsAfter;
+}
+
+function findClosestofSymbol(declarations, location) {
+ if (!declarations) {
+ return null;
+ }
+
+ return declarations.reduce((found, currNode) => {
+ if (
+ currNode.name === "anonymous" ||
+ !containsPosition(currNode.location, {
+ line: location.line,
+ column: location.column || 0,
+ })
+ ) {
+ return found;
+ }
+
+ if (!found) {
+ return currNode;
+ }
+
+ if (found.location.start.line > currNode.location.start.line) {
+ return found;
+ }
+ if (
+ found.location.start.line === currNode.location.start.line &&
+ found.location.start.column > currNode.location.start.column
+ ) {
+ return found;
+ }
+
+ return currNode;
+ }, null);
+}
+
+export function findClosestFunction(symbols, location) {
+ if (!symbols) {
+ return null;
+ }
+
+ return findClosestofSymbol(symbols.functions, location);
+}
+
+export function findClosestClass(symbols, location) {
+ if (!symbols) {
+ return null;
+ }
+
+ return findClosestofSymbol(symbols.classes, location);
+}
diff --git a/devtools/client/debugger/src/utils/async-value.js b/devtools/client/debugger/src/utils/async-value.js
new file mode 100644
index 0000000000..e1467d2401
--- /dev/null
+++ b/devtools/client/debugger/src/utils/async-value.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/>. */
+
+export function pending() {
+ return { state: "pending" };
+}
+export function fulfilled(value) {
+ return { state: "fulfilled", value };
+}
+export function rejected(value) {
+ return { state: "rejected", value };
+}
+
+export function asSettled(value) {
+ return value && value.state !== "pending" ? value : null;
+}
+
+export function isPending(value) {
+ return value.state === "pending";
+}
+export function isFulfilled(value) {
+ return value.state === "fulfilled";
+}
+export function isRejected(value) {
+ return value.state === "rejected";
+}
diff --git a/devtools/client/debugger/src/utils/bootstrap.js b/devtools/client/debugger/src/utils/bootstrap.js
new file mode 100644
index 0000000000..2bd38851e9
--- /dev/null
+++ b/devtools/client/debugger/src/utils/bootstrap.js
@@ -0,0 +1,135 @@
+/* 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 React from "react";
+import { bindActionCreators, combineReducers } from "redux";
+import ReactDOM from "react-dom";
+const { Provider } = require("react-redux");
+
+import ToolboxProvider from "devtools/client/framework/store-provider";
+import flags from "devtools/shared/flags";
+const {
+ registerStoreObserver,
+} = require("devtools/client/shared/redux/subscriber");
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+import { SearchDispatcher } from "../workers/search";
+import { PrettyPrintDispatcher } from "../workers/pretty-print";
+
+import configureStore from "../actions/utils/create-store";
+import reducers from "../reducers";
+import * as selectors from "../selectors";
+import App from "../components/App";
+import { asyncStore, prefs } from "./prefs";
+import { persistTabs } from "../utils/tabs";
+const { sanitizeBreakpoints } = require("devtools/client/shared/thread-utils");
+
+let gWorkers;
+
+export function bootstrapStore(client, workers, panel, initialState) {
+ const debugJsModules = AppConstants.DEBUG_JS_MODULES == "1";
+ const createStore = configureStore({
+ log: prefs.logging || flags.testing,
+ timing: debugJsModules,
+ makeThunkArgs: (args, state) => {
+ return { ...args, client, ...workers, panel };
+ },
+ });
+
+ const store = createStore(combineReducers(reducers), initialState);
+ registerStoreObserver(store, updatePrefs);
+
+ const actions = bindActionCreators(
+ require("../actions").default,
+ store.dispatch
+ );
+
+ return { store, actions, selectors };
+}
+
+export function bootstrapWorkers(panelWorkers) {
+ // The panel worker will typically be the source map and parser workers.
+ // Both will be managed by the toolbox.
+ gWorkers = {
+ prettyPrintWorker: new PrettyPrintDispatcher(),
+ searchWorker: new SearchDispatcher(),
+ };
+ return { ...panelWorkers, ...gWorkers };
+}
+
+export function teardownWorkers() {
+ gWorkers.prettyPrintWorker.stop();
+ gWorkers.searchWorker.stop();
+}
+
+/**
+ * Create and mount the root App component.
+ *
+ * @param {ReduxStore} store
+ * @param {ReduxStore} toolboxStore
+ * @param {Object} appComponentAttributes
+ * @param {Array} appComponentAttributes.fluentBundles
+ * @param {Document} appComponentAttributes.toolboxDoc
+ */
+export function bootstrapApp(store, toolboxStore, appComponentAttributes = {}) {
+ const mount = getMountElement();
+ if (!mount) {
+ return;
+ }
+
+ ReactDOM.render(
+ React.createElement(
+ Provider,
+ { store },
+ React.createElement(
+ ToolboxProvider,
+ { store: toolboxStore },
+ React.createElement(App, appComponentAttributes)
+ )
+ ),
+ mount
+ );
+}
+
+function getMountElement() {
+ return document.querySelector("#mount");
+}
+
+// This is the opposite of bootstrapApp
+export function unmountRoot() {
+ ReactDOM.unmountComponentAtNode(getMountElement());
+}
+
+function updatePrefs(state, oldState) {
+ const hasChanged = selector =>
+ selector(oldState) && selector(oldState) !== selector(state);
+
+ if (hasChanged(selectors.getPendingBreakpoints)) {
+ asyncStore.pendingBreakpoints = sanitizeBreakpoints(
+ selectors.getPendingBreakpoints(state)
+ );
+ }
+
+ if (
+ oldState.eventListenerBreakpoints &&
+ oldState.eventListenerBreakpoints !== state.eventListenerBreakpoints
+ ) {
+ asyncStore.eventListenerBreakpoints = state.eventListenerBreakpoints;
+ }
+
+ if (hasChanged(selectors.getTabs)) {
+ asyncStore.tabs = persistTabs(selectors.getTabs(state));
+ }
+
+ if (hasChanged(selectors.getXHRBreakpoints)) {
+ asyncStore.xhrBreakpoints = selectors.getXHRBreakpoints(state);
+ }
+
+ if (hasChanged(selectors.getBlackBoxRanges)) {
+ asyncStore.blackboxedRanges = selectors.getBlackBoxRanges(state);
+ }
+}
diff --git a/devtools/client/debugger/src/utils/breakpoint/breakpointPositions.js b/devtools/client/debugger/src/utils/breakpoint/breakpointPositions.js
new file mode 100644
index 0000000000..49b8523284
--- /dev/null
+++ b/devtools/client/debugger/src/utils/breakpoint/breakpointPositions.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/>. */
+
+import { comparePosition } from "../location";
+import { getSelectedLocation } from "../selected-location";
+
+export function findPosition(positions, location) {
+ if (!positions) {
+ return null;
+ }
+
+ const lineBps = positions[location.line];
+ if (!lineBps) {
+ return null;
+ }
+ return lineBps.find(pos =>
+ comparePosition(getSelectedLocation(pos, location), location)
+ );
+}
diff --git a/devtools/client/debugger/src/utils/breakpoint/index.js b/devtools/client/debugger/src/utils/breakpoint/index.js
new file mode 100644
index 0000000000..5b0ce06533
--- /dev/null
+++ b/devtools/client/debugger/src/utils/breakpoint/index.js
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { getSourceActorsForSource } from "../../selectors";
+import { isGenerated } from "../source";
+import { sortSelectedLocations } from "../location";
+export * from "./breakpointPositions";
+
+// The ID for a Breakpoint is derived from its location in its Source.
+export function makeBreakpointId(location) {
+ const { sourceId, line, column } = location;
+ const columnString = column || "";
+ return `${sourceId}:${line}:${columnString}`;
+}
+
+export function makeBreakpointServerLocationId(breakpointServerLocation) {
+ const { sourceUrl, sourceId, line, column } = breakpointServerLocation;
+ const sourceUrlOrId = sourceUrl || sourceId;
+ const columnString = column || "";
+
+ return `${sourceUrlOrId}:${line}:${columnString}`;
+}
+
+/**
+ * Create a location object to set a breakpoint on the server.
+ *
+ * Debugger location objects includes a source and sourceActor attributes
+ * whereas the server don't need them and instead only need either
+ * the source URL -or- a precise source actor ID.
+ */
+export function makeBreakpointServerLocation(state, location) {
+ const source = location.source;
+ if (!source) {
+ throw new Error("Missing 'source' attribute on location object");
+ }
+ const breakpointLocation = {
+ line: location.line,
+ column: location.column,
+ };
+ if (source.url) {
+ breakpointLocation.sourceUrl = source.url;
+ } else {
+ breakpointLocation.sourceId = getSourceActorsForSource(
+ state,
+ source.id
+ )[0].id;
+ }
+ return breakpointLocation;
+}
+
+export function createXHRBreakpoint(path, method, overrides = {}) {
+ const properties = {
+ path,
+ method,
+ disabled: false,
+ loading: false,
+ text: L10N.getFormatStr("xhrBreakpoints.item.label", path),
+ };
+
+ return { ...properties, ...overrides };
+}
+
+export function getSelectedText(breakpoint, selectedSource) {
+ return !!selectedSource && isGenerated(selectedSource)
+ ? breakpoint.text
+ : breakpoint.originalText;
+}
+
+export function sortSelectedBreakpoints(breakpoints, selectedSource) {
+ return sortSelectedLocations(breakpoints, selectedSource);
+}
diff --git a/devtools/client/debugger/src/utils/breakpoint/moz.build b/devtools/client/debugger/src/utils/breakpoint/moz.build
new file mode 100644
index 0000000000..02c5302a6c
--- /dev/null
+++ b/devtools/client/debugger/src/utils/breakpoint/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(
+ "breakpointPositions.js",
+ "index.js",
+)
diff --git a/devtools/client/debugger/src/utils/breakpoint/tests/index.spec.js b/devtools/client/debugger/src/utils/breakpoint/tests/index.spec.js
new file mode 100644
index 0000000000..7ff0a1e75c
--- /dev/null
+++ b/devtools/client/debugger/src/utils/breakpoint/tests/index.spec.js
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { sortSelectedBreakpoints } from "../index";
+
+import { makeMockBreakpoint, makeMockSource } from "../../test-mockup";
+
+describe("breakpoint sorting", () => {
+ it("sortSelectedBreakpoints should sort by line number and column ", () => {
+ const sorted = sortSelectedBreakpoints(
+ [
+ makeMockBreakpoint(undefined, 100, 2),
+ makeMockBreakpoint(undefined, 9, 2),
+ makeMockBreakpoint(undefined, 2),
+ makeMockBreakpoint(undefined, 2, 7),
+ ],
+ makeMockSource()
+ );
+
+ expect(sorted[0].location.line).toBe(2);
+ expect(sorted[0].location.column).toBe(undefined);
+ expect(sorted[1].location.line).toBe(2);
+ expect(sorted[1].location.column).toBe(7);
+ expect(sorted[2].location.line).toBe(9);
+ expect(sorted[3].location.line).toBe(100);
+ });
+});
diff --git a/devtools/client/debugger/src/utils/build-query.js b/devtools/client/debugger/src/utils/build-query.js
new file mode 100644
index 0000000000..f10999aee4
--- /dev/null
+++ b/devtools/client/debugger/src/utils/build-query.js
@@ -0,0 +1,80 @@
+/* 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/>. */
+
+function escapeRegExp(str) {
+ const reRegExpChar = /[\\^$.*+?()[\]{}|]/g;
+ return str.replace(reRegExpChar, "\\$&");
+}
+
+/**
+ * Ignore doing outline matches for less than 3 whitespaces
+ *
+ * @memberof utils/source-search
+ * @static
+ */
+function ignoreWhiteSpace(str) {
+ return /^\s{0,2}$/.test(str) ? "(?!\\s*.*)" : str;
+}
+
+function wholeMatch(query, wholeWord) {
+ if (query === "" || !wholeWord) {
+ return query;
+ }
+
+ return `\\b${query}\\b`;
+}
+
+function buildFlags(caseSensitive, isGlobal) {
+ if (caseSensitive && isGlobal) {
+ return "g";
+ }
+
+ if (!caseSensitive && isGlobal) {
+ return "gi";
+ }
+
+ if (!caseSensitive && !isGlobal) {
+ return "i";
+ }
+
+ return null;
+}
+
+export default function buildQuery(
+ originalQuery,
+ modifiers,
+ { isGlobal = false, ignoreSpaces = false }
+) {
+ const { caseSensitive, regexMatch, wholeWord } = modifiers;
+
+ if (originalQuery === "") {
+ return new RegExp(originalQuery);
+ }
+
+ // Remove the backslashes at the end of the query as it
+ // breaks the RegExp
+ let query = originalQuery.replace(/\\$/, "");
+
+ // If we don't want to do a regexMatch, we need to escape all regex related characters
+ // so they would actually match.
+ if (!regexMatch) {
+ query = escapeRegExp(query);
+ }
+
+ // ignoreWhiteSpace might return a negative lookbehind, and in such case, we want it
+ // to be consumed as a RegExp part by the callsite, so this needs to be called after
+ // the regexp is escaped.
+ if (ignoreSpaces) {
+ query = ignoreWhiteSpace(query);
+ }
+
+ query = wholeMatch(query, wholeWord);
+ const flags = buildFlags(caseSensitive, isGlobal);
+
+ if (flags) {
+ return new RegExp(query, flags);
+ }
+
+ return new RegExp(query);
+}
diff --git a/devtools/client/debugger/src/utils/clipboard.js b/devtools/client/debugger/src/utils/clipboard.js
new file mode 100644
index 0000000000..66c0d297cc
--- /dev/null
+++ b/devtools/client/debugger/src/utils/clipboard.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/>. */
+
+/**
+ * Clipboard function taken from
+ * https://searchfox.org/mozilla-central/source/devtools/shared/platform/clipboard.js
+ */
+
+export function copyToTheClipboard(string) {
+ const doCopy = function (e) {
+ e.clipboardData.setData("text/plain", string);
+ e.preventDefault();
+ };
+
+ document.addEventListener("copy", doCopy);
+ document.execCommand("copy", false, null);
+ document.removeEventListener("copy", doCopy);
+}
diff --git a/devtools/client/debugger/src/utils/connect.js b/devtools/client/debugger/src/utils/connect.js
new file mode 100644
index 0000000000..023ae7497d
--- /dev/null
+++ b/devtools/client/debugger/src/utils/connect.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/>. */
+
+import { connect as reduxConnect } from "react-redux";
+
+export const connect = reduxConnect;
diff --git a/devtools/client/debugger/src/utils/context.js b/devtools/client/debugger/src/utils/context.js
new file mode 100644
index 0000000000..ef7fd23bb6
--- /dev/null
+++ b/devtools/client/debugger/src/utils/context.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/>. */
+
+import { getThreadContext } from "../selectors";
+
+// Context encapsulates the main parameters of the current redux state, which
+// impact most other information tracked by the debugger.
+//
+// The main use of Context is to control when asynchronous operations are
+// allowed to make changes to the program state. Such operations might be
+// invalidated as the state changes from the time the operation was originally
+// initiated. For example, operations on pause state might still continue even
+// after the thread unpauses.
+//
+// The methods below can be used to compare an old context with the current one
+// and see if the operation is now invalid and should be abandoned. Actions can
+// also include a 'cx' Context property, which will be checked by the context
+// middleware. If the action fails validateContextAction() then it will not be
+// dispatched.
+//
+// Context can additionally be used as a shortcut to access the main properties
+// of the pause state.
+
+// A normal Context is invalidated if the target navigates.
+
+// A ThreadContext is invalidated if the target navigates, or if the current
+// thread changes, pauses, or resumes.
+
+export class ContextError extends Error {}
+
+export function validateNavigateContext(state, cx) {
+ const newcx = getThreadContext(state);
+
+ if (newcx.navigateCounter != cx.navigateCounter) {
+ throw new ContextError("Page has navigated");
+ }
+}
+
+export function validateThreadContext(state, cx) {
+ const newcx = getThreadContext(state);
+
+ if (cx.thread != newcx.thread) {
+ throw new ContextError("Current thread has changed");
+ }
+
+ if (cx.pauseCounter != newcx.pauseCounter) {
+ throw new ContextError("Current thread has paused or resumed");
+ }
+}
+
+export function validateContext(state, cx) {
+ validateNavigateContext(state, cx);
+
+ if ("thread" in cx) {
+ validateThreadContext(state, cx);
+ }
+}
+
+export function isValidThreadContext(state, cx) {
+ const newcx = getThreadContext(state);
+ return cx.thread == newcx.thread && cx.pauseCounter == newcx.pauseCounter;
+}
diff --git a/devtools/client/debugger/src/utils/dbg.js b/devtools/client/debugger/src/utils/dbg.js
new file mode 100644
index 0000000000..0d7dff72e1
--- /dev/null
+++ b/devtools/client/debugger/src/utils/dbg.js
@@ -0,0 +1,100 @@
+/* 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 { prefs, asyncStore, features } from "./prefs";
+import { getDocument } from "./editor/source-documents";
+import { wasmOffsetToLine } from "./wasm";
+
+function getThreadFront(dbg) {
+ return dbg.targetCommand.targetFront.threadFront;
+}
+
+function findSource(dbg, url) {
+ const sources = dbg.selectors.getSourceList();
+ return sources.find(s => (s.url || "").includes(url));
+}
+
+function findSources(dbg, url) {
+ const sources = dbg.selectors.getSourceList();
+ return sources.filter(s => (s.url || "").includes(url));
+}
+
+function evaluate(dbg, expression) {
+ return dbg.client.evaluate(expression);
+}
+
+function bindSelectors(obj) {
+ return Object.keys(obj.selectors).reduce((bound, selector) => {
+ bound[selector] = (a, b, c) =>
+ obj.selectors[selector](obj.store.getState(), a, b, c);
+ return bound;
+ }, {});
+}
+
+function getCM() {
+ const cm = document.querySelector(".CodeMirror");
+ return cm?.CodeMirror;
+}
+
+function formatMappedLocation(mappedLocation) {
+ const { location, generatedLocation } = mappedLocation;
+ return {
+ original: `(${location.line}, ${location.column})`,
+ generated: `(${generatedLocation.line}, ${generatedLocation.column})`,
+ };
+}
+
+function formatMappedLocations(locations) {
+ return console.table(locations.map(loc => formatMappedLocation(loc)));
+}
+
+function formatSelectedColumnBreakpoints(dbg) {
+ const positions = dbg.selectors.getBreakpointPositionsForSource(
+ dbg.selectors.getSelectedSource().id
+ );
+
+ return formatMappedLocations(positions);
+}
+
+function getDocumentForUrl(dbg, url) {
+ const source = findSource(dbg, url);
+ return getDocument(source.id);
+}
+
+const diff = (a, b) => Object.keys(a).filter(key => !Object.is(a[key], b[key]));
+
+export function setupHelper(obj) {
+ const selectors = bindSelectors(obj);
+ const dbg = {
+ ...obj,
+ selectors,
+ prefs,
+ asyncStore,
+ features,
+ getCM,
+
+ // Expose this to tests as they don't have access to debugger's browser loader require
+ // and so can't load utils/wasm.js
+ wasmOffsetToLine: (sourceId, offset) => wasmOffsetToLine(sourceId, offset),
+
+ helpers: {
+ findSource: url => findSource(dbg, url),
+ findSources: url => findSources(dbg, url),
+ evaluate: expression => evaluate(dbg, expression),
+ dumpThread: () => getThreadFront(dbg).dumpThread(),
+ getDocument: url => getDocumentForUrl(dbg, url),
+ },
+ formatters: {
+ mappedLocations: locations => formatMappedLocations(locations),
+ mappedLocation: location => formatMappedLocation(location),
+ selectedColumnBreakpoints: () => formatSelectedColumnBreakpoints(dbg),
+ },
+ _telemetry: {
+ events: {},
+ },
+ diff,
+ };
+
+ window.dbg = dbg;
+}
diff --git a/devtools/client/debugger/src/utils/editor/create-editor.js b/devtools/client/debugger/src/utils/editor/create-editor.js
new file mode 100644
index 0000000000..3f44ddaa67
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/create-editor.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 SourceEditor from "./source-editor";
+import { features, prefs } from "../prefs";
+
+export function createEditor() {
+ const gutters = ["breakpoints", "hit-markers", "CodeMirror-linenumbers"];
+
+ if (features.codeFolding) {
+ gutters.push("CodeMirror-foldgutter");
+ }
+
+ return new SourceEditor({
+ mode: "javascript",
+ foldGutter: features.codeFolding,
+ enableCodeFolding: features.codeFolding,
+ readOnly: true,
+ lineNumbers: true,
+ theme: "mozilla",
+ styleActiveLine: false,
+ lineWrapping: prefs.editorWrapping,
+ matchBrackets: true,
+ showAnnotationRuler: true,
+ gutters,
+ value: " ",
+ extraKeys: {
+ // Override code mirror keymap to avoid conflicts with split console.
+ Esc: false,
+ "Cmd-F": false,
+ "Ctrl-F": false,
+ "Cmd-G": false,
+ "Ctrl-G": false,
+ },
+ cursorBlinkRate: prefs.cursorBlinkRate,
+ });
+}
+
+export function createHeadlessEditor() {
+ const editor = createEditor();
+ editor.appendToLocalElement(document.createElement("div"));
+ return editor;
+}
diff --git a/devtools/client/debugger/src/utils/editor/get-expression.js b/devtools/client/debugger/src/utils/editor/get-expression.js
new file mode 100644
index 0000000000..c664f163c3
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/get-expression.js
@@ -0,0 +1,54 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+export function tokenAtTextPosition(cm, { line, column }) {
+ if (line < 0 || line >= cm.lineCount()) {
+ return null;
+ }
+
+ const token = cm.getTokenAt({ line: line - 1, ch: column });
+ if (!token) {
+ return null;
+ }
+
+ return { startColumn: token.start, endColumn: token.end, type: token.type };
+}
+
+// The strategy of querying codeMirror tokens was borrowed
+// from Chrome's inital implementation in JavaScriptSourceFrame.js#L414
+export function getExpressionFromCoords(cm, coord) {
+ const token = tokenAtTextPosition(cm, coord);
+ if (!token) {
+ return null;
+ }
+
+ let startHighlight = token.startColumn;
+ const endHighlight = token.endColumn;
+ const lineNumber = coord.line;
+ const line = cm.doc.getLine(coord.line - 1);
+ while (startHighlight > 1 && line.charAt(startHighlight - 1) === ".") {
+ const tokenBefore = tokenAtTextPosition(cm, {
+ line: coord.line,
+ column: startHighlight - 2,
+ });
+
+ if (!tokenBefore || !tokenBefore.type) {
+ return null;
+ }
+
+ startHighlight = tokenBefore.startColumn;
+ }
+
+ const expression = line.substring(startHighlight, endHighlight) || "";
+
+ if (!expression) {
+ return null;
+ }
+
+ const location = {
+ start: { line: lineNumber, column: startHighlight },
+ end: { line: lineNumber, column: endHighlight },
+ };
+ return { expression, location };
+}
diff --git a/devtools/client/debugger/src/utils/editor/get-token-location.js b/devtools/client/debugger/src/utils/editor/get-token-location.js
new file mode 100644
index 0000000000..2642066051
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/get-token-location.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/>. */
+
+export function getTokenLocation(codeMirror, tokenEl) {
+ const { left, top, width, height } = tokenEl.getBoundingClientRect();
+ const { line, ch } = codeMirror.coordsChar({
+ left: left + width / 2,
+ top: top + height / 2,
+ });
+
+ return {
+ line: line + 1,
+ column: ch,
+ };
+}
diff --git a/devtools/client/debugger/src/utils/editor/index.js b/devtools/client/debugger/src/utils/editor/index.js
new file mode 100644
index 0000000000..ce1875bd35
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/index.js
@@ -0,0 +1,230 @@
+/* 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 "./source-documents";
+export * from "./get-token-location";
+export * from "./source-search";
+export * from "../ui";
+export { onMouseOver } from "./token-events";
+
+import { createEditor } from "./create-editor";
+
+import { isWasm, lineToWasmOffset, wasmOffsetToLine } from "../wasm";
+import { createLocation } from "../location";
+
+let editor;
+
+export function getEditor() {
+ if (editor) {
+ return editor;
+ }
+
+ editor = createEditor();
+ return editor;
+}
+
+export function removeEditor() {
+ editor = null;
+}
+
+function getCodeMirror() {
+ return editor && editor.hasCodeMirror ? editor.codeMirror : null;
+}
+
+export function startOperation() {
+ const codeMirror = getCodeMirror();
+ if (!codeMirror) {
+ return;
+ }
+
+ codeMirror.startOperation();
+}
+
+export function endOperation() {
+ const codeMirror = getCodeMirror();
+ if (!codeMirror) {
+ return;
+ }
+
+ codeMirror.endOperation();
+}
+
+export function toEditorLine(sourceId, lineOrOffset) {
+ if (isWasm(sourceId)) {
+ // TODO ensure offset is always "mappable" to edit line.
+ return wasmOffsetToLine(sourceId, lineOrOffset) || 0;
+ }
+
+ return lineOrOffset ? lineOrOffset - 1 : 1;
+}
+
+export function fromEditorLine(sourceId, line, sourceIsWasm) {
+ if (sourceIsWasm) {
+ return lineToWasmOffset(sourceId, line) || 0;
+ }
+
+ return line + 1;
+}
+
+export function toEditorPosition(location) {
+ return {
+ line: toEditorLine(location.sourceId, location.line),
+ column: isWasm(location.sourceId) || !location.column ? 0 : location.column,
+ };
+}
+
+export function toEditorRange(sourceId, location) {
+ const { start, end } = location;
+ return {
+ start: toEditorPosition({ ...start, sourceId }),
+ end: toEditorPosition({ ...end, sourceId }),
+ };
+}
+
+export function toSourceLine(sourceId, line) {
+ return isWasm(sourceId) ? lineToWasmOffset(sourceId, line) : line + 1;
+}
+
+export function scrollToColumn(codeMirror, line, column) {
+ const { top, left } = codeMirror.charCoords({ line, ch: column }, "local");
+
+ if (!isVisible(codeMirror, top, left)) {
+ const scroller = codeMirror.getScrollerElement();
+ const centeredX = Math.max(left - scroller.offsetWidth / 2, 0);
+ const centeredY = Math.max(top - scroller.offsetHeight / 2, 0);
+
+ codeMirror.scrollTo(centeredX, centeredY);
+ }
+}
+
+function isVisible(codeMirror, top, left) {
+ function withinBounds(x, min, max) {
+ return x >= min && x <= max;
+ }
+
+ const scrollArea = codeMirror.getScrollInfo();
+ const charWidth = codeMirror.defaultCharWidth();
+ const fontHeight = codeMirror.defaultTextHeight();
+ const { scrollTop, scrollLeft } = codeMirror.doc;
+
+ const inXView = withinBounds(
+ left,
+ scrollLeft,
+ scrollLeft + (scrollArea.clientWidth - 30) - charWidth
+ );
+
+ const inYView = withinBounds(
+ top,
+ scrollTop,
+ scrollTop + scrollArea.clientHeight - fontHeight
+ );
+
+ return inXView && inYView;
+}
+
+export function getLocationsInViewport(
+ { codeMirror },
+ // Offset represents an allowance of characters or lines offscreen to improve
+ // perceived performance of column breakpoint rendering
+ offsetHorizontalCharacters = 100,
+ offsetVerticalLines = 20
+) {
+ // Get scroll position
+ if (!codeMirror) {
+ return {
+ start: { line: 0, column: 0 },
+ end: { line: 0, column: 0 },
+ };
+ }
+ const charWidth = codeMirror.defaultCharWidth();
+ const scrollArea = codeMirror.getScrollInfo();
+ const { scrollLeft } = codeMirror.doc;
+ const rect = codeMirror.getWrapperElement().getBoundingClientRect();
+ const topVisibleLine =
+ codeMirror.lineAtHeight(rect.top, "window") - offsetVerticalLines;
+ const bottomVisibleLine =
+ codeMirror.lineAtHeight(rect.bottom, "window") + offsetVerticalLines;
+
+ const leftColumn = Math.floor(
+ scrollLeft > 0 ? scrollLeft / charWidth - offsetHorizontalCharacters : 0
+ );
+ const rightPosition = scrollLeft + (scrollArea.clientWidth - 30);
+ const rightCharacter =
+ Math.floor(rightPosition / charWidth) + offsetHorizontalCharacters;
+
+ return {
+ start: {
+ line: topVisibleLine || 0,
+ column: leftColumn || 0,
+ },
+ end: {
+ line: bottomVisibleLine || 0,
+ column: rightCharacter,
+ },
+ };
+}
+
+export function markText({ codeMirror }, className, { start, end }) {
+ return codeMirror.markText(
+ { ch: start.column, line: start.line },
+ { ch: end.column, line: end.line },
+ { className }
+ );
+}
+
+export function lineAtHeight({ codeMirror }, sourceId, event) {
+ const _editorLine = codeMirror.lineAtHeight(event.clientY);
+ return toSourceLine(sourceId, _editorLine);
+}
+
+export function getSourceLocationFromMouseEvent({ codeMirror }, source, e) {
+ const { line, ch } = codeMirror.coordsChar({
+ left: e.clientX,
+ top: e.clientY,
+ });
+
+ return createLocation({
+ source,
+ line: fromEditorLine(source.id, line, isWasm(source.id)),
+ column: isWasm(source.id) ? 0 : ch + 1,
+ });
+}
+
+export function forEachLine(codeMirror, iter) {
+ codeMirror.operation(() => {
+ codeMirror.doc.iter(0, codeMirror.lineCount(), iter);
+ });
+}
+
+export function removeLineClass(codeMirror, line, className) {
+ codeMirror.removeLineClass(line, "wrap", className);
+}
+
+export function clearLineClass(codeMirror, className) {
+ forEachLine(codeMirror, line => {
+ removeLineClass(codeMirror, line, className);
+ });
+}
+
+export function getTextForLine(codeMirror, line) {
+ return codeMirror.getLine(line - 1).trim();
+}
+
+export function getCursorLine(codeMirror) {
+ return codeMirror.getCursor().line;
+}
+
+export function getCursorColumn(codeMirror) {
+ return codeMirror.getCursor().ch;
+}
+
+export function getTokenEnd(codeMirror, line, column) {
+ const token = codeMirror.getTokenAt({
+ line,
+ ch: column + 1,
+ });
+ const tokenString = token.string;
+
+ return tokenString === "{" || tokenString === "[" ? null : token.end;
+}
diff --git a/devtools/client/debugger/src/utils/editor/moz.build b/devtools/client/debugger/src/utils/editor/moz.build
new file mode 100644
index 0000000000..655c0dae43
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/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(
+ "create-editor.js",
+ "get-expression.js",
+ "get-token-location.js",
+ "index.js",
+ "source-documents.js",
+ "source-editor.js",
+ "source-search.js",
+ "token-events.js",
+)
diff --git a/devtools/client/debugger/src/utils/editor/source-documents.js b/devtools/client/debugger/src/utils/editor/source-documents.js
new file mode 100644
index 0000000000..25040eb7cd
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/source-documents.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 { isWasm, getWasmLineNumberFormatter, renderWasmText } from "../wasm";
+import { isMinified } from "../isMinified";
+import { resizeBreakpointGutter, resizeToggleButton } from "../ui";
+import { javascriptLikeExtensions } from "../source";
+
+let sourceDocs = {};
+
+export function getDocument(key) {
+ return sourceDocs[key];
+}
+
+export function hasDocument(key) {
+ return !!getDocument(key);
+}
+
+export function setDocument(key, doc) {
+ sourceDocs[key] = doc;
+}
+
+export function removeDocument(key) {
+ delete sourceDocs[key];
+}
+
+export function clearDocuments() {
+ sourceDocs = {};
+}
+
+function resetLineNumberFormat(editor) {
+ const cm = editor.codeMirror;
+ cm.setOption("lineNumberFormatter", number => number);
+ resizeBreakpointGutter(cm);
+ resizeToggleButton(cm);
+}
+
+function updateLineNumberFormat(editor, sourceId) {
+ if (!isWasm(sourceId)) {
+ resetLineNumberFormat(editor);
+ return;
+ }
+ const cm = editor.codeMirror;
+ const lineNumberFormatter = getWasmLineNumberFormatter(sourceId);
+ cm.setOption("lineNumberFormatter", lineNumberFormatter);
+ resizeBreakpointGutter(cm);
+ resizeToggleButton(cm);
+}
+
+export function updateDocument(editor, source) {
+ if (!source) {
+ return;
+ }
+
+ const sourceId = source.id;
+ const doc = getDocument(sourceId) || editor.createDocument();
+ editor.replaceDocument(doc);
+
+ updateLineNumberFormat(editor, sourceId);
+}
+
+/* used to apply the context menu wrap line option change to all the docs */
+export function updateDocuments(updater) {
+ for (const key in sourceDocs) {
+ if (sourceDocs[key].cm == null) {
+ continue;
+ } else {
+ updater(sourceDocs[key]);
+ }
+ }
+}
+
+export function clearEditor(editor) {
+ const doc = editor.createDocument("", { name: "text" });
+ editor.replaceDocument(doc);
+ resetLineNumberFormat(editor);
+}
+
+export function showLoading(editor) {
+ let doc = getDocument("loading");
+
+ if (doc) {
+ editor.replaceDocument(doc);
+ } else {
+ doc = editor.createDocument(L10N.getStr("loadingText"), { name: "text" });
+ setDocument("loading", doc);
+ }
+}
+
+export function showErrorMessage(editor, msg) {
+ let error;
+ if (msg.includes("WebAssembly binary source is not available")) {
+ error = L10N.getStr("wasmIsNotAvailable");
+ } else {
+ error = L10N.getFormatStr("errorLoadingText3", msg);
+ }
+ const doc = editor.createDocument(error, { name: "text" });
+ editor.replaceDocument(doc);
+ resetLineNumberFormat(editor);
+}
+
+const contentTypeModeMap = new Map([
+ ["text/javascript", { name: "javascript" }],
+ ["text/typescript", { name: "javascript", typescript: true }],
+ ["text/coffeescript", { name: "coffeescript" }],
+ [
+ "text/typescript-jsx",
+ {
+ name: "jsx",
+ base: { name: "javascript", typescript: true },
+ },
+ ],
+ ["text/jsx", { name: "jsx" }],
+ ["text/x-elm", { name: "elm" }],
+ ["text/x-clojure", { name: "clojure" }],
+ ["text/x-clojurescript", { name: "clojure" }],
+ ["text/wasm", { name: "text" }],
+ ["text/html", { name: "htmlmixed" }],
+ ["text/plain", { name: "text" }],
+]);
+
+const nonJSLanguageExtensionMap = new Map([
+ ["c", { name: "text/x-csrc" }],
+ ["kt", { name: "text/x-kotlin" }],
+ ["cpp", { name: "text/x-c++src" }],
+ ["m", { name: "text/x-objectivec" }],
+ ["rs", { name: "text/x-rustsrc" }],
+ ["hx", { name: "text/x-haxe" }],
+]);
+
+/**
+ * Returns Code Mirror mode for source content type
+ */
+// eslint-disable-next-line complexity
+export function getMode(source, sourceTextContent, symbols) {
+ const content = sourceTextContent.value;
+ // Disable modes for minified files with 1+ million characters (See Bug 1569829).
+ if (
+ content.type === "text" &&
+ isMinified(source, sourceTextContent) &&
+ content.value.length > 1000000
+ ) {
+ return contentTypeModeMap.get("text/plain");
+ }
+
+ if (content.type !== "text") {
+ return contentTypeModeMap.get("text/plain");
+ }
+
+ const extension = source.displayURL.fileExtension;
+ if (extension === "jsx" || (symbols && symbols.hasJsx)) {
+ if (symbols && symbols.hasTypes) {
+ return contentTypeModeMap.get("text/typescript-jsx");
+ }
+ return contentTypeModeMap.get("text/jsx");
+ }
+
+ if (symbols && symbols.hasTypes) {
+ if (symbols.hasJsx) {
+ return contentTypeModeMap.get("text/typescript-jsx");
+ }
+
+ return contentTypeModeMap.get("text/typescript");
+ }
+
+ // check for C and other non JS languages
+ if (nonJSLanguageExtensionMap.has(extension)) {
+ return nonJSLanguageExtensionMap.get(extension);
+ }
+
+ // if the url ends with a known Javascript-like URL, provide JavaScript mode.
+ if (javascriptLikeExtensions.has(extension)) {
+ return contentTypeModeMap.get("text/javascript");
+ }
+
+ const { contentType, value: text } = content;
+ // Use HTML mode for files in which the first non whitespace
+ // character is `<` regardless of extension.
+ const isHTMLLike = () => text.match(/^\s*</);
+ if (!contentType) {
+ if (isHTMLLike()) {
+ return contentTypeModeMap.get("text/html");
+ }
+ return contentTypeModeMap.get("text/plain");
+ }
+
+ // // @flow or /* @flow */
+ if (text.match(/^\s*(\/\/ @flow|\/\* @flow \*\/)/)) {
+ return contentTypeModeMap.get("text/typescript");
+ }
+
+ if (contentTypeModeMap.has(contentType)) {
+ return contentTypeModeMap.get(contentType);
+ }
+
+ if (isHTMLLike()) {
+ return contentTypeModeMap.get("text/html");
+ }
+
+ return contentTypeModeMap.get("text/plain");
+}
+
+function setMode(editor, source, sourceTextContent, symbols) {
+ const mode = getMode(source, sourceTextContent, symbols);
+ const currentMode = editor.codeMirror.getOption("mode");
+ if (!currentMode || currentMode.name != mode.name) {
+ editor.setMode(mode);
+ }
+}
+
+/**
+ * Handle getting the source document or creating a new
+ * document with the correct mode and text.
+ */
+export function showSourceText(editor, source, sourceTextContent, symbols) {
+ if (hasDocument(source.id)) {
+ const doc = getDocument(source.id);
+ if (editor.codeMirror.doc === doc) {
+ setMode(editor, source, sourceTextContent, symbols);
+ return;
+ }
+
+ editor.replaceDocument(doc);
+ updateLineNumberFormat(editor, source.id);
+ setMode(editor, source, sourceTextContent, symbols);
+ return;
+ }
+
+ const content = sourceTextContent.value;
+
+ const doc = editor.createDocument(
+ // We can set wasm text content directly from the constructor, so we pass an empty string
+ // here, and set the text after replacing the document.
+ content.type !== "wasm" ? content.value : "",
+ getMode(source, sourceTextContent, symbols)
+ );
+
+ setDocument(source.id, doc);
+ editor.replaceDocument(doc);
+
+ if (content.type === "wasm") {
+ const wasmLines = renderWasmText(source.id, content);
+ // cm will try to split into lines anyway, saving memory
+ editor.setText({ split: () => wasmLines, match: () => false });
+ }
+
+ updateLineNumberFormat(editor, source.id);
+}
diff --git a/devtools/client/debugger/src/utils/editor/source-editor.css b/devtools/client/debugger/src/utils/editor/source-editor.css
new file mode 100644
index 0000000000..b2ae305657
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/source-editor.css
@@ -0,0 +1,271 @@
+/* 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/. */
+
+:root {
+ --breakpoint-active-color: rgba(44, 187, 15, 0.2);
+ --breakpoint-active-color-hover: rgba(44, 187, 15, 0.5);
+ --debug-line-background: rgba(226, 236, 247, 0.5);
+ --debug-line-border: rgb(145, 188, 219);
+}
+
+.theme-dark:root {
+ --debug-line-background: rgb(73, 82, 103);
+ --debug-line-border: rgb(119, 134, 162);
+ --breakpoint-active-color: rgba(45, 210, 158, 0.5);
+ --breakpoint-active-color-hover: rgba(0, 255, 175, 0.7);
+}
+
+.CodeMirror .errors {
+ width: 16px;
+}
+
+.CodeMirror .error {
+ display: inline-block;
+ margin-left: 5px;
+ width: 12px;
+ height: 12px;
+ opacity: 0.75;
+}
+
+.CodeMirror .hit-counts {
+ width: 6px;
+}
+
+.CodeMirror .hit-count {
+ display: inline-block;
+ height: 12px;
+ border: solid rgba(0, 0, 0, 0.2);
+ border-width: 1px 1px 1px 0;
+ border-radius: 0 3px 3px 0;
+ padding: 0 3px;
+ font-size: 10px;
+ pointer-events: none;
+}
+
+.theme-dark .debug-line .CodeMirror-linenumber {
+ color: #c0c0c0;
+}
+
+.debug-line .CodeMirror-line {
+ background-color: var(--debug-line-background) !important;
+ outline: var(--debug-line-border) solid 1px;
+}
+
+/* Don't display the highlight color since the debug line
+ is already highlighted */
+.debug-line .CodeMirror-activeline-background {
+ display: none;
+}
+
+.CodeMirror {
+ cursor: text;
+ height: 100%;
+}
+
+.CodeMirror-gutters {
+ cursor: default;
+}
+
+/* This is to avoid the fake horizontal scrollbar div of codemirror to go 0
+height when floating scrollbars are active. Make sure that this value is equal
+to the maximum of `min-height` specific to the `scrollbar[orient="horizontal"]`
+selector in floating-scrollbar-light.css across all platforms. */
+.CodeMirror-hscrollbar {
+ min-height: 10px;
+}
+
+/* This is to avoid the fake vertical scrollbar div of codemirror to go 0
+width when floating scrollbars are active. Make sure that this value is equal
+to the maximum of `min-width` specific to the `scrollbar[orient="vertical"]`
+selector in floating-scrollbar-light.css across all platforms. */
+.CodeMirror-vscrollbar {
+ min-width: 10px;
+}
+
+.cm-trailingspace {
+ background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAACCAYAAAB/qH1jAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3QUXCToH00Y1UgAAACFJREFUCNdjPMDBUc/AwNDAAAFMTAwMDA0OP34wQgX/AQBYgwYEx4f9lQAAAABJRU5ErkJggg==");
+ opacity: 0.75;
+ background-position: left bottom;
+ background-repeat: repeat-x;
+}
+
+/* CodeMirror dialogs styling */
+
+.CodeMirror-dialog {
+ padding: 4px 3px;
+}
+
+.CodeMirror-dialog,
+.CodeMirror-dialog input {
+ font: message-box;
+}
+
+/* Fold addon */
+
+.CodeMirror-foldmarker {
+ color: blue;
+ text-shadow: #b9f 1px 1px 2px, #b9f -1px -1px 2px, #b9f 1px -1px 2px,
+ #b9f -1px 1px 2px;
+ font-family: sans-serif;
+ line-height: 0.3;
+ cursor: pointer;
+}
+
+.CodeMirror-foldgutter {
+ width: 10px;
+}
+
+.CodeMirror-foldgutter-open,
+.CodeMirror-foldgutter-folded {
+ color: #555;
+ cursor: pointer;
+ line-height: 1;
+ padding: 0 1px;
+}
+
+.CodeMirror-foldgutter-open::after,
+.CodeMirror-foldgutter-open::before,
+.CodeMirror-foldgutter-folded::after,
+.CodeMirror-foldgutter-folded::before {
+ content: "";
+ height: 0;
+ width: 0;
+ position: absolute;
+ border: 4px solid transparent;
+}
+
+.CodeMirror-foldgutter-open::after {
+ border-top-color: var(--theme-codemirror-gutter-background);
+ top: 4px;
+}
+
+.CodeMirror-foldgutter-open::before {
+ border-top-color: var(--theme-body-color);
+ top: 5px;
+}
+
+.new-breakpoint .CodeMirror-foldgutter-open::after {
+ border-top-color: var(--theme-selection-background);
+}
+
+.new-breakpoint .CodeMirror-foldgutter-open::before {
+ border-top-color: white;
+}
+
+.CodeMirror-foldgutter-folded::after {
+ border-left-color: var(--theme-codemirror-gutter-background);
+ left: 3px;
+ top: 3px;
+}
+
+.CodeMirror-foldgutter-folded::before {
+ border-left-color: var(--theme-body-color);
+ left: 4px;
+ top: 3px;
+}
+
+.new-breakpoint .CodeMirror-foldgutter-folded::after {
+ border-left-color: var(--theme-selection-background);
+}
+
+.new-breakpoint .CodeMirror-foldgutter-folded::before {
+ border-left-color: white;
+}
+
+.CodeMirror-hints {
+ position: absolute;
+ z-index: 10;
+ overflow: hidden;
+ list-style: none;
+ margin: 0;
+ padding: 2px;
+ border-radius: 3px;
+ font-size: 90%;
+ max-height: 20em;
+ overflow-y: auto;
+}
+
+.CodeMirror-hint {
+ margin: 0;
+ padding: 0 4px;
+ border-radius: 2px;
+ max-width: 19em;
+ overflow: hidden;
+ white-space: pre;
+ cursor: pointer;
+}
+
+.CodeMirror-Tern-completion {
+ padding-inline-start: 22px;
+ position: relative;
+ line-height: 18px;
+}
+
+.CodeMirror-Tern-completion:before {
+ position: absolute;
+ left: 2px;
+ bottom: 2px;
+ border-radius: 50%;
+ font-size: 12px;
+ font-weight: bold;
+ height: 15px;
+ width: 15px;
+ line-height: 16px;
+ text-align: center;
+ color: #ffffff;
+ box-sizing: border-box;
+}
+
+.CodeMirror-Tern-completion-unknown:before {
+ content: "?";
+}
+
+.CodeMirror-Tern-completion-object:before {
+ content: "O";
+}
+
+.CodeMirror-Tern-completion-fn:before {
+ content: "F";
+}
+
+.CodeMirror-Tern-completion-array:before {
+ content: "A";
+}
+
+.CodeMirror-Tern-completion-number:before {
+ content: "N";
+}
+
+.CodeMirror-Tern-completion-string:before {
+ content: "S";
+}
+
+.CodeMirror-Tern-completion-bool:before {
+ content: "B";
+}
+
+.CodeMirror-Tern-completion-guess {
+ color: #999;
+}
+
+.CodeMirror-Tern-tooltip {
+ border-radius: 3px;
+ padding: 2px 5px;
+ white-space: pre-wrap;
+ max-width: 40em;
+ position: absolute;
+ z-index: 10;
+}
+
+.CodeMirror-Tern-hint-doc {
+ max-width: 25em;
+}
+
+.CodeMirror-Tern-farg-current {
+ text-decoration: underline;
+}
+
+.CodeMirror-Tern-fhint-guess {
+ opacity: 0.7;
+}
diff --git a/devtools/client/debugger/src/utils/editor/source-editor.js b/devtools/client/debugger/src/utils/editor/source-editor.js
new file mode 100644
index 0000000000..8501e22a55
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/source-editor.js
@@ -0,0 +1,145 @@
+/* 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/>. */
+
+/**
+ * CodeMirror source editor utils
+ * @module utils/source-editor
+ */
+
+const CodeMirror = require("codemirror");
+
+require("raw!chrome://devtools/content/shared/sourceeditor/codemirror/lib/codemirror.css");
+require("codemirror/mode/javascript/javascript");
+require("codemirror/mode/htmlmixed/htmlmixed");
+require("codemirror/mode/coffeescript/coffeescript");
+require("codemirror/mode/jsx/jsx");
+require("codemirror/mode/elm/elm");
+require("codemirror/mode/clojure/clojure");
+require("codemirror/mode/haxe/haxe");
+require("codemirror/addon/search/searchcursor");
+require("codemirror/addon/fold/foldcode");
+require("codemirror/addon/fold/brace-fold");
+require("codemirror/addon/fold/indent-fold");
+require("codemirror/addon/fold/foldgutter");
+require("codemirror/addon/runmode/runmode");
+require("codemirror/addon/selection/active-line");
+require("codemirror/addon/edit/matchbrackets");
+require("codemirror/addon/display/placeholder");
+require("codemirror/mode/clike/clike");
+require("codemirror/mode/rust/rust");
+
+require("raw!chrome://devtools/content/debugger/src/utils/editor/source-editor.css");
+
+// NOTE: we should eventually use debugger-html context type mode
+
+// Maximum allowed margin (in number of lines) from top or bottom of the editor
+// while shifting to a line which was initially out of view.
+const MAX_VERTICAL_OFFSET = 3;
+
+export default class SourceEditor {
+ opts;
+ editor;
+
+ constructor(opts) {
+ this.opts = opts;
+ }
+
+ appendToLocalElement(node) {
+ this.editor = CodeMirror(node, this.opts);
+ }
+
+ destroy() {
+ // Unlink the current document.
+ if (this.editor.doc) {
+ this.editor.doc.cm = null;
+ }
+ }
+
+ get codeMirror() {
+ return this.editor;
+ }
+
+ get CodeMirror() {
+ return CodeMirror;
+ }
+
+ setText(str) {
+ this.editor.setValue(str);
+ }
+
+ getText() {
+ return this.editor.getValue();
+ }
+
+ setMode(value) {
+ this.editor.setOption("mode", value);
+ }
+
+ /**
+ * Replaces the current document with a new source document
+ * @memberof utils/source-editor
+ */
+ replaceDocument(doc) {
+ this.editor.swapDoc(doc);
+ }
+
+ /**
+ * Creates a CodeMirror Document
+ * @returns CodeMirror.Doc
+ * @memberof utils/source-editor
+ */
+ createDocument() {
+ return new CodeMirror.Doc("");
+ }
+
+ /**
+ * Aligns the provided line to either "top", "center" or "bottom" of the
+ * editor view with a maximum margin of MAX_VERTICAL_OFFSET lines from top or
+ * bottom.
+ * @memberof utils/source-editor
+ */
+ alignLine(line, align = "top") {
+ const cm = this.editor;
+ const editorClientRect = cm.getWrapperElement().getBoundingClientRect();
+
+ const from = cm.lineAtHeight(editorClientRect.top, "page");
+ const to = cm.lineAtHeight(
+ editorClientRect.height + editorClientRect.top,
+ "page"
+ );
+
+ const linesVisible = to - from;
+ const halfVisible = Math.round(linesVisible / 2);
+
+ // If the target line is in view, skip the vertical alignment part.
+ if (line <= to && line >= from) {
+ return;
+ }
+
+ // Setting the offset so that the line always falls in the upper half
+ // of visible lines (lower half for bottom aligned).
+ // MAX_VERTICAL_OFFSET is the maximum allowed value.
+ const offset = Math.min(halfVisible, MAX_VERTICAL_OFFSET);
+
+ let topLine =
+ {
+ center: Math.max(line - halfVisible, 0),
+ bottom: Math.max(line - linesVisible + offset, 0),
+ top: Math.max(line - offset, 0),
+ }[align || "top"] || offset;
+
+ // Bringing down the topLine to total lines in the editor if exceeding.
+ topLine = Math.min(topLine, cm.lineCount());
+ this.setFirstVisibleLine(topLine);
+ }
+
+ /**
+ * Scrolls the view such that the given line number is the first visible line.
+ * @memberof utils/source-editor
+ */
+ setFirstVisibleLine(line) {
+ const { top } = this.editor.charCoords({ line, ch: 0 }, "local");
+ this.editor.scrollTo(0, top);
+ }
+}
diff --git a/devtools/client/debugger/src/utils/editor/source-search.js b/devtools/client/debugger/src/utils/editor/source-search.js
new file mode 100644
index 0000000000..92097377ba
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/source-search.js
@@ -0,0 +1,327 @@
+/* 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 buildQuery from "../build-query";
+
+/**
+ * @memberof utils/source-search
+ * @static
+ */
+function getSearchCursor(cm, query, pos, modifiers) {
+ const regexQuery = buildQuery(query, modifiers, { isGlobal: true });
+ return cm.getSearchCursor(regexQuery, pos);
+}
+
+/**
+ * @memberof utils/source-search
+ * @static
+ */
+function SearchState() {
+ this.posFrom = this.posTo = this.query = null;
+ this.overlay = null;
+ this.results = [];
+}
+
+/**
+ * @memberof utils/source-search
+ * @static
+ */
+function getSearchState(cm, query) {
+ const state = cm.state.search || (cm.state.search = new SearchState());
+ return state;
+}
+
+function isWhitespace(query) {
+ return !query.match(/\S/);
+}
+
+/**
+ * This returns a mode object used by CodeMirror's addOverlay function
+ * to parse and style tokens in the file.
+ * The mode object contains a tokenizer function (token) which takes
+ * a character stream as input, advances it a character at a time,
+ * and returns style(s) for that token. For more details see
+ * https://codemirror.net/5/doc/manual.html#modeapi
+ *
+ * @memberof utils/source-search
+ * @static
+ */
+function searchOverlay(query, modifiers) {
+ const regexQuery = buildQuery(query, modifiers, {
+ ignoreSpaces: true,
+ // regex must be global for the overlay
+ isGlobal: true,
+ });
+
+ return {
+ token: function (stream, state) {
+ // set the last index to be the current stream position
+ // this acts as an offset
+ regexQuery.lastIndex = stream.pos;
+ const match = regexQuery.exec(stream.string);
+ if (match && match.index === stream.pos) {
+ // if we have a match at the current stream position
+ // set the class for a match
+ stream.pos += match[0].length || 1;
+ return "highlight highlight-full";
+ }
+
+ if (match) {
+ // if we have a match somewhere in the line, go to that point in the
+ // stream
+ stream.pos = match.index;
+ } else {
+ // if we have no matches in this line, skip to the end of the line
+ stream.skipToEnd();
+ }
+
+ return null;
+ },
+ };
+}
+
+/**
+ * @memberof utils/source-search
+ * @static
+ */
+function updateOverlay(cm, state, query, modifiers) {
+ cm.removeOverlay(state.overlay);
+ state.overlay = searchOverlay(query, modifiers);
+ cm.addOverlay(state.overlay, { opaque: false });
+}
+
+function updateCursor(cm, state, keepSelection) {
+ state.posTo = cm.getCursor("anchor");
+ state.posFrom = cm.getCursor("head");
+
+ if (!keepSelection) {
+ state.posTo = { line: 0, ch: 0 };
+ state.posFrom = { line: 0, ch: 0 };
+ }
+}
+
+export function getMatchIndex(count, currentIndex, rev) {
+ if (!rev) {
+ if (currentIndex == count - 1) {
+ return 0;
+ }
+
+ return currentIndex + 1;
+ }
+
+ if (currentIndex == 0) {
+ return count - 1;
+ }
+
+ return currentIndex - 1;
+}
+
+/**
+ * If there's a saved search, selects the next results.
+ * Otherwise, creates a new search and selects the first
+ * result.
+ *
+ * @memberof utils/source-search
+ * @static
+ */
+function doSearch(
+ ctx,
+ rev,
+ query,
+ keepSelection,
+ modifiers,
+ focusFirstResult = true
+) {
+ const { cm, ed } = ctx;
+ if (!cm) {
+ return null;
+ }
+ const defaultIndex = { line: -1, ch: -1 };
+
+ return cm.operation(function () {
+ if (!query || isWhitespace(query)) {
+ clearSearch(cm, query);
+ return null;
+ }
+
+ const state = getSearchState(cm, query);
+ const isNewQuery = state.query !== query;
+ state.query = query;
+
+ updateOverlay(cm, state, query, modifiers);
+ updateCursor(cm, state, keepSelection);
+ const searchLocation = searchNext(ctx, rev, query, isNewQuery, modifiers);
+
+ // We don't want to jump the editor
+ // when we're selecting text
+ if (!cm.state.selectingText && searchLocation && focusFirstResult) {
+ ed.alignLine(searchLocation.from.line, "center");
+ cm.setSelection(searchLocation.from, searchLocation.to);
+ }
+
+ return searchLocation ? searchLocation.from : defaultIndex;
+ });
+}
+
+export function searchSourceForHighlight(
+ ctx,
+ rev,
+ query,
+ keepSelection,
+ modifiers,
+ line,
+ ch
+) {
+ const { cm } = ctx;
+ if (!cm) {
+ return;
+ }
+
+ cm.operation(function () {
+ const state = getSearchState(cm, query);
+ const isNewQuery = state.query !== query;
+ state.query = query;
+
+ updateOverlay(cm, state, query, modifiers);
+ updateCursor(cm, state, keepSelection);
+ findNextOnLine(ctx, rev, query, isNewQuery, modifiers, line, ch);
+ });
+}
+
+function getCursorPos(newQuery, rev, state) {
+ if (newQuery) {
+ return rev ? state.posFrom : state.posTo;
+ }
+
+ return rev ? state.posTo : state.posFrom;
+}
+
+/**
+ * Selects the next result of a saved search.
+ *
+ * @memberof utils/source-search
+ * @static
+ */
+function searchNext(ctx, rev, query, newQuery, modifiers) {
+ const { cm } = ctx;
+ let nextMatch;
+ cm.operation(function () {
+ const state = getSearchState(cm, query);
+ const pos = getCursorPos(newQuery, rev, state);
+
+ if (!state.query) {
+ return;
+ }
+
+ let cursor = getSearchCursor(cm, state.query, pos, modifiers);
+
+ const location = rev
+ ? { line: cm.lastLine(), ch: null }
+ : { line: cm.firstLine(), ch: 0 };
+
+ if (!cursor.find(rev) && state.query) {
+ cursor = getSearchCursor(cm, state.query, location, modifiers);
+ if (!cursor.find(rev)) {
+ return;
+ }
+ }
+
+ nextMatch = { from: cursor.from(), to: cursor.to() };
+ });
+
+ return nextMatch;
+}
+
+function findNextOnLine(ctx, rev, query, newQuery, modifiers, line, ch) {
+ const { cm, ed } = ctx;
+ cm.operation(function () {
+ const pos = { line: line - 1, ch };
+ let cursor = getSearchCursor(cm, query, pos, modifiers);
+
+ if (!cursor.find(rev) && query) {
+ cursor = getSearchCursor(cm, query, pos, modifiers);
+ if (!cursor.find(rev)) {
+ return;
+ }
+ }
+
+ // We don't want to jump the editor
+ // when we're selecting text
+ if (!cm.state.selectingText) {
+ ed.alignLine(cursor.from().line, "center");
+ cm.setSelection(cursor.from(), cursor.to());
+ }
+ });
+}
+
+/**
+ * Remove overlay.
+ *
+ * @memberof utils/source-search
+ * @static
+ */
+export function removeOverlay(ctx, query) {
+ const state = getSearchState(ctx.cm, query);
+ ctx.cm.removeOverlay(state.overlay);
+ const { line, ch } = ctx.cm.getCursor();
+ ctx.cm.doc.setSelection({ line, ch }, { line, ch }, { scroll: false });
+}
+
+/**
+ * Clears the currently saved search.
+ *
+ * @memberof utils/source-search
+ * @static
+ */
+export function clearSearch(cm, query) {
+ const state = getSearchState(cm, query);
+
+ state.results = [];
+
+ if (!state.query) {
+ return;
+ }
+ cm.removeOverlay(state.overlay);
+ state.query = null;
+}
+
+/**
+ * Starts a new search.
+ *
+ * @memberof utils/source-search
+ * @static
+ */
+export function find(ctx, query, keepSelection, modifiers, focusFirstResult) {
+ clearSearch(ctx.cm, query);
+ return doSearch(
+ ctx,
+ false,
+ query,
+ keepSelection,
+ modifiers,
+ focusFirstResult
+ );
+}
+
+/**
+ * Finds the next item based on the currently saved search.
+ *
+ * @memberof utils/source-search
+ * @static
+ */
+export function findNext(ctx, query, keepSelection, modifiers) {
+ return doSearch(ctx, false, query, keepSelection, modifiers);
+}
+
+/**
+ * Finds the previous item based on the currently saved search.
+ *
+ * @memberof utils/source-search
+ * @static
+ */
+export function findPrev(ctx, query, keepSelection, modifiers) {
+ return doSearch(ctx, true, query, keepSelection, modifiers);
+}
+
+export { buildQuery };
diff --git a/devtools/client/debugger/src/utils/editor/tests/__snapshots__/create-editor.spec.js.snap b/devtools/client/debugger/src/utils/editor/tests/__snapshots__/create-editor.spec.js.snap
new file mode 100644
index 0000000000..f5bba6cd3e
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/tests/__snapshots__/create-editor.spec.js.snap
@@ -0,0 +1,60 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`createEditor Adds codeFolding 1`] = `
+Object {
+ "cursorBlinkRate": 530,
+ "enableCodeFolding": true,
+ "extraKeys": Object {
+ "Cmd-F": false,
+ "Cmd-G": false,
+ "Ctrl-F": false,
+ "Ctrl-G": false,
+ "Esc": false,
+ },
+ "foldGutter": true,
+ "gutters": Array [
+ "breakpoints",
+ "hit-markers",
+ "CodeMirror-linenumbers",
+ "CodeMirror-foldgutter",
+ ],
+ "lineNumbers": true,
+ "lineWrapping": false,
+ "matchBrackets": true,
+ "mode": "javascript",
+ "readOnly": true,
+ "showAnnotationRuler": true,
+ "styleActiveLine": false,
+ "theme": "mozilla",
+ "value": " ",
+}
+`;
+
+exports[`createEditor Returns a SourceEditor 1`] = `
+Object {
+ "cursorBlinkRate": 530,
+ "enableCodeFolding": false,
+ "extraKeys": Object {
+ "Cmd-F": false,
+ "Cmd-G": false,
+ "Ctrl-F": false,
+ "Ctrl-G": false,
+ "Esc": false,
+ },
+ "foldGutter": false,
+ "gutters": Array [
+ "breakpoints",
+ "hit-markers",
+ "CodeMirror-linenumbers",
+ ],
+ "lineNumbers": true,
+ "lineWrapping": false,
+ "matchBrackets": true,
+ "mode": "javascript",
+ "readOnly": true,
+ "showAnnotationRuler": true,
+ "styleActiveLine": false,
+ "theme": "mozilla",
+ "value": " ",
+}
+`;
diff --git a/devtools/client/debugger/src/utils/editor/tests/create-editor.spec.js b/devtools/client/debugger/src/utils/editor/tests/create-editor.spec.js
new file mode 100644
index 0000000000..38e7241b2e
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/tests/create-editor.spec.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/>. */
+
+import { createEditor } from "../create-editor";
+import SourceEditor from "../source-editor";
+
+import { features } from "../../prefs";
+
+describe("createEditor", () => {
+ test("Returns a SourceEditor", () => {
+ const editor = createEditor();
+ expect(editor).toBeInstanceOf(SourceEditor);
+ expect(editor.opts).toMatchSnapshot();
+ expect(editor.opts.gutters).not.toContain("CodeMirror-foldgutter");
+ });
+
+ test("Adds codeFolding", () => {
+ features.codeFolding = true;
+ const editor = createEditor();
+ expect(editor).toBeInstanceOf(SourceEditor);
+ expect(editor.opts).toMatchSnapshot();
+ expect(editor.opts.gutters).toContain("CodeMirror-foldgutter");
+ });
+});
diff --git a/devtools/client/debugger/src/utils/editor/tests/editor.spec.js b/devtools/client/debugger/src/utils/editor/tests/editor.spec.js
new file mode 100644
index 0000000000..d657437b19
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/tests/editor.spec.js
@@ -0,0 +1,203 @@
+/* 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 {
+ toEditorLine,
+ toEditorPosition,
+ toEditorRange,
+ toSourceLine,
+ scrollToColumn,
+ markText,
+ lineAtHeight,
+ getSourceLocationFromMouseEvent,
+ forEachLine,
+ removeLineClass,
+ clearLineClass,
+ getTextForLine,
+ getCursorLine,
+} from "../index";
+
+import { makeMockSource } from "../../test-mockup";
+
+describe("toEditorLine", () => {
+ it("returns an editor line", () => {
+ const testId = "test-123";
+ const line = 30;
+ expect(toEditorLine(testId, line)).toEqual(29);
+ });
+});
+
+describe("toEditorPosition", () => {
+ it("returns an editor position", () => {
+ const loc = { source: { id: "source" }, line: 100, column: 25 };
+ expect(toEditorPosition(loc)).toEqual({
+ line: 99,
+ column: 25,
+ });
+ });
+});
+
+describe("toEditorRange", () => {
+ it("returns an editor range", () => {
+ const testId = "test-123";
+ const loc = {
+ start: { source: { id: testId }, line: 100, column: 25 },
+ end: { source: { id: testId }, line: 200, column: 0 },
+ };
+ expect(toEditorRange(testId, loc)).toEqual({
+ start: { line: 99, column: 25 },
+ end: { line: 199, column: 0 },
+ });
+ });
+});
+
+describe("toSourceLine", () => {
+ it("returns a source line", () => {
+ const testId = "test-123";
+ const line = 30;
+ expect(toSourceLine(testId, line)).toEqual(31);
+ });
+});
+
+const codeMirror = {
+ doc: {
+ iter: jest.fn((_, __, cb) => cb()),
+ },
+ lineCount: jest.fn(() => 100),
+ getLine: jest.fn(() => "something"),
+ getCursor: jest.fn(() => ({ line: 3 })),
+ getScrollerElement: jest.fn(() => ({
+ offsetWidth: 100,
+ offsetHeight: 100,
+ })),
+ getScrollInfo: () => ({
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+ clientHeight: 100,
+ clientWidth: 100,
+ }),
+ removeLineClass: jest.fn(),
+ operation: jest.fn(cb => cb()),
+ charCoords: jest.fn(() => ({
+ top: 100,
+ right: 50,
+ bottom: 100,
+ left: 50,
+ })),
+ coordsChar: jest.fn(() => ({ line: 6, ch: 30 })),
+ lineAtHeight: jest.fn(() => 300),
+ markText: jest.fn(),
+ scrollTo: jest.fn(),
+ defaultCharWidth: jest.fn(() => 8),
+ defaultTextHeight: jest.fn(() => 16),
+};
+
+const editor = { codeMirror };
+
+describe("scrollToColumn", () => {
+ it("calls codemirror APIs charCoords, getScrollerElement, scrollTo", () => {
+ scrollToColumn(codeMirror, 60, 123);
+ expect(codeMirror.charCoords).toHaveBeenCalledWith(
+ { line: 60, ch: 123 },
+ "local"
+ );
+ expect(codeMirror.scrollTo).toHaveBeenCalledWith(0, 50);
+ });
+});
+
+describe("markText", () => {
+ it("calls codemirror API markText & returns marker", () => {
+ const loc = {
+ start: { line: 10, column: 0 },
+ end: { line: 30, column: 50 },
+ };
+ markText(editor, "test-123", loc);
+ expect(codeMirror.markText).toHaveBeenCalledWith(
+ { ch: loc.start.column, line: loc.start.line },
+ { ch: loc.end.column, line: loc.end.line },
+ { className: "test-123" }
+ );
+ });
+});
+
+describe("lineAtHeight", () => {
+ it("calls codemirror API lineAtHeight", () => {
+ const e = { clientX: 30, clientY: 60 };
+ expect(lineAtHeight(editor, "test-123", e)).toEqual(301);
+ expect(editor.codeMirror.lineAtHeight).toHaveBeenCalledWith(e.clientY);
+ });
+});
+
+describe("getSourceLocationFromMouseEvent", () => {
+ it("calls codemirror API coordsChar & returns location", () => {
+ const source = makeMockSource(undefined, "test-123");
+ const e = { clientX: 30, clientY: 60 };
+ expect(getSourceLocationFromMouseEvent(editor, source, e)).toEqual({
+ source,
+ sourceId: source.id,
+ line: 7,
+ column: 31,
+ sourceActorId: undefined,
+ sourceActor: null,
+ sourceUrl: "",
+ });
+ expect(editor.codeMirror.coordsChar).toHaveBeenCalledWith({
+ left: 30,
+ top: 60,
+ });
+ });
+});
+
+describe("forEachLine", () => {
+ it("calls codemirror API operation && doc.iter across a doc", () => {
+ const test = jest.fn();
+ forEachLine(codeMirror, test);
+ expect(codeMirror.operation).toHaveBeenCalled();
+ expect(codeMirror.doc.iter).toHaveBeenCalledWith(0, 100, test);
+ });
+});
+
+describe("removeLineClass", () => {
+ it("calls codemirror API removeLineClass", () => {
+ const line = 3;
+ const className = "test-class";
+ removeLineClass(codeMirror, line, className);
+ expect(codeMirror.removeLineClass).toHaveBeenCalledWith(
+ line,
+ "wrap",
+ className
+ );
+ });
+});
+
+describe("clearLineClass", () => {
+ it("Uses forEachLine & removeLineClass to clear class on all lines", () => {
+ codeMirror.operation.mockClear();
+ codeMirror.doc.iter.mockClear();
+ codeMirror.removeLineClass.mockClear();
+ clearLineClass(codeMirror, "test-class");
+ expect(codeMirror.operation).toHaveBeenCalled();
+ expect(codeMirror.doc.iter).toHaveBeenCalledWith(
+ 0,
+ 100,
+ expect.any(Function)
+ );
+ expect(codeMirror.removeLineClass).toHaveBeenCalled();
+ });
+});
+
+describe("getTextForLine", () => {
+ it("calls codemirror API getLine & returns line text", () => {
+ getTextForLine(codeMirror, 3);
+ expect(codeMirror.getLine).toHaveBeenCalledWith(2);
+ });
+});
+describe("getCursorLine", () => {
+ it("calls codemirror API getCursor & returns line number", () => {
+ getCursorLine(codeMirror);
+ expect(codeMirror.getCursor).toHaveBeenCalled();
+ });
+});
diff --git a/devtools/client/debugger/src/utils/editor/tests/get-expression.spec.js b/devtools/client/debugger/src/utils/editor/tests/get-expression.spec.js
new file mode 100644
index 0000000000..65ab5152f6
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/tests/get-expression.spec.js
@@ -0,0 +1,160 @@
+/* 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 CodeMirror from "codemirror";
+import { getExpressionFromCoords } from "../get-expression";
+
+describe("get-expression", () => {
+ let isCreateTextRangeDefined;
+
+ beforeAll(() => {
+ if (document.body.createTextRange) {
+ isCreateTextRangeDefined = true;
+ } else {
+ isCreateTextRangeDefined = false;
+ // CodeMirror needs createTextRange
+ // https://discuss.codemirror.net/t/working-in-jsdom-or-node-js-natively/138/5
+ document.body.createTextRange = () => ({
+ getBoundingClientRect: jest.fn(),
+ getClientRects: () => ({}),
+ });
+ }
+ });
+
+ afterAll(() => {
+ if (!isCreateTextRangeDefined) {
+ delete document.body.createTextRange;
+ }
+ });
+
+ describe("getExpressionFromCoords", () => {
+ it("returns null when location.line is greater than the lineCount", () => {
+ const cm = CodeMirror(document.body, {
+ value: "let Line1;\n" + "let Line2;\n",
+ mode: "javascript",
+ });
+
+ const result = getExpressionFromCoords(cm, {
+ line: 3,
+ column: 1,
+ });
+ expect(result).toBeNull();
+ });
+
+ it("gets the expression using CodeMirror.getTokenAt", () => {
+ const codemirrorMock = {
+ lineCount: () => 100,
+ getTokenAt: jest.fn(() => ({ start: 0, end: 0 })),
+ doc: {
+ getLine: () => "",
+ },
+ };
+ getExpressionFromCoords(codemirrorMock, { line: 1, column: 1 });
+ expect(codemirrorMock.getTokenAt).toHaveBeenCalled();
+ });
+
+ it("requests the correct line and column from codeMirror", () => {
+ const codemirrorMock = {
+ lineCount: () => 100,
+ getTokenAt: jest.fn(() => ({ start: 0, end: 1 })),
+ doc: {
+ getLine: jest.fn(() => ""),
+ },
+ };
+ getExpressionFromCoords(codemirrorMock, { line: 20, column: 5 });
+ // getExpressionsFromCoords uses one based line indexing
+ // CodeMirror uses zero based line indexing
+ expect(codemirrorMock.getTokenAt).toHaveBeenCalledWith({
+ line: 19,
+ ch: 5,
+ });
+ expect(codemirrorMock.doc.getLine).toHaveBeenCalledWith(19);
+ });
+
+ it("when called with column 0 returns null", () => {
+ const cm = CodeMirror(document.body, {
+ value: "foo bar;\n",
+ mode: "javascript",
+ });
+
+ const result = getExpressionFromCoords(cm, {
+ line: 1,
+ column: 0,
+ });
+ expect(result).toBeNull();
+ });
+
+ it("gets the expression when first token on the line", () => {
+ const cm = CodeMirror(document.body, {
+ value: "foo bar;\n",
+ mode: "javascript",
+ });
+
+ const result = getExpressionFromCoords(cm, {
+ line: 1,
+ column: 1,
+ });
+ if (!result) {
+ throw new Error("no result");
+ }
+ expect(result.expression).toEqual("foo");
+ expect(result.location.start).toEqual({ line: 1, column: 0 });
+ expect(result.location.end).toEqual({ line: 1, column: 3 });
+ });
+
+ it("includes previous tokens in the expression", () => {
+ const cm = CodeMirror(document.body, {
+ value: "foo.bar;\n",
+ mode: "javascript",
+ });
+
+ const result = getExpressionFromCoords(cm, {
+ line: 1,
+ column: 5,
+ });
+ if (!result) {
+ throw new Error("no result");
+ }
+ expect(result.expression).toEqual("foo.bar");
+ expect(result.location.start).toEqual({ line: 1, column: 0 });
+ expect(result.location.end).toEqual({ line: 1, column: 7 });
+ });
+
+ it("includes multiple previous tokens in the expression", () => {
+ const cm = CodeMirror(document.body, {
+ value: "foo.bar.baz;\n",
+ mode: "javascript",
+ });
+
+ const result = getExpressionFromCoords(cm, {
+ line: 1,
+ column: 10,
+ });
+ if (!result) {
+ throw new Error("no result");
+ }
+ expect(result.expression).toEqual("foo.bar.baz");
+ expect(result.location.start).toEqual({ line: 1, column: 0 });
+ expect(result.location.end).toEqual({ line: 1, column: 11 });
+ });
+
+ it("does not include tokens not part of the expression", () => {
+ const cm = CodeMirror(document.body, {
+ value: "foo bar.baz;\n",
+ mode: "javascript",
+ });
+
+ const result = getExpressionFromCoords(cm, {
+ line: 1,
+ column: 10,
+ });
+ if (!result) {
+ throw new Error("no result");
+ }
+ expect(result.expression).toEqual("bar.baz");
+ expect(result.location.start).toEqual({ line: 1, column: 4 });
+ expect(result.location.end).toEqual({ line: 1, column: 11 });
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/editor/tests/get-token-location.spec.js b/devtools/client/debugger/src/utils/editor/tests/get-token-location.spec.js
new file mode 100644
index 0000000000..c4aa277f26
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/tests/get-token-location.spec.js
@@ -0,0 +1,31 @@
+/* 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 { getTokenLocation } from "../get-token-location";
+
+describe("getTokenLocation", () => {
+ const codemirror = {
+ coordsChar: jest.fn(() => ({
+ line: 1,
+ ch: "C",
+ })),
+ };
+ const token = {
+ getBoundingClientRect() {
+ return {
+ left: 10,
+ top: 20,
+ width: 10,
+ height: 10,
+ };
+ },
+ };
+ it("calls into codeMirror", () => {
+ getTokenLocation(codemirror, token);
+ expect(codemirror.coordsChar).toHaveBeenCalledWith({
+ left: 15,
+ top: 25,
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/editor/tests/source-documents.spec.js b/devtools/client/debugger/src/utils/editor/tests/source-documents.spec.js
new file mode 100644
index 0000000000..9d4b42e263
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/tests/source-documents.spec.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/>. */
+
+import { getMode } from "../source-documents.js";
+
+import {
+ makeMockSourceWithContent,
+ makeMockWasmSourceWithContent,
+} from "../../test-mockup";
+
+const defaultSymbolDeclarations = {
+ classes: [],
+ functions: [],
+ memberExpressions: [],
+ callExpressions: [],
+ objectProperties: [],
+ identifiers: [],
+ imports: [],
+ comments: [],
+ literals: [],
+ hasJsx: false,
+ hasTypes: false,
+ framework: undefined,
+};
+
+describe("source-documents", () => {
+ describe("getMode", () => {
+ it("// ", () => {
+ const source = makeMockSourceWithContent(
+ undefined,
+ undefined,
+ "text/javascript",
+ "// @flow"
+ );
+ expect(getMode(source, source.content)).toEqual({
+ name: "javascript",
+ typescript: true,
+ });
+ });
+
+ it("/* @flow */", () => {
+ const source = makeMockSourceWithContent(
+ undefined,
+ undefined,
+ "text/javascript",
+ " /* @flow */"
+ );
+ expect(getMode(source, source.content)).toEqual({
+ name: "javascript",
+ typescript: true,
+ });
+ });
+
+ it("mixed html", () => {
+ const source = makeMockSourceWithContent(
+ undefined,
+ undefined,
+ "",
+ " <html"
+ );
+ expect(getMode(source, source.content)).toEqual({ name: "htmlmixed" });
+ });
+
+ it("elm", () => {
+ const source = makeMockSourceWithContent(
+ undefined,
+ undefined,
+ "text/x-elm",
+ 'main = text "Hello, World!"'
+ );
+ expect(getMode(source, source.content)).toEqual({ name: "elm" });
+ });
+
+ it("returns jsx if contentType jsx is given", () => {
+ const source = makeMockSourceWithContent(
+ undefined,
+ undefined,
+ "text/jsx",
+ "<h1></h1>"
+ );
+ expect(getMode(source, source.content)).toEqual({ name: "jsx" });
+ });
+
+ it("returns jsx if sourceMetaData says it's a react component", () => {
+ const source = makeMockSourceWithContent(
+ undefined,
+ undefined,
+ "",
+ "<h1></h1>"
+ );
+ expect(
+ getMode(source, source.content, {
+ ...defaultSymbolDeclarations,
+ hasJsx: true,
+ })
+ ).toEqual({ name: "jsx" });
+ });
+
+ it("returns jsx if the fileExtension is .jsx", () => {
+ const source = makeMockSourceWithContent(
+ "myComponent.jsx",
+ undefined,
+ "",
+ "<h1></h1>"
+ );
+ expect(getMode(source, source.content)).toEqual({ name: "jsx" });
+ });
+
+ it("returns text/x-haxe if the file extension is .hx", () => {
+ const source = makeMockSourceWithContent(
+ "myComponent.hx",
+ undefined,
+ "",
+ "function foo(){}"
+ );
+ expect(getMode(source, source.content)).toEqual({ name: "text/x-haxe" });
+ });
+
+ it("typescript", () => {
+ const source = makeMockSourceWithContent(
+ undefined,
+ undefined,
+ "text/typescript",
+ "function foo(){}"
+ );
+ expect(getMode(source, source.content)).toEqual({
+ name: "javascript",
+ typescript: true,
+ });
+ });
+
+ it("typescript-jsx", () => {
+ const source = makeMockSourceWithContent(
+ undefined,
+ undefined,
+ "text/typescript-jsx",
+ "<h1></h1>"
+ );
+ expect(getMode(source, source.content).base).toEqual({
+ name: "javascript",
+ typescript: true,
+ });
+ });
+
+ it("cross-platform clojure(script) with reader conditionals", () => {
+ const source = makeMockSourceWithContent(
+ "my-clojurescript-source-with-reader-conditionals.cljc",
+ undefined,
+ "text/x-clojure",
+ "(defn str->int [s] " +
+ " #?(:clj (java.lang.Integer/parseInt s) " +
+ " :cljs (js/parseInt s)))"
+ );
+ expect(getMode(source, source.content)).toEqual({ name: "clojure" });
+ });
+
+ it("clojurescript", () => {
+ const source = makeMockSourceWithContent(
+ "my-clojurescript-source.cljs",
+ undefined,
+ "text/x-clojurescript",
+ "(+ 1 2 3)"
+ );
+ expect(getMode(source, source.content)).toEqual({ name: "clojure" });
+ });
+
+ it("coffeescript", () => {
+ const source = makeMockSourceWithContent(
+ undefined,
+ undefined,
+ "text/coffeescript",
+ "x = (a) -> 3"
+ );
+ expect(getMode(source, source.content)).toEqual({ name: "coffeescript" });
+ });
+
+ it("wasm", () => {
+ const source = makeMockWasmSourceWithContent({
+ binary: "\x00asm\x01\x00\x00\x00",
+ });
+ expect(getMode(source, source.content.value)).toEqual({ name: "text" });
+ });
+
+ it("marko", () => {
+ const source = makeMockSourceWithContent(
+ "http://localhost.com:7999/increment/sometestfile.marko",
+ undefined,
+ "does not matter",
+ "function foo(){}"
+ );
+ expect(getMode(source, source.content)).toEqual({ name: "javascript" });
+ });
+
+ it("es6", () => {
+ const source = makeMockSourceWithContent(
+ "http://localhost.com:7999/increment/sometestfile.es6",
+ undefined,
+ "does not matter",
+ "function foo(){}"
+ );
+ expect(getMode(source, source.content)).toEqual({ name: "javascript" });
+ });
+
+ it("vue", () => {
+ const source = makeMockSourceWithContent(
+ "http://localhost.com:7999/increment/sometestfile.vue?query=string",
+ undefined,
+ "does not matter",
+ "function foo(){}"
+ );
+ expect(getMode(source, source.content)).toEqual({ name: "javascript" });
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/editor/tests/source-search.spec.js b/devtools/client/debugger/src/utils/editor/tests/source-search.spec.js
new file mode 100644
index 0000000000..33f479766a
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/tests/source-search.spec.js
@@ -0,0 +1,182 @@
+/* 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 {
+ find,
+ searchSourceForHighlight,
+ getMatchIndex,
+ removeOverlay,
+} from "../source-search";
+
+const getCursor = jest.fn(() => ({ line: 90, ch: 54 }));
+const cursor = {
+ find: jest.fn(),
+ from: jest.fn(),
+ to: jest.fn(),
+};
+const getSearchCursor = jest.fn(() => cursor);
+const modifiers = {
+ caseSensitive: false,
+ regexMatch: false,
+ wholeWord: false,
+};
+
+const getCM = () => ({
+ operation: jest.fn(cb => cb()),
+ addOverlay: jest.fn(),
+ removeOverlay: jest.fn(),
+ getCursor,
+ getSearchCursor,
+ firstLine: jest.fn(),
+ state: {},
+});
+
+describe("source-search", () => {
+ describe("find", () => {
+ it("calls into CodeMirror APIs via clearSearch & doSearch", () => {
+ const ctx = { cm: getCM() };
+ expect(ctx.cm.state).toEqual({});
+ find(ctx, "test", false, modifiers);
+ // First we check the APIs called via clearSearch
+ expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null);
+ // Next those via doSearch
+ expect(ctx.cm.operation).toHaveBeenCalled();
+ expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null);
+ expect(ctx.cm.addOverlay).toHaveBeenCalledWith(
+ { token: expect.any(Function) },
+ { opaque: false }
+ );
+ expect(ctx.cm.getCursor).toHaveBeenCalledWith("anchor");
+ expect(ctx.cm.getCursor).toHaveBeenCalledWith("head");
+ const search = {
+ query: "test",
+ posTo: { line: 0, ch: 0 },
+ posFrom: { line: 0, ch: 0 },
+ overlay: { token: expect.any(Function) },
+ results: [],
+ };
+ expect(ctx.cm.state).toEqual({ search });
+ });
+
+ it("clears a previous overlay", () => {
+ const ctx = { cm: getCM() };
+ ctx.cm.state.search = {
+ query: "foo",
+ posTo: null,
+ posFrom: null,
+ overlay: { token: expect.any(Function) },
+ results: [],
+ };
+ find(ctx, "test", true, modifiers);
+ expect(ctx.cm.removeOverlay).toHaveBeenCalledWith({
+ token: expect.any(Function),
+ });
+ });
+
+ it("clears for empty queries", () => {
+ const ctx = { cm: getCM() };
+ ctx.cm.state.search = {
+ query: "foo",
+ posTo: null,
+ posFrom: null,
+ overlay: null,
+ results: [],
+ };
+ find(ctx, "", true, modifiers);
+ expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null);
+ ctx.cm.removeOverlay.mockClear();
+ ctx.cm.state.search.query = "bar";
+ find(ctx, "", true, modifiers);
+ expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null);
+ });
+ });
+
+ describe("searchSourceForHighlight", () => {
+ it("calls into CodeMirror APIs and sets the correct selection", () => {
+ const line = 15;
+ const from = { line, ch: 1 };
+ const to = { line, ch: 5 };
+ const cm = {
+ ...getCM(),
+ setSelection: jest.fn(),
+ getSearchCursor: () => ({
+ find: () => true,
+ from: () => from,
+ to: () => to,
+ }),
+ };
+ const ed = { alignLine: jest.fn() };
+ const ctx = { cm, ed };
+
+ expect(ctx.cm.state).toEqual({});
+ searchSourceForHighlight(ctx, false, "test", false, modifiers, line, 1);
+
+ expect(ctx.cm.operation).toHaveBeenCalled();
+ expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null);
+ expect(ctx.cm.addOverlay).toHaveBeenCalledWith(
+ { token: expect.any(Function) },
+ { opaque: false }
+ );
+ expect(ctx.cm.getCursor).toHaveBeenCalledWith("anchor");
+ expect(ctx.cm.getCursor).toHaveBeenCalledWith("head");
+ expect(ed.alignLine).toHaveBeenCalledWith(line, "center");
+ expect(cm.setSelection).toHaveBeenCalledWith(from, to);
+ });
+ });
+
+ describe("findNext", () => {});
+
+ describe("findPrev", () => {});
+
+ describe("getMatchIndex", () => {
+ it("iterates in the matches", () => {
+ const count = 3;
+
+ // reverse 2, 1, 0, 2
+
+ let matchIndex = getMatchIndex(count, 2, true);
+ expect(matchIndex).toBe(1);
+
+ matchIndex = getMatchIndex(count, 1, true);
+ expect(matchIndex).toBe(0);
+
+ matchIndex = getMatchIndex(count, 0, true);
+ expect(matchIndex).toBe(2);
+
+ // forward 1, 2, 0, 1
+
+ matchIndex = getMatchIndex(count, 1, false);
+ expect(matchIndex).toBe(2);
+
+ matchIndex = getMatchIndex(count, 2, false);
+ expect(matchIndex).toBe(0);
+
+ matchIndex = getMatchIndex(count, 0, false);
+ expect(matchIndex).toBe(1);
+ });
+ });
+
+ describe("removeOverlay", () => {
+ it("calls CodeMirror APIs: removeOverlay, getCursor & setSelection", () => {
+ const ctx = {
+ cm: {
+ removeOverlay: jest.fn(),
+ getCursor,
+ state: {},
+ doc: {
+ setSelection: jest.fn(),
+ },
+ },
+ };
+ removeOverlay(ctx, "test");
+ expect(ctx.cm.removeOverlay).toHaveBeenCalled();
+ expect(ctx.cm.getCursor).toHaveBeenCalled();
+ expect(ctx.cm.doc.setSelection).toHaveBeenCalledWith(
+ { line: 90, ch: 54 },
+ { line: 90, ch: 54 },
+ { scroll: false }
+ );
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/editor/token-events.js b/devtools/client/debugger/src/utils/editor/token-events.js
new file mode 100644
index 0000000000..aba5b8c94b
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/token-events.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/>. */
+
+import { getTokenLocation } from ".";
+
+function isInvalidTarget(target) {
+ if (!target || !target.innerText) {
+ return true;
+ }
+
+ const tokenText = target.innerText.trim();
+ const cursorPos = target.getBoundingClientRect();
+
+ // exclude literal tokens where it does not make sense to show a preview
+ const invalidType = ["cm-atom", ""].includes(target.className);
+
+ // exclude syntax where the expression would be a syntax error
+ const invalidToken =
+ tokenText === "" || tokenText.match(/^[(){}\|&%,.;=<>\+-/\*\s](?=)/);
+
+ // exclude codemirror elements that are not tokens
+ const invalidTarget =
+ (target.parentElement &&
+ !target.parentElement.closest(".CodeMirror-line")) ||
+ cursorPos.top == 0;
+
+ const invalidClasses = ["editor-mount"];
+ if (invalidClasses.some(className => target.classList.contains(className))) {
+ return true;
+ }
+
+ if (target.closest(".popover")) {
+ return true;
+ }
+
+ return !!(invalidTarget || invalidToken || invalidType);
+}
+
+function dispatch(codeMirror, eventName, data) {
+ codeMirror.constructor.signal(codeMirror, eventName, data);
+}
+
+function invalidLeaveTarget(target) {
+ if (!target || target.closest(".popover")) {
+ return true;
+ }
+
+ return false;
+}
+
+export function onMouseOver(codeMirror) {
+ let prevTokenPos = null;
+
+ function onMouseLeave(event) {
+ if (invalidLeaveTarget(event.relatedTarget)) {
+ addMouseLeave(event.target);
+ return;
+ }
+
+ prevTokenPos = null;
+ dispatch(codeMirror, "tokenleave", event);
+ }
+
+ function addMouseLeave(target) {
+ target.addEventListener("mouseleave", onMouseLeave, {
+ capture: true,
+ once: true,
+ });
+ }
+
+ return enterEvent => {
+ const { target } = enterEvent;
+
+ if (isInvalidTarget(target)) {
+ return;
+ }
+
+ const tokenPos = getTokenLocation(codeMirror, target);
+
+ if (
+ prevTokenPos?.line !== tokenPos?.line ||
+ prevTokenPos?.column !== tokenPos?.column
+ ) {
+ addMouseLeave(target);
+
+ dispatch(codeMirror, "tokenenter", {
+ event: enterEvent,
+ target,
+ tokenPos,
+ });
+ prevTokenPos = tokenPos;
+ }
+ };
+}
diff --git a/devtools/client/debugger/src/utils/environment.js b/devtools/client/debugger/src/utils/environment.js
new file mode 100644
index 0000000000..6f68dd793c
--- /dev/null
+++ b/devtools/client/debugger/src/utils/environment.js
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+export function isNode() {
+ try {
+ return process.release.name == "node";
+ } catch (e) {
+ return false;
+ }
+}
+
+export function isNodeTest() {
+ return isNode() && process.env.NODE_ENV != "production";
+}
diff --git a/devtools/client/debugger/src/utils/evaluation-result.js b/devtools/client/debugger/src/utils/evaluation-result.js
new file mode 100644
index 0000000000..def02aacfa
--- /dev/null
+++ b/devtools/client/debugger/src/utils/evaluation-result.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/>. */
+
+function isFront(result) {
+ return !!result && typeof result === "object" && !!result.getGrip;
+}
+
+export function getGrip(result) {
+ if (isFront(result)) {
+ return result.getGrip();
+ }
+
+ return result;
+}
+
+export function getFront(result) {
+ return isFront(result) ? result : null;
+}
diff --git a/devtools/client/debugger/src/utils/expressions.js b/devtools/client/debugger/src/utils/expressions.js
new file mode 100644
index 0000000000..f30d0c089c
--- /dev/null
+++ b/devtools/client/debugger/src/utils/expressions.js
@@ -0,0 +1,67 @@
+/* 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 { correctIndentation } from "./indentation";
+import { getGrip, getFront } from "./evaluation-result";
+
+const UNAVAILABLE_GRIP = { unavailable: true };
+
+/*
+ * wrap the expression input in a try/catch so that it can be safely
+ * evaluated.
+ *
+ * NOTE: we add line after the expression to protect against comments.
+ */
+export function wrapExpression(input) {
+ return correctIndentation(`
+ try {
+ ${input}
+ } catch (e) {
+ e
+ }
+ `);
+}
+
+function isUnavailable(value) {
+ return (
+ value &&
+ !!value.isError &&
+ (value.class === "ReferenceError" || value.class === "TypeError")
+ );
+}
+
+/**
+ *
+ * @param {Object} expression: Expression item as stored in state.expressions in reducers/expressions.js
+ * @param {String} expression.input: evaluated expression string
+ * @param {Object} expression.value: evaluated expression result object as returned from ScriptCommand#execute
+ * @param {Object} expression.value.result: expression result, might be a primitive, a grip or a front
+ * @param {Object} expression.value.exception: expression result error, might be a primitive, a grip or a front
+ * @returns {Object} an object of the following shape:
+ * - expressionResultGrip: A primitive or a grip
+ * - expressionResultFront: An object front if it exists, or undefined
+ */
+export function getExpressionResultGripAndFront(expression) {
+ const { value } = expression;
+
+ if (!value) {
+ return { expressionResultGrip: UNAVAILABLE_GRIP };
+ }
+
+ const expressionResultReturn = value.exception || value.result;
+ const valueGrip = getGrip(expressionResultReturn);
+ if (!valueGrip || isUnavailable(valueGrip)) {
+ return { expressionResultGrip: UNAVAILABLE_GRIP };
+ }
+
+ if (valueGrip.isError) {
+ const { name, message } = valueGrip.preview;
+ return { expressionResultGrip: `${name}: ${message}` };
+ }
+
+ return {
+ expressionResultGrip: valueGrip,
+ expressionResultFront: getFront(expressionResultReturn),
+ };
+}
diff --git a/devtools/client/debugger/src/utils/function.js b/devtools/client/debugger/src/utils/function.js
new file mode 100644
index 0000000000..39d0f5a9a7
--- /dev/null
+++ b/devtools/client/debugger/src/utils/function.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/>. */
+
+import { isFulfilled } from "./async-value";
+import { findClosestFunction } from "./ast";
+import { correctIndentation } from "./indentation";
+
+export function findFunctionText(line, source, sourceTextContent, symbols) {
+ const func = findClosestFunction(symbols, {
+ sourceId: source.id,
+ line,
+ column: Infinity,
+ });
+
+ if (
+ source.isWasm ||
+ !func ||
+ !sourceTextContent ||
+ !isFulfilled(sourceTextContent) ||
+ sourceTextContent.value.type !== "text"
+ ) {
+ return null;
+ }
+
+ const {
+ location: { start, end },
+ } = func;
+ const lines = sourceTextContent.value.value.split("\n");
+ const firstLine = lines[start.line - 1].slice(start.column);
+ const lastLine = lines[end.line - 1].slice(0, end.column);
+ const middle = lines.slice(start.line, end.line - 1);
+ const functionText = [firstLine, ...middle, lastLine].join("\n");
+ const indentedFunctionText = correctIndentation(functionText);
+
+ return indentedFunctionText;
+}
diff --git a/devtools/client/debugger/src/utils/indentation.js b/devtools/client/debugger/src/utils/indentation.js
new file mode 100644
index 0000000000..80e5014f6d
--- /dev/null
+++ b/devtools/client/debugger/src/utils/indentation.js
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+export function getIndentation(line) {
+ if (!line) {
+ return 0;
+ }
+
+ const lineMatch = line.match(/^\s*/);
+ if (!lineMatch) {
+ return 0;
+ }
+
+ return lineMatch[0].length;
+}
+
+function getMaxIndentation(lines) {
+ const firstLine = lines[0];
+ const secondLine = lines[1];
+ const lastLine = lines[lines.length - 1];
+
+ const indentations = [
+ getIndentation(firstLine),
+ getIndentation(secondLine),
+ getIndentation(lastLine),
+ ];
+
+ return Math.max(...indentations);
+}
+
+export function correctIndentation(text) {
+ const lines = text.trim().split("\n");
+ const indentation = getMaxIndentation(lines);
+ const formattedLines = lines.map(_line =>
+ _line.replace(new RegExp(`^\\s{0,${indentation - 1}}`), "")
+ );
+
+ return formattedLines.join("\n");
+}
diff --git a/devtools/client/debugger/src/utils/isMinified.js b/devtools/client/debugger/src/utils/isMinified.js
new file mode 100644
index 0000000000..dc5963b1b2
--- /dev/null
+++ b/devtools/client/debugger/src/utils/isMinified.js
@@ -0,0 +1,58 @@
+/* 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 { isFulfilled } from "./async-value";
+
+// Used to detect minification for automatic pretty printing
+const SAMPLE_SIZE = 50;
+const INDENT_COUNT_THRESHOLD = 5;
+const CHARACTER_LIMIT = 250;
+const _minifiedCache = new Map();
+
+export function isMinified(source, sourceTextContent) {
+ if (_minifiedCache.has(source.id)) {
+ return _minifiedCache.get(source.id);
+ }
+
+ if (
+ !sourceTextContent ||
+ !isFulfilled(sourceTextContent) ||
+ sourceTextContent.value.type !== "text"
+ ) {
+ return false;
+ }
+
+ let text = sourceTextContent.value.value;
+
+ let lineEndIndex = 0;
+ let lineStartIndex = 0;
+ let lines = 0;
+ let indentCount = 0;
+ let overCharLimit = false;
+
+ // Strip comments.
+ text = text.replace(/\/\*[\S\s]*?\*\/|\/\/(.+|\n)/g, "");
+
+ while (lines++ < SAMPLE_SIZE) {
+ lineEndIndex = text.indexOf("\n", lineStartIndex);
+ if (lineEndIndex == -1) {
+ break;
+ }
+ if (/^\s+/.test(text.slice(lineStartIndex, lineEndIndex))) {
+ indentCount++;
+ }
+ // For files with no indents but are not minified.
+ if (lineEndIndex - lineStartIndex > CHARACTER_LIMIT) {
+ overCharLimit = true;
+ break;
+ }
+ lineStartIndex = lineEndIndex + 1;
+ }
+
+ const minified =
+ (indentCount / lines) * 100 < INDENT_COUNT_THRESHOLD || overCharLimit;
+
+ _minifiedCache.set(source.id, minified);
+ return minified;
+}
diff --git a/devtools/client/debugger/src/utils/location.js b/devtools/client/debugger/src/utils/location.js
new file mode 100644
index 0000000000..eefe516f12
--- /dev/null
+++ b/devtools/client/debugger/src/utils/location.js
@@ -0,0 +1,134 @@
+/* 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 { getSelectedLocation } from "./selected-location";
+import { getSource } from "../selectors";
+
+/**
+ * Note that arguments can be created via `createLocation`.
+ * But they can also be created via `createPendingLocation` in reducer/pending-breakpoints.js.
+ * Both will have similar line and column attributes.
+ */
+export function comparePosition(a, b) {
+ return a && b && a.line == b.line && a.column == b.column;
+}
+
+export function createLocation({
+ source,
+ sourceActor = null,
+
+ // Line 0 represents no specific line chosen for action
+ line = 0,
+ column,
+
+ sourceUrl = "",
+}) {
+ return {
+ source,
+ sourceActor,
+ // Alias which should probably be migrate to query source and sourceActor?
+ sourceId: source.id,
+ sourceActorId: sourceActor?.id,
+
+ line,
+ column,
+
+ // Is this still used anywhere??
+ sourceUrl,
+ };
+}
+
+/**
+ * Convert location objects created via `createLocation` into
+ * the format used by the Source Map Loader/Worker.
+ * It only needs sourceId, line and column attributes.
+ */
+export function debuggerToSourceMapLocation(location) {
+ return {
+ sourceId: location.source.id,
+ line: location.line,
+ column: location.column,
+
+ // Also add sourceUrl attribute as this may be preserved in jest tests
+ // where we return the exact same object.
+ // This will be removed by bug 1822783.
+ sourceUrl: location.sourceUrl,
+ };
+}
+
+/**
+ * Pending location only need these three attributes,
+ * and especially doesn't need the large source and sourceActor objects of the regular location objects.
+ *
+ * @param {Object} location
+ */
+export function createPendingSelectedLocation(location) {
+ return {
+ url: location.source.url,
+
+ line: location.line,
+ column: location.column,
+ };
+}
+
+export function sortSelectedLocations(locations, selectedSource) {
+ return Array.from(locations).sort((locationA, locationB) => {
+ const aSelected = getSelectedLocation(locationA, selectedSource);
+ const bSelected = getSelectedLocation(locationB, selectedSource);
+
+ // Order the locations by line number…
+ if (aSelected.line < bSelected.line) {
+ return -1;
+ }
+
+ if (aSelected.line > bSelected.line) {
+ return 1;
+ }
+
+ // … and if we have the same line, we want to return location with undefined columns
+ // first, and then order them by column
+ if (aSelected.column == bSelected.column) {
+ return 0;
+ }
+
+ if (aSelected.column === undefined) {
+ return -1;
+ }
+
+ if (bSelected.column === undefined) {
+ return 1;
+ }
+
+ return aSelected.column < bSelected.column ? -1 : 1;
+ });
+}
+
+/**
+ * Source map Loader/Worker and debugger frontend don't use the same objects for locations.
+ * Worker uses 'sourceId' attributes whereas the frontend has 'source' attribute.
+ */
+export function sourceMapToDebuggerLocation(state, location) {
+ // From MapScopes modules, we might re-process the exact same location objects
+ // for which we would already have computed the source object,
+ // and which would lack sourceId attribute.
+ if (location.source) {
+ return location;
+ }
+
+ // SourceMapLoader doesn't known about debugger's source objects
+ // so that we have to fetch it from here
+ const source = getSource(state, location.sourceId);
+ if (!source) {
+ throw new Error(`Could not find source-map source ${location.sourceId}`);
+ }
+
+ return createLocation({
+ ...location,
+ source,
+
+ // Ensure having location with sourceUrl attribute set.
+ // To be removed in bug 1822783.
+ sourceUrl: source.url,
+ });
+}
diff --git a/devtools/client/debugger/src/utils/log.js b/devtools/client/debugger/src/utils/log.js
new file mode 100644
index 0000000000..6e2e3b7b15
--- /dev/null
+++ b/devtools/client/debugger/src/utils/log.js
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/* */
+
+/**
+ *
+ * Utils for logging to the console
+ * Suppresses logging in non-development environment
+ *
+ * @module utils/log
+ */
+
+import { prefs } from "./prefs";
+
+/**
+ * Produces a formatted console log line by imploding args, prefixed by [log]
+ *
+ * function input: log(["hello", "world"])
+ * console output: [log] hello world
+ *
+ * @memberof utils/log
+ * @static
+ */
+export function log(...args) {
+ if (prefs.logging) {
+ console.log(...args);
+ }
+}
diff --git a/devtools/client/debugger/src/utils/memoizableAction.js b/devtools/client/debugger/src/utils/memoizableAction.js
new file mode 100644
index 0000000000..0f465177e2
--- /dev/null
+++ b/devtools/client/debugger/src/utils/memoizableAction.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/>. */
+
+import { asSettled } from "./async-value";
+import { validateContext } from "./context";
+
+/*
+ * memoizableActon is a utility for actions that should only be performed
+ * once per key. It is useful for loading sources, parsing symbols ...
+ *
+ * @getValue - gets the result from the redux store
+ * @createKey - creates a key for the requests map
+ * @action - kicks off the async work for the action
+ *
+ *
+ * For Example
+ *
+ * export const setItem = memoizeableAction(
+ * "setItem",
+ * {
+ * hasValue: ({ a }, { getState }) => hasItem(getState(), a),
+ * getValue: ({ a }, { getState }) => getItem(getState(), a),
+ * createKey: ({ a }) => a,
+ * action: ({ a }, thunkArgs) => doSetItem(a, thunkArgs)
+ * }
+ * );
+ *
+ */
+export function memoizeableAction(name, { getValue, createKey, action }) {
+ const requests = new Map();
+ return args => async thunkArgs => {
+ let result = asSettled(getValue(args, thunkArgs));
+ if (!result) {
+ const key = createKey(args, thunkArgs);
+ if (!requests.has(key)) {
+ requests.set(
+ key,
+ (async () => {
+ try {
+ await action(args, thunkArgs);
+ } catch (e) {
+ console.warn(`Action ${name} had an exception:`, e);
+ } finally {
+ requests.delete(key);
+ }
+ })()
+ );
+ }
+
+ await requests.get(key);
+
+ if (args.cx) {
+ validateContext(thunkArgs.getState(), args.cx);
+ }
+
+ result = asSettled(getValue(args, thunkArgs));
+ if (!result) {
+ // Returning null here is not ideal. This means that the action
+ // resolved but 'getValue' didn't return a loaded value, for instance
+ // if the data the action was meant to store was deleted. In a perfect
+ // world we'd throw a ContextError here or handle cancellation somehow.
+ // Throwing will also allow us to change the return type on the action
+ // to always return a promise for the getValue AsyncValue type, but
+ // for now we have to add an additional '| null' for this case.
+ return null;
+ }
+ }
+
+ if (result.state === "rejected") {
+ throw result.value;
+ }
+ return result.value;
+ };
+}
diff --git a/devtools/client/debugger/src/utils/memoize.js b/devtools/client/debugger/src/utils/memoize.js
new file mode 100644
index 0000000000..3f634b326d
--- /dev/null
+++ b/devtools/client/debugger/src/utils/memoize.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/>. */
+
+function hasValue(keys, store) {
+ let currentStore = store;
+ for (const key of keys) {
+ if (!currentStore || !currentStore.has(key)) {
+ return false;
+ }
+
+ currentStore = currentStore.get(key);
+ }
+ return true;
+}
+
+function getValue(keys, store) {
+ let currentStore = store;
+ for (const key of keys) {
+ if (!currentStore) {
+ return null;
+ }
+ currentStore = currentStore.get(key);
+ }
+
+ return currentStore;
+}
+
+function setValue(keys, store, value) {
+ const keysExceptLast = keys.slice(0, -1);
+ const lastKey = keys[keys.length - 1];
+
+ let currentStore = store;
+ for (const key of keysExceptLast) {
+ if (!currentStore) {
+ return;
+ }
+
+ if (!currentStore.has(key)) {
+ currentStore.set(key, new WeakMap());
+ }
+ currentStore = currentStore.get(key);
+ }
+
+ if (currentStore) {
+ currentStore.set(lastKey, value);
+ }
+}
+
+// memoize with n arguments
+export default function memoize(func) {
+ const store = new WeakMap();
+
+ return function (...keys) {
+ if (hasValue(keys, store)) {
+ return getValue(keys, store);
+ }
+
+ const newValue = func.apply(null, keys);
+ setValue(keys, store, newValue);
+ return newValue;
+ };
+}
diff --git a/devtools/client/debugger/src/utils/memoizeLast.js b/devtools/client/debugger/src/utils/memoizeLast.js
new file mode 100644
index 0000000000..b3c46cab57
--- /dev/null
+++ b/devtools/client/debugger/src/utils/memoizeLast.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/>. */
+
+export function memoizeLast(fn) {
+ let lastArgs;
+ let lastResult;
+
+ const memoized = (...args) => {
+ if (
+ lastArgs &&
+ args.length === lastArgs.length &&
+ args.every((arg, i) => arg === lastArgs[i])
+ ) {
+ return lastResult;
+ }
+
+ lastArgs = args;
+ lastResult = fn(...args);
+
+ return lastResult;
+ };
+
+ return memoized;
+}
+
+export default memoizeLast;
diff --git a/devtools/client/debugger/src/utils/moz.build b/devtools/client/debugger/src/utils/moz.build
new file mode 100644
index 0000000000..5d729528e4
--- /dev/null
+++ b/devtools/client/debugger/src/utils/moz.build
@@ -0,0 +1,54 @@
+# 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 += [
+ "breakpoint",
+ "editor",
+ "pause",
+ "sources-tree",
+]
+
+CompiledModules(
+ "assert.js",
+ "ast.js",
+ "async-value.js",
+ "bootstrap.js",
+ "build-query.js",
+ "clipboard.js",
+ "connect.js",
+ "context.js",
+ "dbg.js",
+ "DevToolsUtils.js",
+ "environment.js",
+ "expressions.js",
+ "evaluation-result.js",
+ "function.js",
+ "indentation.js",
+ "isMinified.js",
+ "location.js",
+ "log.js",
+ "memoize.js",
+ "memoizeLast.js",
+ "memoizableAction.js",
+ "path.js",
+ "prefs.js",
+ "preview.js",
+ "quick-open.js",
+ "result-list.js",
+ "selected-location.js",
+ "shallow-equal.js",
+ "source-maps.js",
+ "source-queue.js",
+ "source.js",
+ "tabs.js",
+ "task.js",
+ "telemetry.js",
+ "text.js",
+ "ui.js",
+ "url.js",
+ "utils.js",
+ "wasm.js",
+ "worker.js",
+)
diff --git a/devtools/client/debugger/src/utils/path.js b/devtools/client/debugger/src/utils/path.js
new file mode 100644
index 0000000000..bc7f975919
--- /dev/null
+++ b/devtools/client/debugger/src/utils/path.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/>. */
+
+export function basename(path) {
+ return path.split("/").pop();
+}
+
+export function dirname(path) {
+ const idx = path.lastIndexOf("/");
+ return path.slice(0, idx);
+}
+
+export function isURL(str) {
+ return str.includes("://");
+}
+
+export function isAbsolute(str) {
+ return str[0] === "/";
+}
+
+export function join(base, dir) {
+ return `${base}/${dir}`;
+}
diff --git a/devtools/client/debugger/src/utils/pause/frames/annotateFrames.js b/devtools/client/debugger/src/utils/pause/frames/annotateFrames.js
new file mode 100644
index 0000000000..ad5af11980
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/annotateFrames.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 { getFrameUrl } from "./getFrameUrl";
+import { getLibraryFromUrl } from "./getLibraryFromUrl";
+
+export function annotateFrames(frames) {
+ const annotatedFrames = frames.map(f => annotateFrame(f, frames));
+ return annotateBabelAsyncFrames(annotatedFrames);
+}
+
+function annotateFrame(frame, frames) {
+ const library = getLibraryFromUrl(frame, frames);
+ if (library) {
+ return { ...frame, library };
+ }
+
+ return frame;
+}
+
+function annotateBabelAsyncFrames(frames) {
+ const babelFrameIndexes = getBabelFrameIndexes(frames);
+ const isBabelFrame = frameIndex => babelFrameIndexes.includes(frameIndex);
+
+ return frames.map((frame, frameIndex) =>
+ isBabelFrame(frameIndex) ? { ...frame, library: "Babel" } : frame
+ );
+}
+
+/**
+ * Returns all the indexes that are part of a babel async call stack.
+ *
+ * @param {Array<Object>} frames
+ * @returns Array<Integer>
+ */
+function getBabelFrameIndexes(frames) {
+ const startIndexes = [];
+ const endIndexes = [];
+
+ frames.forEach((frame, index) => {
+ const frameUrl = getFrameUrl(frame);
+
+ if (
+ frameUrl.match(/regenerator-runtime/i) &&
+ frame.displayName === "tryCatch"
+ ) {
+ startIndexes.push(index);
+ }
+ if (frame.displayName === "flush" && frameUrl.match(/_microtask/i)) {
+ endIndexes.push(index);
+ }
+ if (frame.displayName === "_asyncToGenerator/<") {
+ endIndexes.push(index + 1);
+ }
+ });
+
+ if (startIndexes.length != endIndexes.length || startIndexes.length === 0) {
+ return [];
+ }
+
+ const babelFrameIndexes = [];
+ // We have the same number of start and end indexes, we can loop through one of them to
+ // build our async call stack index ranges
+ // e.g. if we have startIndexes: [1,5] and endIndexes: [3,8], we want to return [1,2,3,5,6,7,8]
+ startIndexes.forEach((startIndex, index) => {
+ const matchingEndIndex = endIndexes[index];
+ for (let i = startIndex; i <= matchingEndIndex; i++) {
+ babelFrameIndexes.push(i);
+ }
+ });
+ return babelFrameIndexes;
+}
diff --git a/devtools/client/debugger/src/utils/pause/frames/collapseFrames.js b/devtools/client/debugger/src/utils/pause/frames/collapseFrames.js
new file mode 100644
index 0000000000..e497933e7f
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/collapseFrames.js
@@ -0,0 +1,61 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// eslint-disable-next-line max-len
+import { getFrameUrl } from "./getFrameUrl";
+
+function collapseLastFrames(frames) {
+ const index = frames.findIndex(frame =>
+ getFrameUrl(frame).match(/webpack\/bootstrap/i)
+ );
+
+ if (index == -1) {
+ return { newFrames: frames, lastGroup: [] };
+ }
+
+ const newFrames = frames.slice(0, index);
+ const lastGroup = frames.slice(index);
+ return { newFrames, lastGroup };
+}
+
+export function collapseFrames(frames) {
+ // We collapse groups of one so that user frames
+ // are not in a group of one
+ function addGroupToList(group, list) {
+ if (!group) {
+ return list;
+ }
+
+ if (group.length > 1) {
+ list.push(group);
+ } else {
+ list = list.concat(group);
+ }
+
+ return list;
+ }
+ const { newFrames, lastGroup } = collapseLastFrames(frames);
+ frames = newFrames;
+ let items = [];
+ let currentGroup = null;
+ let prevItem = null;
+ for (const frame of frames) {
+ const prevLibrary = prevItem?.library;
+
+ if (!currentGroup) {
+ currentGroup = [frame];
+ } else if (prevLibrary && prevLibrary == frame.library) {
+ currentGroup.push(frame);
+ } else {
+ items = addGroupToList(currentGroup, items);
+ currentGroup = [frame];
+ }
+
+ prevItem = frame;
+ }
+
+ items = addGroupToList(currentGroup, items);
+ items = addGroupToList(lastGroup, items);
+ return items;
+}
diff --git a/devtools/client/debugger/src/utils/pause/frames/displayName.js b/devtools/client/debugger/src/utils/pause/frames/displayName.js
new file mode 100644
index 0000000000..fa261b9d78
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/displayName.js
@@ -0,0 +1,97 @@
+/* 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/>. */
+
+// eslint-disable-next-line max-len
+
+// Decodes an anonymous naming scheme that
+// spider monkey implements based on "Naming Anonymous JavaScript Functions"
+// http://johnjbarton.github.io/nonymous/index.html
+const objectProperty = /([\w\d\$#]+)$/;
+const arrayProperty = /\[(.*?)\]$/;
+const functionProperty = /([\w\d]+)[\/\.<]*?$/;
+const annonymousProperty = /([\w\d]+)\(\^\)$/;
+
+export function simplifyDisplayName(displayName) {
+ // if the display name has a space it has already been mapped
+ if (!displayName || /\s/.exec(displayName)) {
+ return displayName;
+ }
+
+ const scenarios = [
+ objectProperty,
+ arrayProperty,
+ functionProperty,
+ annonymousProperty,
+ ];
+
+ for (const reg of scenarios) {
+ const match = reg.exec(displayName);
+ if (match) {
+ return match[1];
+ }
+ }
+
+ return displayName;
+}
+
+const displayNameMap = {
+ Babel: {
+ tryCatch: "Async",
+ },
+ Backbone: {
+ "extend/child": "Create Class",
+ ".create": "Create Model",
+ },
+ jQuery: {
+ "jQuery.event.dispatch": "Dispatch Event",
+ },
+ React: {
+ // eslint-disable-next-line max-len
+ "ReactCompositeComponent._renderValidatedComponentWithoutOwnerOrContext/renderedElement<":
+ "Render",
+ _renderValidatedComponentWithoutOwnerOrContext: "Render",
+ },
+ VueJS: {
+ "renderMixin/Vue.prototype._render": "Render",
+ },
+ Webpack: {
+ // eslint-disable-next-line camelcase
+ __webpack_require__: "Bootstrap",
+ },
+};
+
+function mapDisplayNames(frame, library) {
+ const { displayName } = frame;
+ return displayNameMap[library]?.[displayName] || displayName;
+}
+
+function getFrameDisplayName(frame) {
+ const { displayName, originalDisplayName, userDisplayName, name } = frame;
+ return originalDisplayName || userDisplayName || displayName || name;
+}
+
+export function formatDisplayName(
+ frame,
+ { shouldMapDisplayName = true } = {},
+ l10n
+) {
+ const { library } = frame;
+ let displayName = getFrameDisplayName(frame);
+ if (library && shouldMapDisplayName) {
+ displayName = mapDisplayNames(frame, library);
+ }
+
+ return simplifyDisplayName(displayName) || l10n.getStr("anonymousFunction");
+}
+
+export function formatCopyName(frame, l10n) {
+ const displayName = formatDisplayName(frame, undefined, l10n);
+ if (!frame.source) {
+ throw new Error("no frame source");
+ }
+ const fileName = frame.source.url || frame.source.id;
+ const frameLocation = frame.location.line;
+
+ return `${displayName} (${fileName}#${frameLocation})`;
+}
diff --git a/devtools/client/debugger/src/utils/pause/frames/getFrameUrl.js b/devtools/client/debugger/src/utils/pause/frames/getFrameUrl.js
new file mode 100644
index 0000000000..b03a4fd30f
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/getFrameUrl.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/>. */
+
+export function getFrameUrl(frame) {
+ return frame?.source?.url ?? "";
+}
diff --git a/devtools/client/debugger/src/utils/pause/frames/getLibraryFromUrl.js b/devtools/client/debugger/src/utils/pause/frames/getLibraryFromUrl.js
new file mode 100644
index 0000000000..17e294f258
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/getLibraryFromUrl.js
@@ -0,0 +1,144 @@
+/* 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 { getFrameUrl } from "./getFrameUrl";
+
+const libraryMap = [
+ {
+ label: "Backbone",
+ pattern: /backbone/i,
+ },
+ {
+ label: "Babel",
+ pattern: /node_modules\/@babel/i,
+ },
+ {
+ label: "jQuery",
+ pattern: /jquery/i,
+ },
+ {
+ label: "Preact",
+ pattern: /preact/i,
+ },
+ {
+ label: "React",
+ pattern:
+ /(node_modules\/(?:react(-dom)?(-dev)?\/))|(react(-dom)?(-dev)?(\.[a-z]+)*\.js$)/,
+ },
+ {
+ label: "Immutable",
+ pattern: /immutable/i,
+ },
+ {
+ label: "Webpack",
+ pattern: /webpack\/bootstrap/i,
+ },
+ {
+ label: "Express",
+ pattern: /node_modules\/express/,
+ },
+ {
+ label: "Pug",
+ pattern: /node_modules\/pug/,
+ },
+ {
+ label: "ExtJS",
+ pattern: /\/ext-all[\.\-]/,
+ },
+ {
+ label: "MobX",
+ pattern: /mobx/i,
+ },
+ {
+ label: "Underscore",
+ pattern: /underscore/i,
+ },
+ {
+ label: "Lodash",
+ pattern: /lodash/i,
+ },
+ {
+ label: "Ember",
+ pattern: /ember/i,
+ },
+ {
+ label: "Choo",
+ pattern: /choo/i,
+ },
+ {
+ label: "VueJS",
+ pattern: /vue(?:\.[a-z]+)*\.js/i,
+ },
+ {
+ label: "RxJS",
+ pattern: /rxjs/i,
+ },
+ {
+ label: "Angular",
+ pattern: /angular(?!.*\/app\/)/i,
+ contextPattern: /zone\.js/,
+ },
+ {
+ label: "Redux",
+ pattern: /redux/i,
+ },
+ {
+ label: "Dojo",
+ pattern: /dojo/i,
+ },
+ {
+ label: "Marko",
+ pattern: /marko/i,
+ },
+ {
+ label: "NuxtJS",
+ pattern: /[\._]nuxt/i,
+ },
+ {
+ label: "Aframe",
+ pattern: /aframe/i,
+ },
+ {
+ label: "NextJS",
+ pattern: /[\._]next/i,
+ },
+];
+
+export function getLibraryFromUrl(frame, callStack = []) {
+ // @TODO each of these fns calls getFrameUrl, just call it once
+ // (assuming there's not more complex logic to identify a lib)
+ const frameUrl = getFrameUrl(frame);
+
+ // Let's first check if the frame match a defined pattern.
+ let match = libraryMap.find(o => o.pattern.test(frameUrl));
+ if (match) {
+ return match.label;
+ }
+
+ // If it does not, it might still be one of the case where the file is used
+ // by a library but the name has not enough specificity. In such case, we want
+ // to only return the library name if there are frames matching the library
+ // pattern in the callStack (e.g. `zone.js` is used by Angular, but the name
+ // could be quite common and return false positive if evaluated alone. So we
+ // only return Angular if there are other frames matching Angular).
+ match = libraryMap.find(
+ o => o.contextPattern && o.contextPattern.test(frameUrl)
+ );
+ if (match) {
+ const contextMatch = callStack.some(f => {
+ const url = getFrameUrl(f);
+ if (!url) {
+ return false;
+ }
+
+ return libraryMap.some(o => o.pattern.test(url));
+ });
+
+ if (contextMatch) {
+ return match.label;
+ }
+ }
+
+ return null;
+}
diff --git a/devtools/client/debugger/src/utils/pause/frames/index.js b/devtools/client/debugger/src/utils/pause/frames/index.js
new file mode 100644
index 0000000000..0912be05d5
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/index.js
@@ -0,0 +1,9 @@
+/* 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 "./annotateFrames";
+export * from "./collapseFrames";
+export * from "./displayName";
+export * from "./getFrameUrl";
+export * from "./getLibraryFromUrl";
diff --git a/devtools/client/debugger/src/utils/pause/frames/moz.build b/devtools/client/debugger/src/utils/pause/frames/moz.build
new file mode 100644
index 0000000000..5bb330a57f
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/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(
+ "annotateFrames.js",
+ "collapseFrames.js",
+ "displayName.js",
+ "getFrameUrl.js",
+ "getLibraryFromUrl.js",
+ "index.js",
+)
diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/__snapshots__/collapseFrames.spec.js.snap b/devtools/client/debugger/src/utils/pause/frames/tests/__snapshots__/collapseFrames.spec.js.snap
new file mode 100644
index 0000000000..89dbccd374
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/tests/__snapshots__/collapseFrames.spec.js.snap
@@ -0,0 +1,57 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`collapseFrames default 1`] = `
+Array [
+ Object {
+ "displayName": "a",
+ },
+ Array [
+ Object {
+ "displayName": "b",
+ "library": "React",
+ },
+ Object {
+ "displayName": "c",
+ "library": "React",
+ },
+ ],
+]
+`;
+
+exports[`collapseFrames promises 1`] = `
+Array [
+ Object {
+ "displayName": "a",
+ },
+ Array [
+ Object {
+ "displayName": "b",
+ "library": "React",
+ },
+ Object {
+ "displayName": "c",
+ "library": "React",
+ },
+ ],
+ Object {
+ "asyncCause": "promise callback",
+ "displayName": "d",
+ "library": undefined,
+ },
+ Array [
+ Object {
+ "displayName": "e",
+ "library": "React",
+ },
+ Object {
+ "displayName": "f",
+ "library": "React",
+ },
+ ],
+ Object {
+ "asyncCause": null,
+ "displayName": "g",
+ "library": undefined,
+ },
+]
+`;
diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/annotateFrames.spec.js b/devtools/client/debugger/src/utils/pause/frames/tests/annotateFrames.spec.js
new file mode 100644
index 0000000000..6579293b5f
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/tests/annotateFrames.spec.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/>. */
+
+import { annotateFrames } from "../annotateFrames";
+import { makeMockFrameWithURL } from "../../../test-mockup";
+
+describe("annotateFrames", () => {
+ it("should return Angular", () => {
+ const callstack = [
+ makeMockFrameWithURL(
+ "https://stackblitz.io/turbo_modules/@angular/core@7.2.4/bundles/core.umd.js"
+ ),
+ makeMockFrameWithURL("/node_modules/zone/zone.js"),
+ makeMockFrameWithURL(
+ "https://cdnjs.cloudflare.com/ajax/libs/angular/angular.js"
+ ),
+ ];
+ const frames = annotateFrames(callstack);
+ expect(frames).toEqual(callstack.map(f => ({ ...f, library: "Angular" })));
+ });
+});
diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/collapseFrames.spec.js b/devtools/client/debugger/src/utils/pause/frames/tests/collapseFrames.spec.js
new file mode 100644
index 0000000000..15210e0437
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/tests/collapseFrames.spec.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/>. */
+
+import { collapseFrames } from "../collapseFrames";
+
+describe("collapseFrames", () => {
+ it("default", () => {
+ const groups = collapseFrames([
+ { displayName: "a" },
+
+ { displayName: "b", library: "React" },
+ { displayName: "c", library: "React" },
+ ]);
+
+ expect(groups).toMatchSnapshot();
+ });
+
+ it("promises", () => {
+ const groups = collapseFrames([
+ { displayName: "a" },
+
+ { displayName: "b", library: "React" },
+ { displayName: "c", library: "React" },
+ {
+ displayName: "d",
+ library: undefined,
+ asyncCause: "promise callback",
+ },
+ { displayName: "e", library: "React" },
+ { displayName: "f", library: "React" },
+ { displayName: "g", library: undefined, asyncCause: null },
+ ]);
+
+ expect(groups).toMatchSnapshot();
+ });
+});
diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/displayName.spec.js b/devtools/client/debugger/src/utils/pause/frames/tests/displayName.spec.js
new file mode 100644
index 0000000000..d969c18753
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/tests/displayName.spec.js
@@ -0,0 +1,129 @@
+/* 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 {
+ formatCopyName,
+ formatDisplayName,
+ simplifyDisplayName,
+} from "../displayName";
+
+import { makeMockFrame, makeMockSource } from "../../../test-mockup";
+
+describe("formatCopyName", () => {
+ it("simple", () => {
+ const source = makeMockSource("todo-view.js");
+ const frame = makeMockFrame(undefined, source, undefined, 12, "child");
+
+ expect(formatCopyName(frame, L10N)).toEqual("child (todo-view.js#12)");
+ });
+});
+
+describe("formatting display names", () => {
+ it("uses a library description", () => {
+ const source = makeMockSource("assets/backbone.js");
+ const frame = {
+ ...makeMockFrame(undefined, source, undefined, undefined, "extend/child"),
+ library: "Backbone",
+ };
+
+ expect(formatDisplayName(frame, undefined, L10N)).toEqual("Create Class");
+ });
+
+ it("shortens an anonymous function", () => {
+ const source = makeMockSource("assets/bar.js");
+ const frame = makeMockFrame(
+ undefined,
+ source,
+ undefined,
+ undefined,
+ "extend/child/bar/baz"
+ );
+
+ expect(formatDisplayName(frame, undefined, L10N)).toEqual("baz");
+ });
+
+ it("does not truncates long function names", () => {
+ const source = makeMockSource("extend/child/bar/baz");
+ const frame = makeMockFrame(
+ undefined,
+ source,
+ undefined,
+ undefined,
+ "bazbazbazbazbazbazbazbazbazbazbazbazbaz"
+ );
+
+ expect(formatDisplayName(frame, undefined, L10N)).toEqual(
+ "bazbazbazbazbazbazbazbazbazbazbazbazbaz"
+ );
+ });
+
+ it("returns the original function name when present", () => {
+ const source = makeMockSource("entry.js");
+ const frame = {
+ ...makeMockFrame(undefined, source),
+ originalDisplayName: "originalFn",
+ displayName: "fn",
+ };
+
+ expect(formatDisplayName(frame, undefined, L10N)).toEqual("originalFn");
+ });
+
+ it("returns anonymous when displayName is undefined", () => {
+ const frame = { ...makeMockFrame(), displayName: undefined };
+ expect(formatDisplayName(frame, undefined, L10N)).toEqual("<anonymous>");
+ });
+
+ it("returns anonymous when displayName is null", () => {
+ const frame = { ...makeMockFrame(), displayName: null };
+ expect(formatDisplayName(frame, undefined, L10N)).toEqual("<anonymous>");
+ });
+
+ it("returns anonymous when displayName is an empty string", () => {
+ const frame = { ...makeMockFrame(), displayName: "" };
+ expect(formatDisplayName(frame, undefined, L10N)).toEqual("<anonymous>");
+ });
+});
+
+describe("simplifying display names", () => {
+ const cases = {
+ defaultCase: [["define", "define"]],
+
+ objectProperty: [
+ ["z.foz", "foz"],
+ ["z.foz/baz", "baz"],
+ ["z.foz/baz/y.bay", "bay"],
+ ["outer/x.fox.bax.nx", "nx"],
+ ["outer/fow.baw", "baw"],
+ ["fromYUI._attach", "_attach"],
+ ["Y.ClassNameManager</getClassName", "getClassName"],
+ ["orion.textview.TextView</addHandler", "addHandler"],
+ ["this.eventPool_.createObject", "createObject"],
+ ],
+
+ arrayProperty: [
+ ["this.eventPool_[createObject]", "createObject"],
+ ["jQuery.each(^)/jQuery.fn[o]", "o"],
+ ["viewport[get+D]", "get+D"],
+ ["arr[0]", "0"],
+ ],
+
+ functionProperty: [
+ ["fromYUI._attach/<.", "_attach"],
+ ["Y.ClassNameManager<", "ClassNameManager"],
+ ["fromExtJS.setVisible/cb<", "cb"],
+ ["fromDojo.registerWin/<", "registerWin"],
+ ],
+
+ annonymousProperty: [["jQuery.each(^)", "each"]],
+
+ privateMethod: [["#privateFunc", "#privateFunc"]],
+ };
+
+ Object.keys(cases).forEach(type => {
+ cases[type].forEach(([kase, expected]) => {
+ it(`${type} - ${kase}`, () =>
+ expect(simplifyDisplayName(kase)).toEqual(expected));
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/getLibraryFromUrl.spec.js b/devtools/client/debugger/src/utils/pause/frames/tests/getLibraryFromUrl.spec.js
new file mode 100644
index 0000000000..ff5be43285
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/tests/getLibraryFromUrl.spec.js
@@ -0,0 +1,127 @@
+/* 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 { getLibraryFromUrl } from "../getLibraryFromUrl";
+import { makeMockFrameWithURL } from "../../../test-mockup";
+
+describe("getLibraryFromUrl", () => {
+ describe("When Preact is on the frame", () => {
+ it("should return Preact and not React", () => {
+ const frame = makeMockFrameWithURL(
+ "https://cdnjs.cloudflare.com/ajax/libs/preact/8.2.5/preact.js"
+ );
+ expect(getLibraryFromUrl(frame)).toEqual("Preact");
+ });
+ });
+
+ describe("When Vue is on the frame", () => {
+ it("should return VueJS for different builds", () => {
+ const buildTypeList = [
+ "vue.js",
+ "vue.common.js",
+ "vue.esm.js",
+ "vue.runtime.js",
+ "vue.runtime.common.js",
+ "vue.runtime.esm.js",
+ "vue.min.js",
+ "vue.runtime.min.js",
+ ];
+
+ buildTypeList.forEach(buildType => {
+ const frame = makeMockFrameWithURL(
+ `https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/${buildType}`
+ );
+ expect(getLibraryFromUrl(frame)).toEqual("VueJS");
+ });
+ });
+ });
+
+ describe("When React is in the URL", () => {
+ it("should not return React if it is not part of the filename", () => {
+ const notReactUrlList = [
+ "https://react.js.com/test.js",
+ "https://debugger-example.com/test.js",
+ "https://debugger-react-example.com/test.js",
+ "https://debugger-react-example.com/react/test.js",
+ "https://debugger-example.com/react-contextmenu.js",
+ ];
+ notReactUrlList.forEach(notReactUrl => {
+ const frame = makeMockFrameWithURL(notReactUrl);
+ expect(getLibraryFromUrl(frame)).toBeNull();
+ });
+ });
+ it("should return React if it is part of the filename", () => {
+ const reactUrlList = [
+ "https://debugger-example.com/react.js",
+ "https://debugger-example.com/react.development.js",
+ "https://debugger-example.com/react.production.min.js",
+ "https://debugger-react-example.com/react.js",
+ "https://debugger-react-example.com/react/react.js",
+ "https://debugger-example.com/react-dom.js",
+ "https://debugger-example.com/react-dom.development.js",
+ "https://debugger-example.com/react-dom.production.min.js",
+ "https://debugger-react-example.com/react-dom.js",
+ "https://debugger-react-example.com/react/react-dom.js",
+ "https://debugger-react-example.com/react-dom-dev.js",
+ "/node_modules/react/test.js",
+ "/node_modules/react-dev/test.js",
+ "/node_modules/react-dom/test.js",
+ "/node_modules/react-dom-dev/test.js",
+ ];
+ reactUrlList.forEach(reactUrl => {
+ const frame = makeMockFrameWithURL(reactUrl);
+ expect(getLibraryFromUrl(frame)).toEqual("React");
+ });
+ });
+ });
+
+ describe("When Angular is in the URL", () => {
+ it("should return Angular for AngularJS (1.x)", () => {
+ const frame = makeMockFrameWithURL(
+ "https://cdnjs.cloudflare.com/ajax/libs/angular/angular.js"
+ );
+ expect(getLibraryFromUrl(frame)).toEqual("Angular");
+ });
+
+ it("should return Angular for Angular (2.x)", () => {
+ const frame = makeMockFrameWithURL(
+ "https://stackblitz.io/turbo_modules/@angular/core@7.2.4/bundles/core.umd.js"
+ );
+ expect(getLibraryFromUrl(frame)).toEqual("Angular");
+ });
+
+ it("should not return Angular for Angular components", () => {
+ const frame = makeMockFrameWithURL(
+ "https://firefox-devtools-angular-log.stackblitz.io/~/src/app/hello.component.ts"
+ );
+ expect(getLibraryFromUrl(frame)).toBeNull();
+ });
+ });
+
+ describe("When zone.js is on the frame", () => {
+ it("should not return Angular when no callstack", () => {
+ const frame = makeMockFrameWithURL("/node_modules/zone/zone.js");
+ expect(getLibraryFromUrl(frame)).toBeNull();
+ });
+
+ it("should not return Angular when stack without Angular frames", () => {
+ const frame = makeMockFrameWithURL("/node_modules/zone/zone.js");
+ const callstack = [frame];
+
+ expect(getLibraryFromUrl(frame, callstack)).toBeNull();
+ });
+
+ it("should return Angular when stack with AngularJS (1.x) frames", () => {
+ const frame = makeMockFrameWithURL("/node_modules/zone/zone.js");
+ const callstack = [
+ frame,
+ makeMockFrameWithURL(
+ "https://cdnjs.cloudflare.com/ajax/libs/angular/angular.js"
+ ),
+ ];
+
+ expect(getLibraryFromUrl(frame, callstack)).toEqual("Angular");
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/pause/index.js b/devtools/client/debugger/src/utils/pause/index.js
new file mode 100644
index 0000000000..f6966999b0
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/index.js
@@ -0,0 +1,5 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+export * from "./why";
diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/README.md b/devtools/client/debugger/src/utils/pause/mapScopes/README.md
new file mode 100644
index 0000000000..2f65b8e847
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/README.md
@@ -0,0 +1,191 @@
+# mapScopes
+
+The files in this directory manage the Devtools logic for taking the original
+JS file from the sourcemap and using the content, combined with source
+mappings, to provide an enhanced user experience for debugging the code.
+
+In this document, we'll refer to the files as either:
+
+* `original` - The code that the user wrote originally.
+* `generated` - The code actually executing in the engine, which may have been
+ transformed from the `original`, and if a bundler was used, may contain the
+ final output for several different `original` files.
+
+The enhancements implemented here break down into as two primary improvements:
+
+* Rendering the scopes as the user expects them, using the position in the
+ original file, rather than the content of the generated file.
+* Allowing users to interact with the code in the console as if it were the
+ original code, rather than the generated file.
+
+
+## Overall Approach
+
+The core goal of scope mapping is to parse the original and generated files,
+and then infer a correlation between the bindings in the original file,
+and the bindings in the generated file. This is correlation is done via the
+source mappings provided alongside the original code in the sourcemap.
+
+The overall steps break down into:
+
+
+### 1. Parsing
+
+First the generated and original files are parsed into ASTs, and then the ASTs
+are each traversed in order to generate a full picture of the scopes that are
+present in the file. This covers information for each binding in each scope,
+including all metadata about the declarations themselves, and all references
+to each of the bindings. The exact location of each binding reference is the
+most important factor because these will be used when working with sourcemaps.
+
+Importantly, this scope tree's structure also mirrors the structure of the
+scope data that the engine itself returns when paused.
+
+
+### 2. Generated Binding -> Grip Correlation
+
+When the engine pauses, we get back a full description of the current scope,
+as well as `grip` objects that can be used as proxy-like objects in order to
+access the actual values in the engine itself.
+
+With this data, along with the location where the engine is paused, we can
+use the AST-based scope information from step 1 to attach a line/column
+range to each `grip`. Using those mappings we have enough information to get
+the real engine value of any variable based on its position in the generated
+code, as long as it is in scope at the paused location in the generated file.
+
+The generated and engine scopes are correlated depth-first, because in some
+cases the scope data from the engine will be deeper that the data from the
+parsed code, meaning there are additional unknown parent scopes. This can
+happen, for instance, if the executing code is running inside of an `eval`
+since the parser only knows about the scopes inside of `eval`, and cannot
+know what context it is executed inside of.
+
+
+### 3. Original Binding -> Generated Binding Correlation
+
+Now that we can get the value of a generated location, we need to decide which
+generated location correlates with which original location. For each of
+the original bindings in each original scope, we iterate through the
+individual references to the binding in the file and:
+
+1. Use the sourcemap to convert the original location into a range on the
+ generated code.
+2. Filter the available bindings in the generated file down to those that
+ overlap with this generated range.
+3. Perform heuristics to decide if any of the bindings in the range appear
+ to be a valid and safe mapping.
+
+These steps allow us to build a datastructure that describes the scope
+as it is declared in the original file, while rendering the value using the
+`grip` objects, as returned from the engine itself.
+
+There is additional complexity here in the exact details of the heuristics
+mentioned above. See the later Heuristics sections diving into these details.
+
+During this phase, one additional task is performed, which is the construction
+of expressions that relate back to the original binding. After matching each
+binding in a range, we know the name of the binding in the original scope,
+and we _also_ know the name of the generated binding, or more generally the
+expression to evaluate in order to get the value of the original binding.
+
+These expression values are important for ensuring that the developer console
+is able to allow users to interact with the original code. If a user types
+a variable into the console, it can be translated into the expression
+correlated with that original variable, allowing the code to behave as it
+would have, were it actually part of the original file.
+
+
+### 4. Generate a Scope Tree
+
+The structure generated in step 3 is converted into a structure compatible
+with the standard scope data returned from the engine itself in order to allow
+for consistent usage of scope data, irrespective of the scope data source.
+This stage also re-attaches the global scope data, which is otherwise ignored
+during the correlation process.
+
+
+### 5. Validation
+
+As a simple form of validation, we ensure that a large percentage of bindings
+were actually resolved to a `grip` object. This offers some amount of
+protection, if the sourcemap itself turned out to be somewhat low-quality.
+
+This happens often with tooling that generates maps that simply map an original
+line to a generated line. While line-to-line mappings still enable step
+debugging, they do not provide enough information for original-scope mapping.
+
+On the other hand, generally line-to-line mappings are usually generated by
+tooling that performs minimal transformations on their own, so it is often
+acceptable to fall back to the engine-only generated-file scope data in this
+case.
+
+
+## Range Mapping Heuristics
+
+Since we know lots of information about the original bindings when performing
+matches, it is possible to make educated guesses about how their generated
+code will have likely been transformed. This allows us to perform more
+aggressive matching logic what would normally be too false-positive-heavy
+for generic use, when it is deemed safe enough.
+
+
+### Standard Matching
+
+In the general case, we iterate through the bindings that were found in the
+generated range, and consider it a match if the binding overlaps with the
+start of the range. One extra case here is that ranges at the start of
+a line often point to the first binding in a range and do not overlap the
+first binding, so there is special-casing for the first binding in each range.
+
+
+### ES6 Import Reference Special Case
+
+References to ES6 imports are often transformed into property accesses on objects
+in order to emulate the "live-binding" behavior defined by ES6. The standard
+logic here would end up always resolving the imported value to be the import
+namespace object itself, rather than reading the value of the property.
+
+To support this, specifically for ES6 imports, we walk outward from the matched
+binding itself, taking property accesses into account, as long as the
+property access itself is also within the mapped range.
+
+While decently effective, there are currently two downsides to this approach:
+
+* The "live" behavior of imports is often implemented using accessor
+ properties, which as of the time of this writing, cannot be evaluated to
+ retrieve their real value.
+* The "live" behavior of imports is sometimes implemented with function calls,
+ which also also cannot be evaluated, causing their value to be
+ unknown.
+
+
+### ES6 Import Declaration Special Case
+
+If there are no references to an imported value, or matching based on the
+reference failed, we fall back to a second case.
+
+ES6 import declarations themselves often map back to the location of the
+declaration of the imported module's namespace object. By getting the range for
+the import declaration itself, we can infer which generated binding is the
+namespace object. Alongside that, we already know the name of the property on
+the namespace itself because it is statically knowable by analyzing the
+import declaration.
+
+By combining both of those pieces of information, we can access the namespace's
+property to get the imported value.
+
+
+### Typescript Classes
+
+Typescript have several non-ideal ways in which they output source maps, most
+of which center around classes that are either exported, or decorated. These
+issues are currently tracked in [Typescript issue #22833][1].
+
+To work around this issue, we use a method similar to the import declaration
+case above. While the class name itself often maps to unhelpful locations,
+the class declaration itself generally maps to the class's transformed binding,
+so we make use of the class declaration location instead of the location of
+the class's declared name in these cases.
+
+ [1]: https://github.com/Microsoft/TypeScript/issues/22833
diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/buildGeneratedBindingList.js b/devtools/client/debugger/src/utils/pause/mapScopes/buildGeneratedBindingList.js
new file mode 100644
index 0000000000..5f90138013
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/buildGeneratedBindingList.js
@@ -0,0 +1,141 @@
+/* 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 { clientCommands } from "../../../client/firefox";
+
+import { locColumn } from "./locColumn";
+import { getOptimizedOutGrip } from "./optimizedOut";
+
+export function buildGeneratedBindingList(
+ scopes,
+ generatedAstScopes,
+ thisBinding
+) {
+ // The server's binding data doesn't include general 'this' binding
+ // information, so we manually inject the one 'this' binding we have into
+ // the normal binding data we are working with.
+ const frameThisOwner = generatedAstScopes.find(
+ generated => "this" in generated.bindings
+ );
+
+ let globalScope = null;
+ const clientScopes = [];
+ for (let s = scopes; s; s = s.parent) {
+ const bindings = s.bindings
+ ? Object.assign({}, ...s.bindings.arguments, s.bindings.variables)
+ : {};
+
+ clientScopes.push(bindings);
+ globalScope = s;
+ }
+
+ const generatedMainScopes = generatedAstScopes.slice(0, -2);
+ const generatedGlobalScopes = generatedAstScopes.slice(-2);
+
+ const clientMainScopes = clientScopes.slice(0, generatedMainScopes.length);
+ const clientGlobalScopes = clientScopes.slice(generatedMainScopes.length);
+
+ // Map the main parsed script body using the nesting hierarchy of the
+ // generated and client scopes.
+ const generatedBindings = generatedMainScopes.reduce((acc, generated, i) => {
+ const bindings = clientMainScopes[i];
+
+ if (generated === frameThisOwner && thisBinding) {
+ bindings.this = {
+ value: thisBinding,
+ };
+ }
+
+ for (const name of Object.keys(generated.bindings)) {
+ // If there is no 'this' value, we exclude the binding entirely.
+ // Otherwise it would pass through as found, but "(unscoped)", causing
+ // the search logic to stop with a match.
+ if (name === "this" && !bindings[name]) {
+ continue;
+ }
+
+ const { refs } = generated.bindings[name];
+ for (const loc of refs) {
+ acc.push({
+ name,
+ loc,
+ desc: () => Promise.resolve(bindings[name] || null),
+ });
+ }
+ }
+ return acc;
+ }, []);
+
+ // Bindings in the global/lexical global of the generated code may or
+ // may not be the real global if the generated code is running inside
+ // of an evaled context. To handle this, we just look up the client scope
+ // hierarchy to find the closest binding with that name.
+ for (const generated of generatedGlobalScopes) {
+ for (const name of Object.keys(generated.bindings)) {
+ const { refs } = generated.bindings[name];
+ const bindings = clientGlobalScopes.find(b => name in b);
+
+ for (const loc of refs) {
+ if (bindings) {
+ generatedBindings.push({
+ name,
+ loc,
+ desc: () => Promise.resolve(bindings[name]),
+ });
+ } else {
+ const globalGrip = globalScope?.object;
+ if (globalGrip) {
+ // Should always exist, just checking to keep Flow happy.
+
+ generatedBindings.push({
+ name,
+ loc,
+ desc: async () => {
+ const objectFront =
+ clientCommands.createObjectFront(globalGrip);
+ return (await objectFront.getProperty(name)).descriptor;
+ },
+ });
+ }
+ }
+ }
+ }
+ }
+
+ // Sort so we can binary-search.
+ return sortBindings(generatedBindings);
+}
+
+export function buildFakeBindingList(generatedAstScopes) {
+ // TODO if possible, inject real bindings for the global scope
+ const generatedBindings = generatedAstScopes.reduce((acc, generated) => {
+ for (const name of Object.keys(generated.bindings)) {
+ if (name === "this") {
+ continue;
+ }
+ const { refs } = generated.bindings[name];
+ for (const loc of refs) {
+ acc.push({
+ name,
+ loc,
+ desc: () => Promise.resolve(getOptimizedOutGrip()),
+ });
+ }
+ }
+ return acc;
+ }, []);
+ return sortBindings(generatedBindings);
+}
+
+function sortBindings(generatedBindings) {
+ return generatedBindings.sort((a, b) => {
+ const aStart = a.loc.start;
+ const bStart = b.loc.start;
+
+ if (aStart.line === bStart.line) {
+ return locColumn(aStart) - locColumn(bStart);
+ }
+ return aStart.line - bStart.line;
+ });
+}
diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/filtering.js b/devtools/client/debugger/src/utils/pause/mapScopes/filtering.js
new file mode 100644
index 0000000000..dec3772e82
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/filtering.js
@@ -0,0 +1,45 @@
+/* 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/>. */
+
+function findInsertionLocation(array, callback) {
+ let left = 0;
+ let right = array.length;
+ while (left < right) {
+ const mid = Math.floor((left + right) / 2);
+ const item = array[mid];
+
+ const result = callback(item);
+ if (result === 0) {
+ left = mid;
+ break;
+ }
+ if (result >= 0) {
+ right = mid;
+ } else {
+ left = mid + 1;
+ }
+ }
+
+ // Ensure the value is the start of any set of matches.
+ let i = left;
+ if (i < array.length) {
+ while (i >= 0 && callback(array[i]) >= 0) {
+ i--;
+ }
+ return i + 1;
+ }
+
+ return i;
+}
+
+export function filterSortedArray(array, callback) {
+ const start = findInsertionLocation(array, callback);
+
+ const results = [];
+ for (let i = start; i < array.length && callback(array[i]) === 0; i++) {
+ results.push(array[i]);
+ }
+
+ return results;
+}
diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/findGeneratedBindingFromPosition.js b/devtools/client/debugger/src/utils/pause/mapScopes/findGeneratedBindingFromPosition.js
new file mode 100644
index 0000000000..6e833959f4
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/findGeneratedBindingFromPosition.js
@@ -0,0 +1,305 @@
+/* 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 { locColumn } from "./locColumn";
+import { mappingContains } from "./mappingContains";
+
+// eslint-disable-next-line max-len
+
+import { clientCommands } from "../../../client/firefox";
+
+/**
+ * Given a mapped range over the generated source, attempt to resolve a real
+ * binding descriptor that can be used to access the value.
+ */
+export async function findGeneratedReference(applicableBindings) {
+ // We can adjust this number as we go, but these are a decent start as a
+ // general heuristic to assume the bindings were bad or just map a chunk of
+ // whole line or something.
+ if (applicableBindings.length > 4) {
+ // Babel's for..of generates at least 3 bindings inside one range for
+ // block-scoped loop variables, so we shouldn't go below that.
+ applicableBindings = [];
+ }
+
+ for (const applicable of applicableBindings) {
+ const result = await mapBindingReferenceToDescriptor(applicable);
+ if (result) {
+ return result;
+ }
+ }
+ return null;
+}
+
+export async function findGeneratedImportReference(applicableBindings) {
+ // When wrapped, for instance as `Object(ns.default)`, the `Object` binding
+ // will be the first in the list. To avoid resolving `Object` as the
+ // value of the import itself, we potentially skip the first binding.
+ applicableBindings = applicableBindings.filter((applicable, i) => {
+ if (
+ !applicable.firstInRange ||
+ applicable.binding.loc.type !== "ref" ||
+ applicable.binding.loc.meta
+ ) {
+ return true;
+ }
+
+ const next =
+ i + 1 < applicableBindings.length ? applicableBindings[i + 1] : null;
+
+ return !next || next.binding.loc.type !== "ref" || !next.binding.loc.meta;
+ });
+
+ // We can adjust this number as we go, but these are a decent start as a
+ // general heuristic to assume the bindings were bad or just map a chunk of
+ // whole line or something.
+ if (applicableBindings.length > 2) {
+ // Babel's for..of generates at least 3 bindings inside one range for
+ // block-scoped loop variables, so we shouldn't go below that.
+ applicableBindings = [];
+ }
+
+ for (const applicable of applicableBindings) {
+ const result = await mapImportReferenceToDescriptor(applicable);
+ if (result) {
+ return result;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Given a mapped range over the generated source and the name of the imported
+ * value that is referenced, attempt to resolve a binding descriptor for
+ * the import's value.
+ */
+export async function findGeneratedImportDeclaration(
+ applicableBindings,
+ importName
+) {
+ // We can adjust this number as we go, but these are a decent start as a
+ // general heuristic to assume the bindings were bad or just map a chunk of
+ // whole line or something.
+ if (applicableBindings.length > 10) {
+ // Import declarations tend to have a large number of bindings for
+ // for things like 'require' and 'interop', so this number is larger
+ // than other binding count checks.
+ applicableBindings = [];
+ }
+
+ let result = null;
+
+ for (const { binding } of applicableBindings) {
+ if (binding.loc.type === "ref") {
+ continue;
+ }
+
+ const namespaceDesc = await binding.desc();
+ if (isPrimitiveValue(namespaceDesc)) {
+ continue;
+ }
+ if (!isObjectValue(namespaceDesc)) {
+ // We want to handle cases like
+ //
+ // var _mod = require(...);
+ // var _mod2 = _interopRequire(_mod);
+ //
+ // where "_mod" is optimized out because it is only referenced once. To
+ // allow that, we track the optimized-out value as a possible result,
+ // but allow later binding values to overwrite the result.
+ result = {
+ name: binding.name,
+ desc: namespaceDesc,
+ expression: binding.name,
+ };
+ continue;
+ }
+
+ const desc = await readDescriptorProperty(namespaceDesc, importName);
+ const expression = `${binding.name}.${importName}`;
+
+ if (desc) {
+ result = {
+ name: binding.name,
+ desc,
+ expression,
+ };
+ break;
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Given a generated binding, and a range over the generated code, statically
+ * check if the given binding matches the range.
+ */
+async function mapBindingReferenceToDescriptor({
+ binding,
+ range,
+ firstInRange,
+ firstOnLine,
+}) {
+ // Allow the mapping to point anywhere within the generated binding
+ // location to allow for less than perfect sourcemaps. Since you also
+ // need at least one character between identifiers, we also give one
+ // characters of space at the front the generated binding in order
+ // to increase the probability of finding the right mapping.
+ if (
+ range.start.line === binding.loc.start.line &&
+ // If a binding is the first on a line, Babel will extend the mapping to
+ // include the whitespace between the newline and the binding. To handle
+ // that, we skip the range requirement for starting location.
+ (firstInRange ||
+ firstOnLine ||
+ locColumn(range.start) >= locColumn(binding.loc.start)) &&
+ locColumn(range.start) <= locColumn(binding.loc.end)
+ ) {
+ return {
+ name: binding.name,
+ desc: await binding.desc(),
+ expression: binding.name,
+ };
+ }
+
+ return null;
+}
+
+/**
+ * Given an generated binding, and a range over the generated code, statically
+ * evaluate accessed properties within the mapped range to resolve the actual
+ * imported value.
+ */
+async function mapImportReferenceToDescriptor({ binding, range }) {
+ if (binding.loc.type !== "ref") {
+ return null;
+ }
+
+ // Expression matches require broader searching because sourcemaps usage
+ // varies in how they map certain things. For instance given
+ //
+ // import { bar } from "mod";
+ // bar();
+ //
+ // The "bar()" expression is generally expanded into one of two possibly
+ // forms, both of which map the "bar" identifier in different ways. See
+ // the "^^" markers below for the ranges.
+ //
+ // (0, foo.bar)() // Babel
+ // ^^^^^^^ // mapping
+ // ^^^ // binding
+ // vs
+ //
+ // __webpack_require__.i(foo.bar)() // Webpack 2
+ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // mapping
+ // ^^^ // binding
+ // vs
+ //
+ // Object(foo.bar)() // Webpack >= 3
+ // ^^^^^^^^^^^^^^^ // mapping
+ // ^^^ // binding
+ //
+ // Unfortunately, Webpack also has a tendancy to over-map past the call
+ // expression to the start of the next line, at least when there isn't
+ // anything else on that line that is mapped, e.g.
+ //
+ // Object(foo.bar)()
+ // ^^^^^^^^^^^^^^^^^
+ // ^ // wrapped to column 0 of next line
+
+ if (!mappingContains(range, binding.loc)) {
+ return null;
+ }
+
+ // Webpack 2's import declarations wrap calls with an identity fn, so we
+ // need to make sure to skip that binding because it is mapped to the
+ // location of the original binding usage.
+ if (
+ binding.name === "__webpack_require__" &&
+ binding.loc.meta &&
+ binding.loc.meta.type === "member" &&
+ binding.loc.meta.property === "i"
+ ) {
+ return null;
+ }
+
+ let expression = binding.name;
+ let desc = await binding.desc();
+
+ if (binding.loc.type === "ref") {
+ const { meta } = binding.loc;
+
+ // Limit to 2 simple property or inherits operartions, since it would
+ // just be more work to search more and it is very unlikely that
+ // bindings would be mapped to more than a single member + inherits
+ // wrapper.
+ for (
+ let op = meta, index = 0;
+ op && mappingContains(range, op) && desc && index < 2;
+ index++, op = op?.parent
+ ) {
+ // Calling could potentially trigger side-effects, which would not
+ // be ideal for this case.
+ if (op.type === "call") {
+ return null;
+ }
+
+ if (op.type === "inherit") {
+ continue;
+ }
+
+ desc = await readDescriptorProperty(desc, op.property);
+ expression += `.${op.property}`;
+ }
+ }
+
+ return desc
+ ? {
+ name: binding.name,
+ desc,
+ expression,
+ }
+ : null;
+}
+
+function isPrimitiveValue(desc) {
+ return desc && (!desc.value || typeof desc.value !== "object");
+}
+function isObjectValue(desc) {
+ return (
+ desc &&
+ !isPrimitiveValue(desc) &&
+ desc.value.type === "object" &&
+ // Note: The check for `.type` might already cover the optimizedOut case
+ // but not 100% sure, so just being cautious.
+ !desc.value.optimizedOut
+ );
+}
+
+async function readDescriptorProperty(desc, property) {
+ if (!desc) {
+ return null;
+ }
+
+ if (typeof desc.value !== "object" || !desc.value) {
+ // If accessing a property on a primitive type, just return 'undefined'
+ // as the value.
+ return {
+ value: {
+ type: "undefined",
+ },
+ };
+ }
+
+ if (!isObjectValue(desc)) {
+ // If we got a non-primitive descriptor but it isn't an object, then
+ // it's definitely not the namespace and it is probably an error.
+ return desc;
+ }
+
+ const objectFront = clientCommands.createObjectFront(desc.value);
+ return (await objectFront.getProperty(property)).descriptor;
+}
diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/getApplicableBindingsForOriginalPosition.js b/devtools/client/debugger/src/utils/pause/mapScopes/getApplicableBindingsForOriginalPosition.js
new file mode 100644
index 0000000000..c1a64ddfa0
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/getApplicableBindingsForOriginalPosition.js
@@ -0,0 +1,112 @@
+/* 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 { positionCmp } from "./positionCmp";
+import { filterSortedArray } from "./filtering";
+import { mappingContains } from "./mappingContains";
+import { getGeneratedLocation } from "../../source-maps";
+
+export async function originalRangeStartsInside({ start, end }, thunkArgs) {
+ const endPosition = await getGeneratedLocation(end, thunkArgs);
+ const startPosition = await getGeneratedLocation(start, thunkArgs);
+
+ // If the start and end positions collapse into eachother, it means that
+ // the range in the original content didn't _start_ at the start position.
+ // Since this likely means that the range doesn't logically apply to this
+ // binding location, we skip it.
+ return positionCmp(startPosition, endPosition) !== 0;
+}
+
+export async function getApplicableBindingsForOriginalPosition(
+ generatedAstBindings,
+ source,
+ { start, end },
+ bindingType,
+ locationType,
+ thunkArgs
+) {
+ const { sourceMapLoader } = thunkArgs;
+ const ranges = await sourceMapLoader.getGeneratedRanges(start);
+
+ const resultRanges = ranges.map(mapRange => ({
+ start: {
+ line: mapRange.line,
+ column: mapRange.columnStart,
+ },
+ end: {
+ line: mapRange.line,
+ // SourceMapConsumer's 'lastColumn' is inclusive, so we add 1 to make
+ // it exclusive like all other locations.
+ column: mapRange.columnEnd + 1,
+ },
+ }));
+
+ // When searching for imports, we expand the range to up to the next available
+ // mapping to allow for import declarations that are composed of multiple
+ // variable statements, where the later ones are entirely unmapped.
+ // Babel 6 produces imports in this style, e.g.
+ //
+ // var _mod = require("mod"); // mapped from import statement
+ // var _mod2 = interop(_mod); // entirely unmapped
+ if (bindingType === "import" && locationType !== "ref") {
+ const endPosition = await getGeneratedLocation(end, thunkArgs);
+ const startPosition = await getGeneratedLocation(start, thunkArgs);
+
+ for (const range of resultRanges) {
+ if (
+ mappingContains(range, { start: startPosition, end: startPosition }) &&
+ positionCmp(range.end, endPosition) < 0
+ ) {
+ range.end = {
+ line: endPosition.line,
+ column: endPosition.column,
+ };
+ break;
+ }
+ }
+ }
+
+ return filterApplicableBindings(generatedAstBindings, resultRanges);
+}
+
+function filterApplicableBindings(bindings, ranges) {
+ const result = [];
+ for (const range of ranges) {
+ // Any binding overlapping a part of the mapping range.
+ const filteredBindings = filterSortedArray(bindings, binding => {
+ if (positionCmp(binding.loc.end, range.start) <= 0) {
+ return -1;
+ }
+ if (positionCmp(binding.loc.start, range.end) >= 0) {
+ return 1;
+ }
+
+ return 0;
+ });
+
+ let firstInRange = true;
+ let firstOnLine = true;
+ let line = -1;
+
+ for (const binding of filteredBindings) {
+ if (binding.loc.start.line === line) {
+ firstOnLine = false;
+ } else {
+ line = binding.loc.start.line;
+ firstOnLine = true;
+ }
+
+ result.push({
+ binding,
+ range,
+ firstOnLine,
+ firstInRange,
+ });
+
+ firstInRange = false;
+ }
+ }
+
+ return result;
+}
diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/index.js b/devtools/client/debugger/src/utils/pause/mapScopes/index.js
new file mode 100644
index 0000000000..8736c42218
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/index.js
@@ -0,0 +1,583 @@
+/* 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 {
+ debuggerToSourceMapLocation,
+ sourceMapToDebuggerLocation,
+} from "../../location";
+import { locColumn } from "./locColumn";
+import { loadRangeMetadata, findMatchingRange } from "./rangeMetadata";
+
+// eslint-disable-next-line max-len
+import {
+ findGeneratedReference,
+ findGeneratedImportReference,
+ findGeneratedImportDeclaration,
+} from "./findGeneratedBindingFromPosition";
+import {
+ buildGeneratedBindingList,
+ buildFakeBindingList,
+} from "./buildGeneratedBindingList";
+import {
+ originalRangeStartsInside,
+ getApplicableBindingsForOriginalPosition,
+} from "./getApplicableBindingsForOriginalPosition";
+import { getOptimizedOutGrip } from "./optimizedOut";
+
+import { log } from "../../log";
+
+// Create real location objects for all location start and end.
+//
+// Parser worker returns scopes with location having a sourceId
+// instead of a source object as it doesn't know about main thread source objects.
+function updateLocationsInScopes(state, scopes) {
+ for (const item of scopes) {
+ for (const name of Object.keys(item.bindings)) {
+ for (const ref of item.bindings[name].refs) {
+ const locs = [ref];
+ if (ref.type !== "ref") {
+ locs.push(ref.declaration);
+ }
+ for (const loc of locs) {
+ loc.start = sourceMapToDebuggerLocation(state, loc.start);
+ loc.end = sourceMapToDebuggerLocation(state, loc.end);
+ }
+ }
+ }
+ }
+}
+
+export async function buildMappedScopes(
+ source,
+ content,
+ frame,
+ scopes,
+ thunkArgs
+) {
+ const { getState, parserWorker } = thunkArgs;
+ if (!parserWorker.isLocationSupported(frame.location)) {
+ return null;
+ }
+ const originalAstScopes = await parserWorker.getScopes(frame.location);
+ updateLocationsInScopes(getState(), originalAstScopes);
+ const generatedAstScopes = await parserWorker.getScopes(
+ frame.generatedLocation
+ );
+ updateLocationsInScopes(getState(), generatedAstScopes);
+
+ if (!originalAstScopes || !generatedAstScopes) {
+ return null;
+ }
+
+ const originalRanges = await loadRangeMetadata(
+ frame.location,
+ originalAstScopes,
+ thunkArgs
+ );
+
+ if (hasLineMappings(originalRanges)) {
+ return null;
+ }
+
+ let generatedAstBindings;
+ if (scopes) {
+ generatedAstBindings = buildGeneratedBindingList(
+ scopes,
+ generatedAstScopes,
+ frame.this
+ );
+ } else {
+ generatedAstBindings = buildFakeBindingList(generatedAstScopes);
+ }
+
+ const { mappedOriginalScopes, expressionLookup } =
+ await mapOriginalBindingsToGenerated(
+ source,
+ content,
+ originalRanges,
+ originalAstScopes,
+ generatedAstBindings,
+ thunkArgs
+ );
+
+ const globalLexicalScope = scopes
+ ? getGlobalFromScope(scopes)
+ : generateGlobalFromAst(generatedAstScopes);
+ const mappedGeneratedScopes = generateClientScope(
+ globalLexicalScope,
+ mappedOriginalScopes
+ );
+
+ return isReliableScope(mappedGeneratedScopes)
+ ? { mappings: expressionLookup, scope: mappedGeneratedScopes }
+ : null;
+}
+
+async function mapOriginalBindingsToGenerated(
+ source,
+ content,
+ originalRanges,
+ originalAstScopes,
+ generatedAstBindings,
+ thunkArgs
+) {
+ const expressionLookup = {};
+ const mappedOriginalScopes = [];
+
+ const cachedSourceMaps = batchScopeMappings(
+ originalAstScopes,
+ source,
+ thunkArgs
+ );
+ // Override sourceMapLoader attribute with the special cached SourceMapLoader instance
+ // in order to make it used by all functions used in this method.
+ thunkArgs = { ...thunkArgs, sourceMapLoader: cachedSourceMaps };
+
+ for (const item of originalAstScopes) {
+ const generatedBindings = {};
+
+ for (const name of Object.keys(item.bindings)) {
+ const binding = item.bindings[name];
+
+ const result = await findGeneratedBinding(
+ source,
+ content,
+ name,
+ binding,
+ originalRanges,
+ generatedAstBindings,
+ thunkArgs
+ );
+
+ if (result) {
+ generatedBindings[name] = result.grip;
+
+ if (
+ binding.refs.length !== 0 &&
+ // These are assigned depth-first, so we don't want shadowed
+ // bindings in parent scopes overwriting the expression.
+ !Object.prototype.hasOwnProperty.call(expressionLookup, name)
+ ) {
+ expressionLookup[name] = result.expression;
+ }
+ }
+ }
+
+ mappedOriginalScopes.push({
+ ...item,
+ generatedBindings,
+ });
+ }
+
+ return {
+ mappedOriginalScopes,
+ expressionLookup,
+ };
+}
+
+/**
+ * Consider a scope and its parents reliable if the vast majority of its
+ * bindings were successfully mapped to generated scope bindings.
+ */
+function isReliableScope(scope) {
+ let totalBindings = 0;
+ let unknownBindings = 0;
+
+ for (let s = scope; s; s = s.parent) {
+ const vars = s.bindings?.variables || {};
+ for (const key of Object.keys(vars)) {
+ const binding = vars[key];
+
+ totalBindings += 1;
+ if (
+ binding.value &&
+ typeof binding.value === "object" &&
+ (binding.value.type === "unscoped" || binding.value.type === "unmapped")
+ ) {
+ unknownBindings += 1;
+ }
+ }
+ }
+
+ // As determined by fair dice roll.
+ return totalBindings === 0 || unknownBindings / totalBindings < 0.25;
+}
+
+function hasLineMappings(ranges) {
+ return ranges.every(
+ range => range.columnStart === 0 && range.columnEnd === Infinity
+ );
+}
+
+/**
+ * Build a special SourceMapLoader instance, based on the one passed in thunkArgs,
+ * which will both:
+ * - preload generated ranges/locations for original locations mentioned
+ * in originalAstScopes
+ * - cache the requests to fetch these genereated ranges/locations
+ */
+function batchScopeMappings(originalAstScopes, source, thunkArgs) {
+ const { sourceMapLoader } = thunkArgs;
+ const precalculatedRanges = new Map();
+ const precalculatedLocations = new Map();
+
+ // Explicitly dispatch all of the sourcemap requests synchronously up front so
+ // that they will be batched into a single request for the worker to process.
+ for (const item of originalAstScopes) {
+ for (const name of Object.keys(item.bindings)) {
+ for (const ref of item.bindings[name].refs) {
+ const locs = [ref];
+ if (ref.type !== "ref") {
+ locs.push(ref.declaration);
+ }
+
+ for (const loc of locs) {
+ precalculatedRanges.set(
+ buildLocationKey(loc.start),
+ sourceMapLoader.getGeneratedRanges(
+ debuggerToSourceMapLocation(loc.start)
+ )
+ );
+ precalculatedLocations.set(
+ buildLocationKey(loc.start),
+ sourceMapLoader.getGeneratedLocation(
+ debuggerToSourceMapLocation(loc.start)
+ )
+ );
+ precalculatedLocations.set(
+ buildLocationKey(loc.end),
+ sourceMapLoader.getGeneratedLocation(
+ debuggerToSourceMapLocation(loc.end)
+ )
+ );
+ }
+ }
+ }
+ }
+
+ return {
+ async getGeneratedRanges(pos) {
+ const key = buildLocationKey(pos);
+
+ if (!precalculatedRanges.has(key)) {
+ log("Bad precalculated mapping");
+ return sourceMapLoader.getGeneratedRanges(
+ debuggerToSourceMapLocation(pos)
+ );
+ }
+ return precalculatedRanges.get(key);
+ },
+
+ async getGeneratedLocation(pos) {
+ const key = buildLocationKey(pos);
+
+ if (!precalculatedLocations.has(key)) {
+ log("Bad precalculated mapping");
+ return sourceMapLoader.getGeneratedLocation(
+ debuggerToSourceMapLocation(pos)
+ );
+ }
+ return precalculatedLocations.get(key);
+ },
+ };
+}
+function buildLocationKey(loc) {
+ return `${loc.line}:${locColumn(loc)}`;
+}
+
+function generateClientScope(globalLexicalScope, originalScopes) {
+ // Build a structure similar to the client's linked scope object using
+ // the original AST scopes, but pulling in the generated bindings
+ // linked to each scope.
+ const result = originalScopes
+ .slice(0, -2)
+ .reverse()
+ .reduce((acc, orig, i) => {
+ const {
+ // The 'this' binding data we have is handled independently, so
+ // the binding data is not included here.
+ // eslint-disable-next-line no-unused-vars
+ this: _this,
+ ...variables
+ } = orig.generatedBindings;
+
+ return {
+ parent: acc,
+ actor: `originalActor${i}`,
+ type: orig.type,
+ scopeKind: orig.scopeKind,
+ bindings: {
+ arguments: [],
+ variables,
+ },
+ ...(orig.type === "function"
+ ? {
+ function: {
+ displayName: orig.displayName,
+ },
+ }
+ : null),
+ ...(orig.type === "block"
+ ? {
+ block: {
+ displayName: orig.displayName,
+ },
+ }
+ : null),
+ };
+ }, globalLexicalScope);
+
+ // The rendering logic in getScope 'this' bindings only runs on the current
+ // selected frame scope, so we pluck out the 'this' binding that was mapped,
+ // and put it in a special location
+ const thisScope = originalScopes.find(scope => scope.bindings.this);
+ if (result.bindings && thisScope) {
+ result.bindings.this = thisScope.generatedBindings.this || null;
+ }
+
+ return result;
+}
+
+function getGlobalFromScope(scopes) {
+ // Pull the root object scope and root lexical scope to reuse them in
+ // our mapped scopes. This assumes that file being processed is
+ // a CommonJS or ES6 module, which might not be ideal. Potentially
+ // should add some logic to try to detect those cases?
+ let globalLexicalScope = null;
+ for (let s = scopes; s.parent; s = s.parent) {
+ globalLexicalScope = s;
+ }
+ if (!globalLexicalScope) {
+ throw new Error("Assertion failure - there should always be a scope");
+ }
+ return globalLexicalScope;
+}
+
+function generateGlobalFromAst(generatedScopes) {
+ const globalLexicalAst = generatedScopes[generatedScopes.length - 2];
+ if (!globalLexicalAst) {
+ throw new Error("Assertion failure - there should always be a scope");
+ }
+ return {
+ actor: "generatedActor1",
+ type: "block",
+ scopeKind: "",
+ bindings: {
+ arguments: [],
+ variables: Object.fromEntries(
+ Object.keys(globalLexicalAst).map(key => [key, getOptimizedOutGrip()])
+ ),
+ },
+ parent: {
+ actor: "generatedActor0",
+ object: getOptimizedOutGrip(),
+ scopeKind: "",
+ type: "object",
+ },
+ };
+}
+
+function hasValidIdent(range, pos) {
+ return (
+ range.type === "match" ||
+ // For declarations, we allow the range on the identifier to be a
+ // more general "contains" to increase the chances of a match.
+ (pos.type !== "ref" && range.type === "contains")
+ );
+}
+
+// eslint-disable-next-line complexity
+async function findGeneratedBinding(
+ source,
+ content,
+ name,
+ originalBinding,
+ originalRanges,
+ generatedAstBindings,
+ thunkArgs
+) {
+ // If there are no references to the implicits, then we have no way to
+ // even attempt to map it back to the original since there is no location
+ // data to use. Bail out instead of just showing it as unmapped.
+ if (
+ originalBinding.type === "implicit" &&
+ !originalBinding.refs.some(item => item.type === "ref")
+ ) {
+ return null;
+ }
+
+ const loadApplicableBindings = async (pos, locationType) => {
+ let applicableBindings = await getApplicableBindingsForOriginalPosition(
+ generatedAstBindings,
+ source,
+ pos,
+ originalBinding.type,
+ locationType,
+ thunkArgs
+ );
+ if (applicableBindings.length) {
+ hadApplicableBindings = true;
+ }
+ if (locationType === "ref") {
+ // Some tooling creates ranges that map a line as a whole, which is useful
+ // for step-debugging, but can easily lead to finding the wrong binding.
+ // To avoid these false-positives, we entirely ignore bindings matched
+ // by ranges that cover full lines.
+ applicableBindings = applicableBindings.filter(
+ ({ range }) =>
+ !(range.start.column === 0 && range.end.column === Infinity)
+ );
+ }
+ if (
+ locationType !== "ref" &&
+ !(await originalRangeStartsInside(pos, thunkArgs))
+ ) {
+ applicableBindings = [];
+ }
+ return applicableBindings;
+ };
+
+ const { refs } = originalBinding;
+
+ let hadApplicableBindings = false;
+ let genContent = null;
+ for (const pos of refs) {
+ const applicableBindings = await loadApplicableBindings(pos, pos.type);
+
+ const range = findMatchingRange(originalRanges, pos);
+ if (range && hasValidIdent(range, pos)) {
+ if (originalBinding.type === "import") {
+ genContent = await findGeneratedImportReference(applicableBindings);
+ } else {
+ genContent = await findGeneratedReference(applicableBindings);
+ }
+ }
+
+ if (
+ (pos.type === "class-decl" || pos.type === "class-inner") &&
+ content.contentType &&
+ content.contentType.match(/\/typescript/)
+ ) {
+ const declRange = findMatchingRange(originalRanges, pos.declaration);
+ if (declRange && declRange.type !== "multiple") {
+ const applicableDeclBindings = await loadApplicableBindings(
+ pos.declaration,
+ pos.type
+ );
+
+ // Resolve to first binding in the range
+ const declContent = await findGeneratedReference(
+ applicableDeclBindings
+ );
+
+ if (declContent) {
+ // Prefer the declaration mapping in this case because TS sometimes
+ // maps class declaration names to "export.Foo = Foo;" or to
+ // the decorator logic itself
+ genContent = declContent;
+ }
+ }
+ }
+
+ if (
+ !genContent &&
+ pos.type === "import-decl" &&
+ typeof pos.importName === "string"
+ ) {
+ const { importName } = pos;
+ const declRange = findMatchingRange(originalRanges, pos.declaration);
+
+ // The import declaration should have an original position mapping,
+ // but otherwise we don't really have preferences on the range type
+ // because it can have multiple bindings, but we do want to make sure
+ // that all of the bindings that match the range are part of the same
+ // import declaration.
+ if (declRange?.singleDeclaration) {
+ const applicableDeclBindings = await loadApplicableBindings(
+ pos.declaration,
+ pos.type
+ );
+
+ // match the import declaration location
+ genContent = await findGeneratedImportDeclaration(
+ applicableDeclBindings,
+ importName
+ );
+ }
+ }
+
+ if (genContent) {
+ break;
+ }
+ }
+
+ if (genContent && genContent.desc) {
+ return {
+ grip: genContent.desc,
+ expression: genContent.expression,
+ };
+ } else if (genContent) {
+ // If there is no descriptor for 'this', then this is not the top-level
+ // 'this' that the server gave us a binding for, and we can just ignore it.
+ if (name === "this") {
+ return null;
+ }
+
+ // If the location is found but the descriptor is not, then it
+ // means that the server scope information didn't match the scope
+ // information from the DevTools parsed scopes.
+ return {
+ grip: {
+ configurable: false,
+ enumerable: true,
+ writable: false,
+ value: {
+ type: "unscoped",
+ unscoped: true,
+
+ // HACK: Until support for "unscoped" lands in devtools-reps,
+ // this will make these show as (unavailable).
+ missingArguments: true,
+ },
+ },
+ expression: null,
+ };
+ } else if (!hadApplicableBindings && name !== "this") {
+ // If there were no applicable bindings to consider while searching for
+ // matching bindings, then the source map for this file didn't make any
+ // attempt to map the binding, and that most likely means that the
+ // code was entirely emitted from the output code.
+ return {
+ grip: getOptimizedOutGrip(),
+ expression: `
+ (() => {
+ throw new Error('"' + ${JSON.stringify(
+ name
+ )} + '" has been optimized out.');
+ })()
+ `,
+ };
+ }
+
+ // If no location mapping is found, then the map is bad, or
+ // the map is okay but it original location is inside
+ // of some scope, but the generated location is outside, leading
+ // us to search for bindings that don't technically exist.
+ return {
+ grip: {
+ configurable: false,
+ enumerable: true,
+ writable: false,
+ value: {
+ type: "unmapped",
+ unmapped: true,
+
+ // HACK: Until support for "unmapped" lands in devtools-reps,
+ // this will make these show as (unavailable).
+ missingArguments: true,
+ },
+ },
+ expression: null,
+ };
+}
diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/locColumn.js b/devtools/client/debugger/src/utils/pause/mapScopes/locColumn.js
new file mode 100644
index 0000000000..075e902299
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/locColumn.js
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+export function locColumn(loc) {
+ if (typeof loc.column !== "number") {
+ // This shouldn't really happen with locations from the AST, but
+ // the datatype we are using allows null/undefined column.
+ return 0;
+ }
+
+ return loc.column;
+}
diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/mappingContains.js b/devtools/client/debugger/src/utils/pause/mapScopes/mappingContains.js
new file mode 100644
index 0000000000..ca82fe42ec
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/mappingContains.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/>. */
+
+import { positionCmp } from "./positionCmp";
+
+export function mappingContains(mapped, item) {
+ return (
+ positionCmp(item.start, mapped.start) >= 0 &&
+ positionCmp(item.end, mapped.end) <= 0
+ );
+}
diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/moz.build b/devtools/client/debugger/src/utils/pause/mapScopes/moz.build
new file mode 100644
index 0000000000..05f2b7e3d8
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/moz.build
@@ -0,0 +1,19 @@
+# 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(
+ "buildGeneratedBindingList.js",
+ "filtering.js",
+ "findGeneratedBindingFromPosition.js",
+ "getApplicableBindingsForOriginalPosition.js",
+ "index.js",
+ "locColumn.js",
+ "mappingContains.js",
+ "optimizedOut.js",
+ "positionCmp.js",
+ "rangeMetadata.js",
+)
diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/optimizedOut.js b/devtools/client/debugger/src/utils/pause/mapScopes/optimizedOut.js
new file mode 100644
index 0000000000..755b308a2d
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/optimizedOut.js
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+export function getOptimizedOutGrip() {
+ return {
+ configurable: false,
+ enumerable: true,
+ writable: false,
+ value: {
+ type: "null",
+ optimizedOut: true,
+ },
+ };
+}
diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/positionCmp.js b/devtools/client/debugger/src/utils/pause/mapScopes/positionCmp.js
new file mode 100644
index 0000000000..5d53fb933e
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/positionCmp.js
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { locColumn } from "./locColumn";
+
+/**
+ * * === 0 - Positions are equal.
+ * * < 0 - first position before second position
+ * * > 0 - first position after second position
+ */
+export function positionCmp(p1, p2) {
+ if (p1.line === p2.line) {
+ const l1 = locColumn(p1);
+ const l2 = locColumn(p2);
+
+ if (l1 === l2) {
+ return 0;
+ }
+ return l1 < l2 ? -1 : 1;
+ }
+
+ return p1.line < p2.line ? -1 : 1;
+}
diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/rangeMetadata.js b/devtools/client/debugger/src/utils/pause/mapScopes/rangeMetadata.js
new file mode 100644
index 0000000000..7a22313aca
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/rangeMetadata.js
@@ -0,0 +1,117 @@
+/* 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 { locColumn } from "./locColumn";
+import { positionCmp } from "./positionCmp";
+import { filterSortedArray } from "./filtering";
+
+// * match - Range contains a single identifier with matching start location
+// * contains - Range contains a single identifier with non-matching start
+// * multiple - Range contains multiple identifiers
+// * empty - Range contains no identifiers
+
+export async function loadRangeMetadata(
+ location,
+ originalAstScopes,
+ { sourceMapLoader }
+) {
+ const originalRanges = await sourceMapLoader.getOriginalRanges(
+ location.sourceId
+ );
+
+ const sortedOriginalAstBindings = [];
+ for (const item of originalAstScopes) {
+ for (const name of Object.keys(item.bindings)) {
+ for (const ref of item.bindings[name].refs) {
+ sortedOriginalAstBindings.push(ref);
+ }
+ }
+ }
+ sortedOriginalAstBindings.sort((a, b) => positionCmp(a.start, b.start));
+
+ let i = 0;
+
+ return originalRanges.map(range => {
+ const bindings = [];
+
+ while (
+ i < sortedOriginalAstBindings.length &&
+ (sortedOriginalAstBindings[i].start.line < range.line ||
+ (sortedOriginalAstBindings[i].start.line === range.line &&
+ locColumn(sortedOriginalAstBindings[i].start) < range.columnStart))
+ ) {
+ i++;
+ }
+
+ while (
+ i < sortedOriginalAstBindings.length &&
+ sortedOriginalAstBindings[i].start.line === range.line &&
+ locColumn(sortedOriginalAstBindings[i].start) >= range.columnStart &&
+ locColumn(sortedOriginalAstBindings[i].start) < range.columnEnd
+ ) {
+ const lastBinding = bindings[bindings.length - 1];
+ // Only add bindings when they're in new positions
+ if (
+ !lastBinding ||
+ positionCmp(lastBinding.start, sortedOriginalAstBindings[i].start) !== 0
+ ) {
+ bindings.push(sortedOriginalAstBindings[i]);
+ }
+ i++;
+ }
+
+ let type = "empty";
+ let singleDeclaration = true;
+ if (bindings.length === 1) {
+ const binding = bindings[0];
+ if (
+ binding.start.line === range.line &&
+ binding.start.column === range.columnStart
+ ) {
+ type = "match";
+ } else {
+ type = "contains";
+ }
+ } else if (bindings.length > 1) {
+ type = "multiple";
+ const binding = bindings[0];
+ const declStart =
+ binding.type !== "ref" ? binding.declaration.start : null;
+
+ singleDeclaration = bindings.every(b => {
+ return (
+ declStart &&
+ b.type !== "ref" &&
+ positionCmp(declStart, b.declaration.start) === 0
+ );
+ });
+ }
+
+ return {
+ type,
+ singleDeclaration,
+ ...range,
+ };
+ });
+}
+
+export function findMatchingRange(sortedOriginalRanges, bindingRange) {
+ return filterSortedArray(sortedOriginalRanges, range => {
+ if (range.line < bindingRange.start.line) {
+ return -1;
+ }
+ if (range.line > bindingRange.start.line) {
+ return 1;
+ }
+
+ if (range.columnEnd <= locColumn(bindingRange.start)) {
+ return -1;
+ }
+ if (range.columnStart > locColumn(bindingRange.start)) {
+ return 1;
+ }
+
+ return 0;
+ }).pop();
+}
diff --git a/devtools/client/debugger/src/utils/pause/moz.build b/devtools/client/debugger/src/utils/pause/moz.build
new file mode 100644
index 0000000000..e0705d3115
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/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 += [
+ "frames",
+ "mapScopes",
+ "scopes",
+]
+
+CompiledModules(
+ "index.js",
+ "why.js",
+)
diff --git a/devtools/client/debugger/src/utils/pause/scopes/getScope.js b/devtools/client/debugger/src/utils/pause/scopes/getScope.js
new file mode 100644
index 0000000000..549e387d51
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/scopes/getScope.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/>. */
+
+import { objectInspector } from "devtools/client/shared/components/reps/index";
+import { getBindingVariables } from "./getVariables";
+import { getFramePopVariables, getThisVariable } from "./utils";
+import { simplifyDisplayName } from "../../pause/frames";
+
+const {
+ utils: {
+ node: { NODE_TYPES },
+ },
+} = objectInspector;
+
+function getScopeTitle(type, scope) {
+ if (type === "block" && scope.block && scope.block.displayName) {
+ return scope.block.displayName;
+ }
+
+ if (type === "function" && scope.function) {
+ return scope.function.displayName
+ ? simplifyDisplayName(scope.function.displayName)
+ : L10N.getStr("anonymousFunction");
+ }
+ return L10N.getStr("scopes.block");
+}
+
+export function getScope(scope, selectedFrame, frameScopes, why, scopeIndex) {
+ const { type, actor } = scope;
+
+ const isLocalScope = scope.actor === frameScopes.actor;
+
+ const key = `${actor}-${scopeIndex}`;
+ if (type === "function" || type === "block") {
+ const { bindings } = scope;
+
+ let vars = getBindingVariables(bindings, key);
+
+ // show exception, return, and this variables in innermost scope
+ if (isLocalScope) {
+ vars = vars.concat(getFramePopVariables(why, key));
+
+ let thisDesc_ = selectedFrame.this;
+
+ if (bindings && "this" in bindings) {
+ // The presence of "this" means we're rendering a "this" binding
+ // generated from mapScopes and this can override the binding
+ // provided by the current frame.
+ thisDesc_ = bindings.this ? bindings.this.value : null;
+ }
+
+ const this_ = getThisVariable(thisDesc_, key);
+
+ if (this_) {
+ vars.push(this_);
+ }
+ }
+
+ if (vars?.length) {
+ const title = getScopeTitle(type, scope) || "";
+ vars.sort((a, b) => a.name.localeCompare(b.name));
+ return {
+ name: title,
+ path: key,
+ contents: vars,
+ type: NODE_TYPES.BLOCK,
+ };
+ }
+ } else if (type === "object" && scope.object) {
+ let value = scope.object;
+ // If this is the global window scope, mark it as such so that it will
+ // preview Window: Global instead of Window: Window
+ if (value.class === "Window") {
+ value = { ...scope.object, displayClass: "Global" };
+ }
+ return {
+ name: scope.object.class,
+ path: key,
+ contents: { value },
+ };
+ }
+
+ return null;
+}
+
+export function mergeScopes(scope, parentScope, item, parentItem) {
+ if (scope.scopeKind == "function lexical" && parentScope.type == "function") {
+ const contents = item.contents.concat(parentItem.contents);
+ contents.sort((a, b) => a.name.localeCompare(b.name));
+
+ return {
+ name: parentItem.name,
+ path: parentItem.path,
+ contents,
+ type: NODE_TYPES.BLOCK,
+ };
+ }
+
+ return null;
+}
diff --git a/devtools/client/debugger/src/utils/pause/scopes/getVariables.js b/devtools/client/debugger/src/utils/pause/scopes/getVariables.js
new file mode 100644
index 0000000000..3346a99cdb
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/scopes/getVariables.js
@@ -0,0 +1,32 @@
+/* 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/>. */
+
+// VarAndBindingsPair actually is [name: string, contents: BindingContents]
+
+// Scope's bindings field which holds variables and arguments
+
+// Create the tree nodes representing all the variables and arguments
+// for the bindings from a scope.
+export function getBindingVariables(bindings, parentName) {
+ if (!bindings) {
+ return [];
+ }
+
+ const nodes = [];
+ const addNode = (name, contents) =>
+ nodes.push({ name, contents, path: `${parentName}/${name}` });
+
+ for (const arg of bindings.arguments) {
+ // `arg` is an object which only has a single property whose name is the name of the
+ // argument. So here we can directly pick the first (and only) entry of `arg`
+ const [name, contents] = Object.entries(arg)[0];
+ addNode(name, contents);
+ }
+
+ for (const name in bindings.variables) {
+ addNode(name, bindings.variables[name]);
+ }
+
+ return nodes;
+}
diff --git a/devtools/client/debugger/src/utils/pause/scopes/index.js b/devtools/client/debugger/src/utils/pause/scopes/index.js
new file mode 100644
index 0000000000..2f24a41640
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/scopes/index.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { getScope, mergeScopes } from "./getScope";
+
+export function getScopes(why, selectedFrame, frameScopes) {
+ if (!why || !selectedFrame) {
+ return null;
+ }
+
+ if (!frameScopes) {
+ return null;
+ }
+
+ const scopes = [];
+
+ let scope = frameScopes;
+ let scopeIndex = 1;
+ let prev = null,
+ prevItem = null;
+
+ while (scope) {
+ let scopeItem = getScope(
+ scope,
+ selectedFrame,
+ frameScopes,
+ why,
+ scopeIndex
+ );
+
+ if (scopeItem) {
+ const mergedItem =
+ prev && prevItem ? mergeScopes(prev, scope, prevItem, scopeItem) : null;
+ if (mergedItem) {
+ scopeItem = mergedItem;
+ scopes.pop();
+ }
+ scopes.push(scopeItem);
+ }
+ prev = scope;
+ prevItem = scopeItem;
+ scopeIndex++;
+ scope = scope.parent;
+ }
+
+ return scopes;
+}
diff --git a/devtools/client/debugger/src/utils/pause/scopes/moz.build b/devtools/client/debugger/src/utils/pause/scopes/moz.build
new file mode 100644
index 0000000000..059d187e3d
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/scopes/moz.build
@@ -0,0 +1,13 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += []
+
+CompiledModules(
+ "getScope.js",
+ "getVariables.js",
+ "index.js",
+ "utils.js",
+)
diff --git a/devtools/client/debugger/src/utils/pause/scopes/tests/getFramePopVariables.spec.js b/devtools/client/debugger/src/utils/pause/scopes/tests/getFramePopVariables.spec.js
new file mode 100644
index 0000000000..cf05c71345
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/scopes/tests/getFramePopVariables.spec.js
@@ -0,0 +1,114 @@
+/* eslint max-nested-callbacks: ["error", 4] */
+/* 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 { getFramePopVariables } from "../utils";
+
+const errorGrip = {
+ type: "object",
+ actor: "server2.conn66.child1/obj243",
+ class: "Error",
+ extensible: true,
+ frozen: false,
+ sealed: false,
+ ownPropertyLength: 4,
+ preview: {
+ kind: "Error",
+ name: "Error",
+ message: "blah",
+ stack:
+ "onclick@http://localhost:8000/examples/doc-return-values.html:1:18\n",
+ fileName: "http://localhost:8000/examples/doc-return-values.html",
+ lineNumber: 1,
+ columnNumber: 18,
+ },
+};
+
+function returnWhy(grip) {
+ return {
+ type: "resumeLimit",
+ frameFinished: {
+ return: grip,
+ },
+ };
+}
+
+function throwWhy(grip) {
+ return {
+ type: "resumeLimit",
+ frameFinished: {
+ throw: grip,
+ },
+ };
+}
+
+function getContentsValue(v) {
+ return v.contents.value;
+}
+
+function getContentsClass(v) {
+ const value = getContentsValue(v);
+ return value ? value.class || undefined : "";
+}
+
+describe("pause - scopes", () => {
+ describe("getFramePopVariables", () => {
+ describe("falsey values", () => {
+ // NOTE: null and undefined are treated like objects and given a type
+ const falsey = { false: false, 0: 0, null: { type: "null" } };
+ for (const test in falsey) {
+ const value = falsey[test];
+ it(`shows ${test} returns`, () => {
+ const why = returnWhy(value);
+ const vars = getFramePopVariables(why, "");
+ expect(vars[0].name).toEqual("<return>");
+ expect(vars[0].name).toEqual("<return>");
+ expect(getContentsValue(vars[0])).toEqual(value);
+ });
+
+ it(`shows ${test} throws`, () => {
+ const why = throwWhy(value);
+ const vars = getFramePopVariables(why, "");
+ expect(vars[0].name).toEqual("<exception>");
+ expect(vars[0].name).toEqual("<exception>");
+ expect(getContentsValue(vars[0])).toEqual(value);
+ });
+ }
+ });
+
+ describe("Error / Objects", () => {
+ it("shows Error returns", () => {
+ const why = returnWhy(errorGrip);
+ const vars = getFramePopVariables(why, "");
+ expect(vars[0].name).toEqual("<return>");
+ expect(vars[0].name).toEqual("<return>");
+ expect(getContentsClass(vars[0])).toEqual("Error");
+ });
+
+ it("shows error throws", () => {
+ const why = throwWhy(errorGrip);
+ const vars = getFramePopVariables(why, "");
+ expect(vars[0].name).toEqual("<exception>");
+ expect(vars[0].name).toEqual("<exception>");
+ expect(getContentsClass(vars[0])).toEqual("Error");
+ });
+ });
+
+ describe("undefined", () => {
+ it("does not show undefined returns", () => {
+ const why = returnWhy({ type: "undefined" });
+ const vars = getFramePopVariables(why, "");
+ expect(vars).toHaveLength(0);
+ });
+
+ it("shows undefined throws", () => {
+ const why = throwWhy({ type: "undefined" });
+ const vars = getFramePopVariables(why, "");
+ expect(vars[0].name).toEqual("<exception>");
+ expect(vars[0].name).toEqual("<exception>");
+ expect(getContentsValue(vars[0])).toEqual({ type: "undefined" });
+ });
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/pause/scopes/tests/scopes.spec.js b/devtools/client/debugger/src/utils/pause/scopes/tests/scopes.spec.js
new file mode 100644
index 0000000000..07a962dfab
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/scopes/tests/scopes.spec.js
@@ -0,0 +1,134 @@
+/* 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 { getScopes } from "..";
+import {
+ makeMockFrame,
+ makeMockScope,
+ makeWhyNormal,
+ makeWhyThrow,
+ mockScopeAddVariable,
+} from "../../../test-mockup";
+
+function convertScope(scope) {
+ return scope;
+}
+
+describe("scopes", () => {
+ describe("getScopes", () => {
+ it("single scope", () => {
+ const pauseData = makeWhyNormal();
+ const scope = makeMockScope("actor1");
+ const selectedFrame = makeMockFrame(undefined, undefined, scope);
+
+ if (!selectedFrame.scope) {
+ throw new Error("Frame must include scopes");
+ }
+
+ const frameScopes = convertScope(selectedFrame.scope);
+ const scopes = getScopes(pauseData, selectedFrame, frameScopes);
+ if (!scopes) {
+ throw new Error("missing scopes");
+ }
+ expect(scopes[0].path).toEqual("actor1-1");
+ expect(scopes[0].contents[0]).toEqual({
+ name: "<this>",
+ path: "actor1-1/<this>",
+ contents: { value: {} },
+ });
+ });
+
+ it("second scope", () => {
+ const pauseData = makeWhyNormal();
+ const scope0 = makeMockScope("actor2");
+ const scope1 = makeMockScope("actor1", undefined, scope0);
+ const selectedFrame = makeMockFrame(undefined, undefined, scope1);
+ mockScopeAddVariable(scope0, "foo");
+
+ if (!selectedFrame.scope) {
+ throw new Error("Frame must include scopes");
+ }
+
+ const frameScopes = convertScope(selectedFrame.scope);
+ const scopes = getScopes(pauseData, selectedFrame, frameScopes);
+ if (!scopes) {
+ throw new Error("missing scopes");
+ }
+ expect(scopes[1].path).toEqual("actor2-2");
+ expect(scopes[1].contents[0]).toEqual({
+ name: "foo",
+ path: "actor2-2/foo",
+ contents: { value: null },
+ });
+ });
+
+ it("returning scope", () => {
+ const why = makeWhyNormal("to sender");
+ const scope = makeMockScope("actor1");
+ const selectedFrame = makeMockFrame(undefined, undefined, scope);
+
+ if (!selectedFrame.scope) {
+ throw new Error("Frame must include scopes");
+ }
+
+ const frameScopes = convertScope(selectedFrame.scope);
+ const scopes = getScopes(why, selectedFrame, frameScopes);
+ expect(scopes).toMatchObject([
+ {
+ path: "actor1-1",
+ contents: [
+ {
+ name: "<return>",
+ path: "actor1-1/<return>",
+ contents: {
+ value: "to sender",
+ },
+ },
+ {
+ name: "<this>",
+ path: "actor1-1/<this>",
+ contents: {
+ value: {},
+ },
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("throwing scope", () => {
+ const why = makeWhyThrow("a party");
+ const scope = makeMockScope("actor1");
+ const selectedFrame = makeMockFrame(undefined, undefined, scope);
+
+ if (!selectedFrame.scope) {
+ throw new Error("Frame must include scopes");
+ }
+
+ const frameScopes = convertScope(selectedFrame.scope);
+ const scopes = getScopes(why, selectedFrame, frameScopes);
+ expect(scopes).toMatchObject([
+ {
+ path: "actor1-1",
+ contents: [
+ {
+ name: "<exception>",
+ path: "actor1-1/<exception>",
+ contents: {
+ value: "a party",
+ },
+ },
+ {
+ name: "<this>",
+ path: "actor1-1/<this>",
+ contents: {
+ value: {},
+ },
+ },
+ ],
+ },
+ ]);
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/pause/scopes/utils.js b/devtools/client/debugger/src/utils/pause/scopes/utils.js
new file mode 100644
index 0000000000..16ebbf04a9
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/scopes/utils.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/>. */
+
+export function getFramePopVariables(why, path) {
+ const vars = [];
+
+ if (why && why.frameFinished) {
+ const { frameFinished } = why;
+
+ // Always display a `throw` property if present, even if it is falsy.
+ if (Object.prototype.hasOwnProperty.call(frameFinished, "throw")) {
+ vars.push({
+ name: "<exception>",
+ path: `${path}/<exception>`,
+ contents: { value: frameFinished.throw },
+ });
+ }
+
+ if (Object.prototype.hasOwnProperty.call(frameFinished, "return")) {
+ const returned = frameFinished.return;
+
+ // Do not display undefined. Do display falsy values like 0 and false. The
+ // protocol grip for undefined is a JSON object: { type: "undefined" }.
+ if (typeof returned !== "object" || returned.type !== "undefined") {
+ vars.push({
+ name: "<return>",
+ path: `${path}/<return>`,
+ contents: { value: returned },
+ });
+ }
+ }
+ }
+
+ return vars;
+}
+
+export function getThisVariable(this_, path) {
+ if (!this_) {
+ return null;
+ }
+
+ return {
+ name: "<this>",
+ path: `${path}/<this>`,
+ contents: { value: this_ },
+ };
+}
+
+// Get a string path for an scope item which can be used in different pauses for
+// a thread.
+export function getScopeItemPath(item) {
+ // Calling toString() on item.path allows symbols to be handled.
+ return item.path.toString();
+}
diff --git a/devtools/client/debugger/src/utils/pause/why.js b/devtools/client/debugger/src/utils/pause/why.js
new file mode 100644
index 0000000000..115d94873b
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/why.js
@@ -0,0 +1,40 @@
+/* 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 { DEBUGGER_PAUSED_REASONS_L10N_MAPPING } from "devtools/shared/constants";
+
+export function getPauseReason(why) {
+ if (!why) {
+ return null;
+ }
+
+ const reasonType = why.type;
+ if (!DEBUGGER_PAUSED_REASONS_L10N_MAPPING[reasonType]) {
+ console.log("Please file an issue: reasonType=", reasonType);
+ }
+
+ return DEBUGGER_PAUSED_REASONS_L10N_MAPPING[reasonType];
+}
+
+export function isException(why) {
+ return why?.type === "exception";
+}
+
+export function isInterrupted(why) {
+ return why?.type === "interrupted";
+}
+
+export function inDebuggerEval(why) {
+ if (
+ why &&
+ why.type === "exception" &&
+ why.exception &&
+ why.exception.preview &&
+ why.exception.preview.fileName
+ ) {
+ return why.exception.preview.fileName === "debugger eval code";
+ }
+
+ return false;
+}
diff --git a/devtools/client/debugger/src/utils/prefs.js b/devtools/client/debugger/src/utils/prefs.js
new file mode 100644
index 0000000000..03d41c1b6b
--- /dev/null
+++ b/devtools/client/debugger/src/utils/prefs.js
@@ -0,0 +1,153 @@
+/* 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/>. */
+
+const { PrefsHelper } = require("devtools/client/shared/prefs");
+
+import { isNode } from "./environment";
+
+// Schema version to bump when the async store format has changed incompatibly
+// and old stores should be cleared.
+const prefsSchemaVersion = 11;
+const { pref } = Services;
+
+if (isNode()) {
+ pref("devtools.debugger.logging", false);
+ pref("devtools.debugger.alphabetize-outline", false);
+ pref("devtools.debugger.auto-pretty-print", false);
+ pref("devtools.source-map.client-service.enabled", true);
+ pref("devtools.chrome.enabled", false);
+ pref("devtools.debugger.pause-on-exceptions", false);
+ pref("devtools.debugger.pause-on-caught-exceptions", false);
+ pref("devtools.debugger.ignore-caught-exceptions", true);
+ pref("devtools.debugger.call-stack-visible", true);
+ pref("devtools.debugger.scopes-visible", true);
+ pref("devtools.debugger.threads-visible", true);
+ pref("devtools.debugger.expressions-visible", false);
+ pref("devtools.debugger.xhr-breakpoints-visible", false);
+ pref("devtools.debugger.breakpoints-visible", true);
+ pref("devtools.debugger.event-listeners-visible", false);
+ pref("devtools.debugger.dom-mutation-breakpoints-visible", false);
+ pref("devtools.debugger.start-panel-collapsed", false);
+ pref("devtools.debugger.end-panel-collapsed", false);
+ pref("devtools.debugger.start-panel-size", 300);
+ pref("devtools.debugger.end-panel-size", 300);
+ pref("devtools.debugger.ui.editor-wrapping", false);
+ pref("devtools.debugger.ui.framework-grouping-on", true);
+ pref("devtools.debugger.pending-selected-location", "{}");
+ pref("devtools.debugger.expressions", "[]");
+ pref("devtools.debugger.search-options", "{}");
+ pref("devtools.debugger.project-directory-root", "");
+ pref("devtools.debugger.map-scopes-enabled", false);
+ pref("devtools.debugger.prefs-schema-version", prefsSchemaVersion);
+ pref("devtools.debugger.skip-pausing", false);
+ pref("devtools.debugger.log-actions", true);
+ pref("devtools.debugger.log-event-breakpoints", false);
+ pref("devtools.debugger.javascript-tracing-log-method", "console");
+ pref("devtools.debugger.hide-ignored-sources", false);
+ pref("devtools.debugger.source-map-ignore-list-enabled", true);
+ pref("devtools.debugger.features.wasm", true);
+ pref("devtools.debugger.features.map-scopes", true);
+ pref("devtools.debugger.features.code-folding", false);
+ pref("devtools.debugger.features.command-click", false);
+ pref("devtools.debugger.features.component-pane", false);
+ pref("devtools.debugger.features.autocomplete-expressions", false);
+ pref("devtools.debugger.features.map-expression-bindings", true);
+ pref("devtools.debugger.features.map-await-expression", true);
+ pref("devtools.debugger.features.log-points", true);
+ pref("devtools.debugger.features.inline-preview", true);
+ pref("devtools.debugger.features.javascript-tracing", false);
+ pref("devtools.editor.tabsize", 2);
+}
+
+export const prefs = new PrefsHelper("devtools", {
+ logging: ["Bool", "debugger.logging"],
+ editorWrapping: ["Bool", "debugger.ui.editor-wrapping"],
+ alphabetizeOutline: ["Bool", "debugger.alphabetize-outline"],
+ autoPrettyPrint: ["Bool", "debugger.auto-pretty-print"],
+ clientSourceMapsEnabled: ["Bool", "source-map.client-service.enabled"],
+ chromeAndExtensionsEnabled: ["Bool", "chrome.enabled"],
+ pauseOnExceptions: ["Bool", "debugger.pause-on-exceptions"],
+ pauseOnCaughtExceptions: ["Bool", "debugger.pause-on-caught-exceptions"],
+ ignoreCaughtExceptions: ["Bool", "debugger.ignore-caught-exceptions"],
+ callStackVisible: ["Bool", "debugger.call-stack-visible"],
+ scopesVisible: ["Bool", "debugger.scopes-visible"],
+ threadsVisible: ["Bool", "debugger.threads-visible"],
+ breakpointsVisible: ["Bool", "debugger.breakpoints-visible"],
+ expressionsVisible: ["Bool", "debugger.expressions-visible"],
+ xhrBreakpointsVisible: ["Bool", "debugger.xhr-breakpoints-visible"],
+ eventListenersVisible: ["Bool", "debugger.event-listeners-visible"],
+ domMutationBreakpointsVisible: [
+ "Bool",
+ "debugger.dom-mutation-breakpoints-visible",
+ ],
+ startPanelCollapsed: ["Bool", "debugger.start-panel-collapsed"],
+ endPanelCollapsed: ["Bool", "debugger.end-panel-collapsed"],
+ startPanelSize: ["Int", "debugger.start-panel-size"],
+ endPanelSize: ["Int", "debugger.end-panel-size"],
+ frameworkGroupingOn: ["Bool", "debugger.ui.framework-grouping-on"],
+ pendingSelectedLocation: ["Json", "debugger.pending-selected-location", {}],
+ expressions: ["Json", "debugger.expressions", []],
+ searchOptions: ["Json", "debugger.search-options"],
+ debuggerPrefsSchemaVersion: ["Int", "debugger.prefs-schema-version"],
+ projectDirectoryRoot: ["Char", "debugger.project-directory-root", ""],
+ projectDirectoryRootName: [
+ "Char",
+ "debugger.project-directory-root-name",
+ "",
+ ],
+ skipPausing: ["Bool", "debugger.skip-pausing"],
+ mapScopes: ["Bool", "debugger.map-scopes-enabled"],
+ logActions: ["Bool", "debugger.log-actions"],
+ logEventBreakpoints: ["Bool", "debugger.log-event-breakpoints"],
+ indentSize: ["Int", "editor.tabsize"],
+ javascriptTracingLogMethod: [
+ "String",
+ "debugger.javascript-tracing-log-method",
+ ],
+ hideIgnoredSources: ["Bool", "debugger.hide-ignored-sources"],
+ sourceMapIgnoreListEnabled: [
+ "Bool",
+ "debugger.source-map-ignore-list-enabled",
+ ],
+});
+
+// The pref may not be defined. Defaulting to null isn't viable (cursor never blinks).
+// Can't use CodeMirror.defaults here because it's loaded later.
+// Hardcode the fallback value to that of CodeMirror.defaults.cursorBlinkRate.
+prefs.cursorBlinkRate = Services.prefs.getIntPref("ui.caretBlinkTime", 530);
+
+export const features = new PrefsHelper("devtools.debugger.features", {
+ wasm: ["Bool", "wasm"],
+ mapScopes: ["Bool", "map-scopes"],
+ outline: ["Bool", "outline"],
+ codeFolding: ["Bool", "code-folding"],
+ autocompleteExpression: ["Bool", "autocomplete-expressions"],
+ mapExpressionBindings: ["Bool", "map-expression-bindings"],
+ mapAwaitExpression: ["Bool", "map-await-expression"],
+ componentPane: ["Bool", "component-pane"],
+ logPoints: ["Bool", "log-points"],
+ commandClick: ["Bool", "command-click"],
+ inlinePreview: ["Bool", "inline-preview"],
+ windowlessServiceWorkers: ["Bool", "windowless-service-workers"],
+ javascriptTracing: ["Bool", "javascript-tracing"],
+});
+
+// Import the asyncStore already spawned by the TargetMixin class
+const ThreadUtils = require("devtools/client/shared/thread-utils");
+export const asyncStore = ThreadUtils.asyncStore;
+
+export function resetSchemaVersion() {
+ prefs.debuggerPrefsSchemaVersion = prefsSchemaVersion;
+}
+
+export function verifyPrefSchema() {
+ if (prefs.debuggerPrefsSchemaVersion < prefsSchemaVersion) {
+ asyncStore.pendingBreakpoints = {};
+ asyncStore.tabs = [];
+ asyncStore.xhrBreakpoints = [];
+ asyncStore.eventListenerBreakpoints = undefined;
+ asyncStore.blackboxedRanges = {};
+ prefs.debuggerPrefsSchemaVersion = prefsSchemaVersion;
+ }
+}
diff --git a/devtools/client/debugger/src/utils/preview.js b/devtools/client/debugger/src/utils/preview.js
new file mode 100644
index 0000000000..48b9d2bc98
--- /dev/null
+++ b/devtools/client/debugger/src/utils/preview.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/>. */
+
+export function isConsole(expression) {
+ return /^console/.test(expression);
+}
diff --git a/devtools/client/debugger/src/utils/quick-open.js b/devtools/client/debugger/src/utils/quick-open.js
new file mode 100644
index 0000000000..f522242891
--- /dev/null
+++ b/devtools/client/debugger/src/utils/quick-open.js
@@ -0,0 +1,123 @@
+/* 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 { endTruncateStr } from "./utils";
+import {
+ getFilename,
+ getSourceClassnames,
+ getSourceQueryString,
+ getRelativeUrl,
+} from "./source";
+
+export const MODIFIERS = {
+ "@": "functions",
+ "#": "variables",
+ ":": "goto",
+ "?": "shortcuts",
+};
+
+export function parseQuickOpenQuery(query) {
+ const startsWithModifier =
+ query[0] === "@" ||
+ query[0] === "#" ||
+ query[0] === ":" ||
+ query[0] === "?";
+
+ if (startsWithModifier) {
+ const modifier = query[0];
+ return MODIFIERS[modifier];
+ }
+
+ const isGotoSource = query.includes(":", 1);
+
+ if (isGotoSource) {
+ return "gotoSource";
+ }
+
+ return "sources";
+}
+
+export function parseLineColumn(query) {
+ const [, line, column] = query.split(":");
+ const lineNumber = parseInt(line, 10);
+ const columnNumber = parseInt(column, 10);
+ if (isNaN(lineNumber)) {
+ return null;
+ }
+
+ return {
+ line: lineNumber,
+ ...(!isNaN(columnNumber) ? { column: columnNumber } : null),
+ };
+}
+
+export function formatSourceForList(
+ source,
+ hasTabOpened,
+ isBlackBoxed,
+ projectDirectoryRoot
+) {
+ const title = getFilename(source);
+ const relativeUrlWithQuery = `${getRelativeUrl(
+ source,
+ projectDirectoryRoot
+ )}${getSourceQueryString(source) || ""}`;
+ const subtitle = endTruncateStr(relativeUrlWithQuery, 100);
+ const value = relativeUrlWithQuery;
+ return {
+ value,
+ title,
+ subtitle,
+ icon: hasTabOpened
+ ? "tab result-item-icon"
+ : `result-item-icon ${getSourceClassnames(source, null, isBlackBoxed)}`,
+ id: source.id,
+ url: source.url,
+ source,
+ };
+}
+
+export function formatSymbol(symbol) {
+ return {
+ id: `${symbol.name}:${symbol.location.start.line}`,
+ title: symbol.name,
+ subtitle: `${symbol.location.start.line}`,
+ value: symbol.name,
+ location: symbol.location,
+ };
+}
+
+export function formatSymbols(symbols, maxResults) {
+ if (!symbols) {
+ return { functions: [] };
+ }
+
+ let { functions } = symbols;
+ // Avoid formating more symbols than necessary
+ functions = functions.slice(0, maxResults);
+
+ return {
+ functions: functions.map(formatSymbol),
+ };
+}
+
+export function formatShortcutResults() {
+ return [
+ {
+ value: L10N.getStr("symbolSearch.search.functionsPlaceholder.title"),
+ title: `@ ${L10N.getStr("symbolSearch.search.functionsPlaceholder")}`,
+ id: "@",
+ },
+ {
+ value: L10N.getStr("symbolSearch.search.variablesPlaceholder.title"),
+ title: `# ${L10N.getStr("symbolSearch.search.variablesPlaceholder")}`,
+ id: "#",
+ },
+ {
+ value: L10N.getStr("gotoLineModal.title"),
+ title: `: ${L10N.getStr("gotoLineModal.placeholder")}`,
+ id: ":",
+ },
+ ];
+}
diff --git a/devtools/client/debugger/src/utils/result-list.js b/devtools/client/debugger/src/utils/result-list.js
new file mode 100644
index 0000000000..c8fe97ade6
--- /dev/null
+++ b/devtools/client/debugger/src/utils/result-list.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/>. */
+
+export function scrollList(resultList, index) {
+ if (!resultList.hasOwnProperty(index)) {
+ return;
+ }
+
+ const resultEl = resultList[index];
+
+ const scroll = () => {
+ // Avoid expensive DOM computations involved in scrollIntoView
+ // https://nolanlawson.com/2018/09/25/accurately-measuring-layout-on-the-web/
+ requestAnimationFrame(() => {
+ setTimeout(() => {
+ if (!resultEl.scrollIntoView) {
+ return;
+ }
+ resultEl.scrollIntoView({ block: "nearest", behavior: "auto" });
+ });
+ });
+ };
+
+ scroll();
+}
diff --git a/devtools/client/debugger/src/utils/selected-location.js b/devtools/client/debugger/src/utils/selected-location.js
new file mode 100644
index 0000000000..c590f6e8ed
--- /dev/null
+++ b/devtools/client/debugger/src/utils/selected-location.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/>. */
+
+import { isOriginalId } from "devtools/client/shared/source-map-loader/index";
+
+export function getSelectedLocation(mappedLocation, context) {
+ if (!context) {
+ return mappedLocation.location;
+ }
+
+ const sourceId = context.sourceId || context.id;
+ return isOriginalId(sourceId)
+ ? mappedLocation.location
+ : mappedLocation.generatedLocation;
+}
diff --git a/devtools/client/debugger/src/utils/shallow-equal.js b/devtools/client/debugger/src/utils/shallow-equal.js
new file mode 100644
index 0000000000..f75430f476
--- /dev/null
+++ b/devtools/client/debugger/src/utils/shallow-equal.js
@@ -0,0 +1,51 @@
+/* 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/>. */
+
+/**
+ * Shallow equal will consider equal:
+ * - exact same values (strict '===' equality)
+ * - distinct array instances having the exact same values in them (same number and strict equality).
+ * - distinct object instances having the exact same attributes and values.
+ *
+ * It will typically consider different array and object whose values
+ * aren't strictly equal. You may consider using "deep equality" checks for this scenario.
+ */
+export function shallowEqual(value, other) {
+ if (value === other) {
+ return true;
+ }
+
+ if (Array.isArray(value) && Array.isArray(other)) {
+ return arrayShallowEqual(value, other);
+ }
+
+ if (isObject(value) && isObject(other)) {
+ return objectShallowEqual(value, other);
+ }
+
+ return false;
+}
+
+export function arrayShallowEqual(value, other) {
+ // Redo this check in case we are called directly from the selectors.
+ if (value === other) {
+ return true;
+ }
+ return value.length === other.length && value.every((k, i) => k === other[i]);
+}
+
+function objectShallowEqual(value, other) {
+ const existingKeys = Object.keys(other);
+ const keys = Object.keys(value);
+
+ return (
+ keys.length === existingKeys.length &&
+ keys.every((k, i) => k === existingKeys[i]) &&
+ keys.every(k => value[k] === other[k])
+ );
+}
+
+function isObject(value) {
+ return typeof value === "object" && !!value;
+}
diff --git a/devtools/client/debugger/src/utils/source-maps.js b/devtools/client/debugger/src/utils/source-maps.js
new file mode 100644
index 0000000000..774bb3997f
--- /dev/null
+++ b/devtools/client/debugger/src/utils/source-maps.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/>. */
+
+import { isOriginalId } from "devtools/client/shared/source-map-loader/index";
+import {
+ debuggerToSourceMapLocation,
+ sourceMapToDebuggerLocation,
+} from "./location";
+import { waitForSourceToBeRegisteredInStore } from "../client/firefox/create";
+
+/**
+ * For any location, return the matching generated location.
+ * If this is already a generated location, returns the same location.
+ *
+ * In additional to `SourceMapLoader.getGeneratedLocation`,
+ * this asserts that the related source is still registered in the reducer current state.
+ *
+ * @param {Object} location
+ * @param {Object} thunkArgs
+ * Redux action thunk arguments
+ * @param {Object}
+ * The matching generated location.
+ */
+export async function getGeneratedLocation(location, thunkArgs) {
+ if (!isOriginalId(location.sourceId)) {
+ return location;
+ }
+
+ const { sourceMapLoader, getState } = thunkArgs;
+ const generatedLocation = await sourceMapLoader.getGeneratedLocation(
+ debuggerToSourceMapLocation(location)
+ );
+ if (!generatedLocation) {
+ return location;
+ }
+
+ return sourceMapToDebuggerLocation(getState(), generatedLocation);
+}
+
+/**
+ * For any location, return the matching original location.
+ * If this is already an original location, returns the same location.
+ *
+ * In additional to `SourceMapLoader.getOriginalLocation`,
+ * this automatically fetches the original source object in order to build
+ * the original location object.
+ *
+ * @param {Object} location
+ * @param {Object} thunkArgs
+ * Redux action thunk arguments
+ * @param {boolean} waitForSource
+ * Default to false. If true is passed, this function will
+ * ensure waiting, possibly asynchronously for the related original source
+ * to be registered in the redux store.
+ *
+ * @param {Object}
+ * The matching original location.
+ */
+export async function getOriginalLocation(
+ location,
+ thunkArgs,
+ waitForSource = false
+) {
+ if (isOriginalId(location.sourceId)) {
+ return location;
+ }
+ const { getState, sourceMapLoader } = thunkArgs;
+ const originalLocation = await sourceMapLoader.getOriginalLocation(
+ debuggerToSourceMapLocation(location)
+ );
+ if (!originalLocation) {
+ return location;
+ }
+
+ // When we are mapping frames while being paused,
+ // the original source may not be registered yet in the reducer.
+ if (waitForSource) {
+ await waitForSourceToBeRegisteredInStore(originalLocation.sourceId);
+ }
+
+ return sourceMapToDebuggerLocation(getState(), originalLocation);
+}
+
+export async function getMappedLocation(location, thunkArgs) {
+ if (!location.source) {
+ throw new Error(`no source ${location.sourceId}`);
+ }
+
+ if (isOriginalId(location.sourceId)) {
+ const generatedLocation = await getGeneratedLocation(location, thunkArgs);
+ return { location, generatedLocation };
+ }
+
+ const generatedLocation = location;
+ const originalLocation = await getOriginalLocation(
+ generatedLocation,
+ thunkArgs
+ );
+
+ return { location: originalLocation, generatedLocation };
+}
+
+/**
+ * Gets the "mapped location".
+ *
+ * If the passed location is on a generated source, it gets the
+ * related location in the original source.
+ * If the passed location is on an original source, it gets the
+ * related location in the generated source.
+ */
+export async function getRelatedMapLocation(location, thunkArgs) {
+ if (!location.source) {
+ return location;
+ }
+
+ if (isOriginalId(location.sourceId)) {
+ return getGeneratedLocation(location, thunkArgs);
+ }
+
+ return getOriginalLocation(location, thunkArgs);
+}
diff --git a/devtools/client/debugger/src/utils/source-queue.js b/devtools/client/debugger/src/utils/source-queue.js
new file mode 100644
index 0000000000..b871dc14e5
--- /dev/null
+++ b/devtools/client/debugger/src/utils/source-queue.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/>. */
+
+const { throttle } = require("devtools/shared/throttle");
+
+// This SourceQueue module is now only used for source mapped sources
+let newOriginalQueuedSources;
+let queuedOriginalSources;
+let currentWork;
+
+async function dispatchNewSources() {
+ const sources = queuedOriginalSources;
+ queuedOriginalSources = [];
+ currentWork = await newOriginalQueuedSources(sources);
+}
+
+const queue = throttle(dispatchNewSources, 100);
+
+export default {
+ initialize: actions => {
+ newOriginalQueuedSources = actions.newOriginalSources;
+ queuedOriginalSources = [];
+ },
+ queueOriginalSources: sources => {
+ if (sources.length) {
+ queuedOriginalSources = queuedOriginalSources.concat(sources);
+ queue();
+ }
+ },
+
+ flush: () => Promise.all([queue.flush(), currentWork]),
+ clear: () => {
+ queuedOriginalSources = [];
+ queue.cancel();
+ },
+};
diff --git a/devtools/client/debugger/src/utils/source.js b/devtools/client/debugger/src/utils/source.js
new file mode 100644
index 0000000000..245caa67a9
--- /dev/null
+++ b/devtools/client/debugger/src/utils/source.js
@@ -0,0 +1,536 @@
+/* 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/>. */
+
+/**
+ * Utils for working with Source URLs
+ * @module utils/source
+ */
+
+const { getUnicodeUrl } = require("devtools/client/shared/unicode-url");
+const {
+ micromatch,
+} = require("devtools/client/shared/vendor/micromatch/micromatch.js");
+
+import { getRelativePath } from "../utils/sources-tree/utils";
+import { endTruncateStr } from "./utils";
+import { truncateMiddleText } from "../utils/text";
+import { parse as parseURL } from "../utils/url";
+import { memoizeLast } from "../utils/memoizeLast";
+import { renderWasmText } from "./wasm";
+import { toEditorLine } from "./editor";
+export { isMinified } from "./isMinified";
+
+import { isFulfilled } from "./async-value";
+
+export const sourceTypes = {
+ coffee: "coffeescript",
+ js: "javascript",
+ jsx: "react",
+ ts: "typescript",
+ tsx: "typescript",
+ vue: "vue",
+};
+
+export const javascriptLikeExtensions = new Set(["marko", "es6", "vue", "jsm"]);
+
+function getPath(source) {
+ const { path } = source.displayURL;
+ let lastIndex = path.lastIndexOf("/");
+ let nextToLastIndex = path.lastIndexOf("/", lastIndex - 1);
+
+ const result = [];
+ do {
+ result.push(path.slice(nextToLastIndex + 1, lastIndex));
+ lastIndex = nextToLastIndex;
+ nextToLastIndex = path.lastIndexOf("/", lastIndex - 1);
+ } while (lastIndex !== nextToLastIndex);
+
+ result.push("");
+
+ return result;
+}
+
+export function shouldBlackbox(source) {
+ if (!source) {
+ return false;
+ }
+
+ if (!source.url) {
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Checks if the frame is within a line ranges which are blackboxed
+ * in the source.
+ *
+ * @param {Object} frame
+ * The current frame
+ * @param {Object} blackboxedRanges
+ * The currently blackboxedRanges for all the sources.
+ * @param {Boolean} isFrameBlackBoxed
+ * If the frame is within the blackboxed range
+ * or not.
+ */
+export function isFrameBlackBoxed(frame, blackboxedRanges) {
+ return (
+ frame.source &&
+ !!blackboxedRanges[frame.source.url] &&
+ (!blackboxedRanges[frame.source.url].length ||
+ !!findBlackBoxRange(frame.source, blackboxedRanges, {
+ start: frame.location.line,
+ end: frame.location.line,
+ }))
+ );
+}
+
+/**
+ * Checks if a blackbox range exist for the line range.
+ * That is if any start and end lines overlap any of the
+ * blackbox ranges
+ *
+ * @param {Object} source
+ * The current selected source
+ * @param {Object} blackboxedRanges
+ * The store of blackboxedRanges
+ * @param {Object} lineRange
+ * The start/end line range `{ start: <Number>, end: <Number> }`
+ * @return {Object} blackboxRange
+ * The first matching blackbox range that all or part of the
+ * specified lineRange sits within.
+ */
+export function findBlackBoxRange(source, blackboxedRanges, lineRange) {
+ const ranges = blackboxedRanges[source.url];
+ if (!ranges || !ranges.length) {
+ return null;
+ }
+
+ return ranges.find(
+ range =>
+ (lineRange.start >= range.start.line &&
+ lineRange.start <= range.end.line) ||
+ (lineRange.end >= range.start.line && lineRange.end <= range.end.line)
+ );
+}
+
+/**
+ * Checks if a source line is blackboxed
+ * @param {Array} ranges - Line ranges that are blackboxed
+ * @param {Number} line
+ * @param {Boolean} isSourceOnIgnoreList - is the line in a source that is on
+ * the sourcemap ignore lists then the line is blackboxed.
+ * @returns boolean
+ */
+export function isLineBlackboxed(ranges, line, isSourceOnIgnoreList) {
+ if (isSourceOnIgnoreList) {
+ return true;
+ }
+
+ if (!ranges) {
+ return false;
+ }
+ // If the whole source is ignored , then the line is
+ // ignored.
+ if (!ranges.length) {
+ return true;
+ }
+ return !!ranges.find(
+ range => line >= range.start.line && line <= range.end.line
+ );
+}
+
+/**
+ * Returns true if the specified url and/or content type are specific to
+ * javascript files.
+ *
+ * @return boolean
+ * True if the source is likely javascript.
+ *
+ * @memberof utils/source
+ * @static
+ */
+export function isJavaScript(source, content) {
+ const extension = source.displayURL.fileExtension;
+ const contentType = content.type === "wasm" ? null : content.contentType;
+ return (
+ javascriptLikeExtensions.has(extension) ||
+ !!(contentType && contentType.includes("javascript"))
+ );
+}
+
+/**
+ * @memberof utils/source
+ * @static
+ */
+export function isPretty(source) {
+ return isPrettyURL(source.url);
+}
+
+export function isPrettyURL(url) {
+ return url ? url.endsWith(":formatted") : false;
+}
+
+/**
+ * @memberof utils/source
+ * @static
+ */
+export function getPrettySourceURL(url) {
+ if (!url) {
+ url = "";
+ }
+ return `${url}:formatted`;
+}
+
+/**
+ * @memberof utils/source
+ * @static
+ */
+export function getRawSourceURL(url) {
+ return url && url.endsWith(":formatted")
+ ? url.slice(0, -":formatted".length)
+ : url;
+}
+
+function resolveFileURL(
+ url,
+ transformUrl = initialUrl => initialUrl,
+ truncate = true
+) {
+ url = getRawSourceURL(url || "");
+ const name = transformUrl(url);
+ if (!truncate) {
+ return name;
+ }
+ return endTruncateStr(name, 50);
+}
+
+export function getFormattedSourceId(id) {
+ return id.substring(id.lastIndexOf("/") + 1);
+}
+
+/**
+ * Gets a readable filename from a source URL for display purposes.
+ * If the source does not have a URL, the source ID will be returned instead.
+ *
+ * @memberof utils/source
+ * @static
+ */
+export function getFilename(
+ source,
+ rawSourceURL = getRawSourceURL(source.url)
+) {
+ const { id } = source;
+ if (!rawSourceURL) {
+ return getFormattedSourceId(id);
+ }
+
+ const { filename } = source.displayURL;
+ return getRawSourceURL(filename);
+}
+
+/**
+ * Provides a middle-trunated filename
+ *
+ * @memberof utils/source
+ * @static
+ */
+export function getTruncatedFileName(source, querystring = "", length = 30) {
+ return truncateMiddleText(`${getFilename(source)}${querystring}`, length);
+}
+
+/* Gets path for files with same filename for editor tabs, breakpoints, etc.
+ * Pass the source, and list of other sources
+ *
+ * @memberof utils/source
+ * @static
+ */
+
+export function getDisplayPath(mySource, sources) {
+ const rawSourceURL = getRawSourceURL(mySource.url);
+ const filename = getFilename(mySource, rawSourceURL);
+
+ // Find sources that have the same filename, but different paths
+ // as the original source
+ const similarSources = sources.filter(source => {
+ const rawSource = getRawSourceURL(source.url);
+ return (
+ rawSourceURL != rawSource && filename == getFilename(source, rawSource)
+ );
+ });
+
+ if (!similarSources.length) {
+ return undefined;
+ }
+
+ // get an array of source path directories e.g. ['a/b/c.html'] => [['b', 'a']]
+ const paths = new Array(similarSources.length + 1);
+
+ paths[0] = getPath(mySource);
+ for (let i = 0; i < similarSources.length; ++i) {
+ paths[i + 1] = getPath(similarSources[i]);
+ }
+
+ // create an array of similar path directories and one dis-similar directory
+ // for example [`a/b/c.html`, `a1/b/c.html`] => ['b', 'a']
+ // where 'b' is the similar directory and 'a' is the dis-similar directory.
+ let displayPath = "";
+ for (let i = 0; i < paths[0].length; i++) {
+ let similar = false;
+ for (let k = 1; k < paths.length; ++k) {
+ if (paths[k][i] === paths[0][i]) {
+ similar = true;
+ break;
+ }
+ }
+
+ displayPath = paths[0][i] + (i !== 0 ? "/" : "") + displayPath;
+
+ if (!similar) {
+ break;
+ }
+ }
+
+ return displayPath;
+}
+
+/**
+ * Gets a readable source URL for display purposes.
+ * If the source does not have a URL, the source ID will be returned instead.
+ *
+ * @memberof utils/source
+ * @static
+ */
+export function getFileURL(source, truncate = true) {
+ const { url, id } = source;
+ if (!url) {
+ return getFormattedSourceId(id);
+ }
+
+ return resolveFileURL(url, getUnicodeUrl, truncate);
+}
+
+export function getSourcePath(url) {
+ if (!url) {
+ return "";
+ }
+
+ const { path, href } = parseURL(url);
+ // for URLs like "about:home" the path is null so we pass the full href
+ return path || href;
+}
+
+/**
+ * Returns amount of lines in the source. If source is a WebAssembly binary,
+ * the function returns amount of bytes.
+ */
+export function getSourceLineCount(content) {
+ if (content.type === "wasm") {
+ const { binary } = content.value;
+ return binary.length;
+ }
+
+ let count = 0;
+
+ for (let i = 0; i < content.value.length; ++i) {
+ if (content.value[i] === "\n") {
+ ++count;
+ }
+ }
+
+ return count + 1;
+}
+
+export function isInlineScript(source) {
+ return source.introductionType === "scriptElement";
+}
+
+function getNthLine(str, lineNum) {
+ let startIndex = -1;
+
+ let newLinesFound = 0;
+ while (newLinesFound < lineNum) {
+ const nextIndex = str.indexOf("\n", startIndex + 1);
+ if (nextIndex === -1) {
+ return null;
+ }
+ startIndex = nextIndex;
+ newLinesFound++;
+ }
+ const endIndex = str.indexOf("\n", startIndex + 1);
+ if (endIndex === -1) {
+ return str.slice(startIndex + 1);
+ }
+
+ return str.slice(startIndex + 1, endIndex);
+}
+
+export const getLineText = memoizeLast((sourceId, asyncContent, line) => {
+ if (!asyncContent || !isFulfilled(asyncContent)) {
+ return "";
+ }
+
+ const content = asyncContent.value;
+
+ if (content.type === "wasm") {
+ const editorLine = toEditorLine(sourceId, line);
+ const lines = renderWasmText(sourceId, content);
+ return lines[editorLine] || "";
+ }
+
+ const lineText = getNthLine(content.value, line - 1);
+ return lineText || "";
+});
+
+export function getTextAtPosition(sourceId, asyncContent, location) {
+ const { column, line = 0 } = location;
+
+ const lineText = getLineText(sourceId, asyncContent, line);
+ return lineText.slice(column, column + 100).trim();
+}
+
+/**
+ * Compute the CSS classname string to use for the icon of a given source.
+ *
+ * @param {Object} source
+ * The reducer source object.
+ * @param {Object} symbols
+ * The reducer symbol object for the given source.
+ * @param {Boolean} isBlackBoxed
+ * To be set to true, when the given source is blackboxed.
+ * @param {Boolean} hasPrettyTab
+ * To be set to true, if the given source isn't the pretty printed one,
+ * but another tab for that source is opened pretty printed.
+ * @return String
+ * The classname to use.
+ */
+export function getSourceClassnames(
+ source,
+ symbols,
+ isBlackBoxed,
+ hasPrettyTab = false
+) {
+ // Conditionals should be ordered by priority of icon!
+ const defaultClassName = "file";
+
+ if (!source || !source.url) {
+ return defaultClassName;
+ }
+
+ // In the SourceTree, we don't show the pretty printed sources,
+ // but still want to show the pretty print icon when a pretty printed tab
+ // for the current source is opened.
+ if (isPretty(source) || hasPrettyTab) {
+ return "prettyPrint";
+ }
+
+ if (isBlackBoxed) {
+ return "blackBox";
+ }
+
+ if (symbols && symbols.framework) {
+ return symbols.framework.toLowerCase();
+ }
+
+ if (isUrlExtension(source.url)) {
+ return "extension";
+ }
+
+ return sourceTypes[source.displayURL.fileExtension] || defaultClassName;
+}
+
+export function getRelativeUrl(source, root) {
+ const { group, path } = source.displayURL;
+ if (!root) {
+ return path;
+ }
+
+ // + 1 removes the leading "/"
+ const url = group + path;
+ return url.slice(url.indexOf(root) + root.length + 1);
+}
+
+/**
+ * source.url doesn't include thread actor ID, so before calling underRoot(), the thread actor ID
+ * must be removed from the root, which this function handles.
+ * @param {string} root The root url to be cleaned
+ * @param {Set<Thread>} threads The list of threads
+ * @returns {string} The root url with thread actor IDs removed
+ */
+export function removeThreadActorId(root, threads) {
+ threads.forEach(thread => {
+ if (root.includes(thread.actor)) {
+ root = root.slice(thread.actor.length + 1);
+ }
+ });
+ return root;
+}
+
+/**
+ * Checks if the source is descendant of the root identified by the
+ * root url specified. The root might likely be projectDirectoryRoot which
+ * is a defined by a pref that allows users restrict the source tree to
+ * a subset of sources.
+ *
+ * @param {Object} source
+ * The source object
+ * @param {String} rootUrlWithoutThreadActor
+ * The url for the root node, without the thread actor ID. This can be obtained
+ * by calling removeThreadActorId()
+ */
+export function isDescendantOfRoot(source, rootUrlWithoutThreadActor) {
+ if (source.url && source.url.includes("chrome://")) {
+ const { group, path } = source.displayURL;
+ return (group + path).includes(rootUrlWithoutThreadActor);
+ }
+
+ return !!source.url && source.url.includes(rootUrlWithoutThreadActor);
+}
+
+export function isGenerated(source) {
+ return !source.isOriginal;
+}
+
+export function getSourceQueryString(source) {
+ if (!source) {
+ return "";
+ }
+
+ return parseURL(getRawSourceURL(source.url)).search;
+}
+
+export function isUrlExtension(url) {
+ return url.includes("moz-extension:") || url.includes("chrome-extension");
+}
+
+/**
+* Checks that source url matches one of the glob patterns
+*
+* @param {Object} source
+* @param {String} excludePatterns
+ String of comma-seperated glob patterns
+* @return {return} Boolean value specifies if the string matches any
+ of the patterns.
+*/
+export function matchesGlobPatterns(source, excludePatterns) {
+ if (!excludePatterns) {
+ return false;
+ }
+ const patterns = excludePatterns
+ .split(",")
+ .map(pattern => pattern.trim())
+ .filter(pattern => pattern !== "");
+
+ if (!patterns.length) {
+ return false;
+ }
+
+ return micromatch.contains(
+ // Makes sure we format the url or id exactly the way its displayed in the search ui,
+ // as user wil usually create glob patterns based on what is seen in the ui.
+ source.url ? getRelativePath(source.url) : getFormattedSourceId(source.id),
+ patterns
+ );
+}
diff --git a/devtools/client/debugger/src/utils/sources-tree/getURL.js b/devtools/client/debugger/src/utils/sources-tree/getURL.js
new file mode 100644
index 0000000000..c06e522723
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/getURL.js
@@ -0,0 +1,180 @@
+/* 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 { parse } from "../url";
+
+const {
+ getUnicodeHostname,
+ getUnicodeUrlPath,
+} = require("devtools/client/shared/unicode-url");
+
+export function getFilenameFromPath(pathname) {
+ let filename = "";
+ if (pathname) {
+ filename = pathname.substring(pathname.lastIndexOf("/") + 1);
+ // This file does not have a name. Default should be (index).
+ if (filename == "") {
+ filename = "(index)";
+ } else if (filename == ":formatted") {
+ filename = "(index:formatted)";
+ }
+ }
+ return filename;
+}
+
+function getFileExtension(path) {
+ if (!path) {
+ return "";
+ }
+
+ const lastIndex = path.lastIndexOf(".");
+ return lastIndex !== -1 ? path.slice(lastIndex + 1).toLowerCase() : "";
+}
+
+const NoDomain = "(no domain)";
+const def = {
+ path: "",
+ search: "",
+ group: "",
+ filename: "",
+ fileExtension: "",
+};
+
+/**
+ * Compute the URL which may be displayed in the Source Tree.
+ *
+ * @param {String} url
+ * The source absolute URL as a string
+ * @param {String} extensionName
+ * Optional, but mandatory when passing a moz-extension URL.
+ * Name of the extension serving this moz-extension source.
+ * @return URL Object
+ * A URL object to represent this source.
+ *
+ * Note that this isn't the standard URL object.
+ * This is augmented with custom properties like:
+ * - `group`, which is mostly the host of the source's URL.
+ * This is used to sort sources in the Source tree.
+ * - `fileExtension`, lowercased file extension of the source
+ * (if any extension is available)
+ * - `path` and `pathname` have some special behavior.
+ * See `parse` implementation.
+ */
+export function getDisplayURL(url, extensionName = null) {
+ if (!url) {
+ return def;
+ }
+
+ const { pathname, search, protocol, host } = parse(url);
+ const filename = getUnicodeUrlPath(getFilenameFromPath(pathname));
+
+ switch (protocol) {
+ case "javascript:":
+ // Ignore `javascript:` URLs for now
+ return def;
+
+ case "moz-extension:":
+ return {
+ ...def,
+ path: pathname,
+ search,
+ filename,
+ fileExtension: getFileExtension(pathname),
+ // For moz-extension, we replace the uuid by the extension name
+ // that we receive from the SourceActor.extensionName attribute.
+ // `extensionName` might be null for content script of disabled add-ons.
+ group: extensionName || `${protocol}//${host}`,
+ };
+ case "resource:":
+ return {
+ ...def,
+ path: pathname,
+ search,
+ filename,
+ fileExtension: getFileExtension(pathname),
+ group: `${protocol}//${host || ""}`,
+ };
+ case "webpack:":
+ return {
+ ...def,
+ path: pathname,
+ search,
+ filename,
+ fileExtension: getFileExtension(pathname),
+ group: `Webpack`,
+ };
+ case "ng:":
+ return {
+ ...def,
+ path: pathname,
+ search,
+ filename,
+ fileExtension: getFileExtension(pathname),
+ group: `Angular`,
+ };
+ case "about:":
+ // An about page is a special case
+ return {
+ ...def,
+ path: "/",
+ search,
+ filename,
+ fileExtension: getFileExtension("/"),
+ group: url,
+ };
+
+ case "data:":
+ return {
+ ...def,
+ path: "/",
+ search,
+ filename: url,
+ fileExtension: getFileExtension("/"),
+ group: NoDomain,
+ };
+
+ case "":
+ if (pathname && pathname.startsWith("/")) {
+ // use file protocol for a URL like "/foo/bar.js"
+ return {
+ ...def,
+ path: pathname,
+ search,
+ filename,
+ fileExtension: getFileExtension(pathname),
+ group: "file://",
+ };
+ } else if (!host) {
+ return {
+ ...def,
+ path: pathname,
+ search,
+ filename,
+ fileExtension: getFileExtension(pathname),
+ group: "",
+ };
+ }
+ break;
+
+ case "http:":
+ case "https:":
+ return {
+ ...def,
+ path: pathname,
+ search,
+ filename,
+ fileExtension: getFileExtension(pathname),
+ group: getUnicodeHostname(host),
+ };
+ }
+
+ return {
+ ...def,
+ path: pathname,
+ search,
+ fileExtension: getFileExtension(pathname),
+ filename,
+ group: protocol ? `${protocol}//` : "",
+ };
+}
diff --git a/devtools/client/debugger/src/utils/sources-tree/moz.build b/devtools/client/debugger/src/utils/sources-tree/moz.build
new file mode 100644
index 0000000000..400c0f0d1a
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/moz.build
@@ -0,0 +1,9 @@
+# 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/.
+
+CompiledModules(
+ "getURL.js",
+ "utils.js",
+)
diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/getUrl.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/getUrl.spec.js
new file mode 100644
index 0000000000..51919ffc4e
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/tests/getUrl.spec.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/>. */
+
+import { getDisplayURL } from "../getURL";
+
+describe("getUrl", () => {
+ it("handles normal url with http and https for filename", function () {
+ const urlObject = getDisplayURL("https://a/b.js");
+ expect(urlObject.filename).toBe("b.js");
+
+ const urlObject2 = getDisplayURL("http://a/b.js");
+ expect(urlObject2.filename).toBe("b.js");
+ });
+
+ it("handles url with querystring for filename", function () {
+ const urlObject = getDisplayURL("https://a/b.js?key=randomKey");
+ expect(urlObject.filename).toBe("b.js");
+ });
+
+ it("handles url with '#' for filename", function () {
+ const urlObject = getDisplayURL("https://a/b.js#specialSection");
+ expect(urlObject.filename).toBe("b.js");
+ });
+
+ it("handles url with no file extension for filename", function () {
+ const urlObject = getDisplayURL("https://a/c");
+ expect(urlObject.filename).toBe("c");
+ });
+
+ it("handles url with no name for filename", function () {
+ const urlObject = getDisplayURL("https://a/");
+ expect(urlObject.filename).toBe("(index)");
+ });
+
+ it("separates resources by protocol and host", () => {
+ const urlObject = getDisplayURL("moz-extension://xyz/123");
+ expect(urlObject.group).toBe("moz-extension://xyz");
+ });
+
+ it("creates a group name for webpack", () => {
+ const urlObject = getDisplayURL("webpack:///src/component.jsx");
+ expect(urlObject.group).toBe("Webpack");
+ });
+
+ it("creates a group name for angular source", () => {
+ const urlObject = getDisplayURL("ng://src/component.jsx");
+ expect(urlObject.group).toBe("Angular");
+ });
+});
diff --git a/devtools/client/debugger/src/utils/sources-tree/utils.js b/devtools/client/debugger/src/utils/sources-tree/utils.js
new file mode 100644
index 0000000000..0a2f41752b
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/utils.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 { parse } from "../../utils/url";
+
+/**
+ * Get the relative path of the url
+ * Does not include any query parameters or fragment parts
+ *
+ * @param string url
+ * @returns string path
+ */
+export function getRelativePath(url) {
+ const { pathname } = parse(url);
+ if (!pathname) {
+ return url;
+ }
+ const index = pathname.indexOf("/");
+ if (index !== -1) {
+ const path = pathname.slice(index + 1);
+ // If the path is empty this is likely the index file.
+ // e.g http://foo.com/
+ if (path == "") {
+ return "(index)";
+ }
+ return path;
+ }
+ return "";
+}
+
+/**
+ *
+ * @param {String} name: Name (e.g. computed in SourcesTreeItem renderItemName),
+ * which might include URI search.
+ * @returns {String} result of `decodedURI(name)`, or name if it `name` is malformed.
+ */
+export function safeDecodeItemName(name) {
+ try {
+ return decodeURI(name);
+ } catch (e) {
+ return name;
+ }
+}
diff --git a/devtools/client/debugger/src/utils/tabs.js b/devtools/client/debugger/src/utils/tabs.js
new file mode 100644
index 0000000000..5f16b6ce63
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tabs.js
@@ -0,0 +1,121 @@
+/* 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/>. */
+
+/*
+ * Finds the hidden tabs by comparing the tabs' top offset.
+ * hidden tabs will have a great top offset.
+ *
+ * @param sourceTabs Array
+ * @param sourceTabEls HTMLCollection
+ *
+ * @returns Array
+ */
+
+export function getHiddenTabs(sourceTabs, sourceTabEls) {
+ sourceTabEls = [].slice.call(sourceTabEls);
+ function getTopOffset() {
+ const topOffsets = sourceTabEls.map(t => t.getBoundingClientRect().top);
+ return Math.min(...topOffsets);
+ }
+
+ function hasTopOffset(el) {
+ // adding 10px helps account for cases where the tab might be offset by
+ // styling such as selected tabs which don't have a border.
+ const tabTopOffset = getTopOffset();
+ return el.getBoundingClientRect().top > tabTopOffset + 10;
+ }
+
+ return sourceTabs.filter((tab, index) => {
+ const element = sourceTabEls[index];
+ return element && hasTopOffset(element);
+ });
+}
+
+export function getTabMenuItems() {
+ return {
+ closeTab: {
+ id: "node-menu-close-tab",
+ label: L10N.getStr("sourceTabs.closeTab"),
+ accesskey: L10N.getStr("sourceTabs.closeTab.accesskey"),
+ disabled: false,
+ },
+ closeOtherTabs: {
+ id: "node-menu-close-other-tabs",
+ label: L10N.getStr("sourceTabs.closeOtherTabs"),
+ accesskey: L10N.getStr("sourceTabs.closeOtherTabs.accesskey"),
+ disabled: false,
+ },
+ closeTabsToEnd: {
+ id: "node-menu-close-tabs-to-end",
+ label: L10N.getStr("sourceTabs.closeTabsToEnd"),
+ accesskey: L10N.getStr("sourceTabs.closeTabsToEnd.accesskey"),
+ disabled: false,
+ },
+ closeAllTabs: {
+ id: "node-menu-close-all-tabs",
+ label: L10N.getStr("sourceTabs.closeAllTabs"),
+ accesskey: L10N.getStr("sourceTabs.closeAllTabs.accesskey"),
+ disabled: false,
+ },
+ showSource: {
+ id: "node-menu-show-source",
+ label: L10N.getStr("sourceTabs.revealInTree"),
+ accesskey: L10N.getStr("sourceTabs.revealInTree.accesskey"),
+ disabled: false,
+ },
+ copySource: {
+ id: "node-menu-copy-source",
+ label: L10N.getStr("copySource.label"),
+ accesskey: L10N.getStr("copySource.accesskey"),
+ disabled: false,
+ },
+ copySourceUri2: {
+ id: "node-menu-copy-source-url",
+ label: L10N.getStr("copySourceUri2"),
+ accesskey: L10N.getStr("copySourceUri2.accesskey"),
+ disabled: false,
+ },
+ toggleBlackBox: {
+ id: "node-menu-blackbox",
+ label: L10N.getStr("ignoreContextItem.ignore"),
+ accesskey: L10N.getStr("ignoreContextItem.ignore.accesskey"),
+ disabled: false,
+ },
+ prettyPrint: {
+ id: "node-menu-pretty-print",
+ label: L10N.getStr("sourceTabs.prettyPrint"),
+ accesskey: L10N.getStr("sourceTabs.prettyPrint.accesskey"),
+ disabled: false,
+ },
+ };
+}
+
+/**
+ * Determines if a tab exists with the following properties
+ *
+ * @param {Object} tab
+ * @param {String} url
+ * @param {Boolean} isOriginal
+ */
+export function isSimilarTab(tab, url, isOriginal) {
+ return tab.url === url && tab.isOriginal === isOriginal;
+}
+
+/**
+ * This cleans up some tab info (source id and thread info),
+ * mostly for persiting to pref and for navigation or reload.
+ * This is neccesary because the source and thread are destroyed
+ * and re-created across navigations / reloads.
+ *
+ * @param {Array} tabs
+ */
+export function persistTabs(tabs) {
+ return [...tabs]
+ .filter(tab => tab.url)
+ .map(tab => ({
+ ...tab,
+ source: null,
+ sourceActor: null,
+ }));
+}
diff --git a/devtools/client/debugger/src/utils/task.js b/devtools/client/debugger/src/utils/task.js
new file mode 100644
index 0000000000..25663fdd16
--- /dev/null
+++ b/devtools/client/debugger/src/utils/task.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/>. */
+
+/**
+ * This object provides the public module functions.
+ */
+export const Task = {
+ // XXX: Not sure if this works in all cases...
+ async: function (task) {
+ return function () {
+ return Task.spawn(task, this, arguments);
+ };
+ },
+
+ /**
+ * Creates and starts a new task.
+ * @param task A generator function
+ * @return A promise, resolved when the task terminates
+ */
+ spawn: function (task, scope, args) {
+ return new Promise(function (resolve, reject) {
+ const iterator = task.apply(scope, args);
+
+ const callNext = lastValue => {
+ const iteration = iterator.next(lastValue);
+ Promise.resolve(iteration.value)
+ .then(value => {
+ if (iteration.done) {
+ resolve(value);
+ } else {
+ callNext(value);
+ }
+ })
+ .catch(error => {
+ reject(error);
+ iterator.throw(error);
+ });
+ };
+
+ callNext(undefined);
+ });
+ },
+};
diff --git a/devtools/client/debugger/src/utils/telemetry.js b/devtools/client/debugger/src/utils/telemetry.js
new file mode 100644
index 0000000000..3fb6fc23c9
--- /dev/null
+++ b/devtools/client/debugger/src/utils/telemetry.js
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Usage:
+ *
+ * import { recordEvent } from "src/utils/telemetry";
+ *
+ * // Event without extra properties
+ * recordEvent("add_breakpoint");
+ *
+ * // Event with extra properties
+ * recordEvent("pause", {
+ * "reason": "debugger-statement",
+ * "collapsed_callstacks": 1
+ * });
+ *
+ * // If the properties are in multiple code paths and you can't send them all
+ * // in one go you will need to use the full telemetry API.
+ *
+ * const Telemetry = require("devtools/client/shared/telemetry");
+ *
+ * const telemetry = new Telemetry();
+ *
+ * // Prepare the event and define which properties to expect.
+ * //
+ * // NOTE: You CAN send properties before preparing the event.
+ * //
+ * telemetry.preparePendingEvent(this, "pause", "debugger", null, [
+ * "reason", "collapsed_callstacks"
+ * ]);
+ *
+ * // Elsewhere in another codepath send the reason property
+ * telemetry.addEventProperty(
+ * this, "pause", "debugger", null, "reason", "debugger-statement"
+ * );
+ *
+ * // Elsewhere in another codepath send the collapsed_callstacks property
+ * telemetry.addEventProperty(
+ * this, "pause", "debugger", null, "collapsed_callstacks", 1
+ * );
+ */
+
+import { isNode } from "./environment";
+
+let telemetry;
+
+if (isNode()) {
+ const Telemetry = require("devtools/client/shared/telemetry");
+ telemetry = new Telemetry();
+}
+
+export function setToolboxTelemetry(toolboxTelemetry) {
+ telemetry = toolboxTelemetry;
+}
+
+/**
+ * @memberof utils/telemetry
+ * @static
+ */
+export function recordEvent(eventName, fields = {}) {
+ telemetry.recordEvent(eventName, "debugger", null, fields);
+
+ if (isNode()) {
+ const { events } = window.dbg._telemetry;
+ if (!events[eventName]) {
+ events[eventName] = [];
+ }
+ events[eventName].push(fields);
+ }
+}
diff --git a/devtools/client/debugger/src/utils/test-head.js b/devtools/client/debugger/src/utils/test-head.js
new file mode 100644
index 0000000000..590726d08e
--- /dev/null
+++ b/devtools/client/debugger/src/utils/test-head.js
@@ -0,0 +1,289 @@
+/* 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/>. */
+
+/**
+ * Utils for Jest
+ * @module utils/test-head
+ */
+
+import { combineReducers } from "redux";
+import reducers from "../reducers";
+import actions from "../actions";
+import * as selectors from "../selectors";
+import {
+ searchWorker,
+ prettyPrintWorker,
+ parserWorker,
+} from "../test/tests-setup";
+import configureStore from "../actions/utils/create-store";
+import sourceQueue from "../utils/source-queue";
+import { setupCreate } from "../client/firefox/create";
+import { createLocation } from "./location";
+
+// Import the internal module used by the source-map worker
+// as node doesn't have Web Worker support and require path mapping
+// doesn't work from nodejs worker thread and break mappings to devtools/ folder.
+import sourceMapLoader from "devtools/client/shared/source-map-loader/source-map";
+
+/**
+ * This file contains older interfaces used by tests that have not been
+ * converted to use test-mockup.js
+ */
+
+/**
+ * @memberof utils/test-head
+ * @static
+ */
+function createStore(client, initialState = {}, sourceMapLoaderMock) {
+ const store = configureStore({
+ log: false,
+ makeThunkArgs: args => {
+ return {
+ ...args,
+ client,
+ sourceMapLoader:
+ sourceMapLoaderMock !== undefined
+ ? sourceMapLoaderMock
+ : sourceMapLoader,
+ parserWorker,
+ prettyPrintWorker,
+ searchWorker,
+ };
+ },
+ })(combineReducers(reducers), initialState);
+ sourceQueue.clear();
+ sourceQueue.initialize({
+ newOriginalSources: sources =>
+ store.dispatch(actions.newOriginalSources(sources)),
+ });
+
+ store.thunkArgs = () => ({
+ dispatch: store.dispatch,
+ getState: store.getState,
+ client,
+ sourceMapLoader,
+ panel: {},
+ });
+
+ // Put the initial context in the store, for convenience to unit tests.
+ store.cx = selectors.getThreadContext(store.getState());
+
+ setupCreate({ store });
+
+ return store;
+}
+
+/**
+ * @memberof utils/test-head
+ * @static
+ */
+function commonLog(msg, data = {}) {
+ console.log(`[INFO] ${msg} ${JSON.stringify(data)}`);
+}
+
+function makeFrame({ id, sourceId, thread }, opts = {}) {
+ const source = createSourceObject(sourceId);
+ const sourceActor = {
+ id: `${sourceId}-actor`,
+ actor: `${sourceId}-actor`,
+ source: sourceId,
+ sourceObject: source,
+ };
+ return {
+ id,
+ scope: { bindings: { variables: {}, arguments: [] } },
+ location: createLocation({ source, sourceActor, line: 4 }),
+ thread: thread || "FakeThread",
+ ...opts,
+ };
+}
+
+function createSourceObject(filename, props = {}) {
+ return {
+ id: filename,
+ url: makeSourceURL(filename),
+ isPrettyPrinted: false,
+ isExtension: false,
+ isOriginal: filename.includes("originalSource"),
+ };
+}
+
+function createOriginalSourceObject(generated) {
+ const rv = {
+ ...generated,
+ id: `${generated.id}/originalSource`,
+ };
+
+ return rv;
+}
+
+function makeSourceURL(filename) {
+ return `http://localhost:8000/examples/${filename}`;
+}
+
+function createMakeSource() {
+ const indicies = {};
+
+ return function (name, props = {}) {
+ const index = (indicies[name] | 0) + 1;
+ indicies[name] = index;
+
+ // Mock a SOURCE Resource, which happens to be the SourceActor's form
+ // with resourceType and targetFront additional attributes
+ return {
+ resourceType: "source",
+ // Mock the targetFront to support makeSourceId function
+ targetFront: {
+ isDestroyed() {
+ return false;
+ },
+ getCachedFront(typeName) {
+ return typeName == "thread" ? { actorID: "FakeThread" } : null;
+ },
+ },
+ // Allow to use custom ID's for reducer source objects
+ mockedJestID: name,
+ actor: `${name}-${index}-actor`,
+ url: `http://localhost:8000/examples/${name}`,
+ sourceMapBaseURL: props.sourceMapBaseURL || null,
+ sourceMapURL: props.sourceMapURL || null,
+ introductionType: props.introductionType || null,
+ extensionName: null,
+ };
+ };
+}
+
+/**
+ * @memberof utils/test-head
+ * @static
+ */
+let creator;
+beforeEach(() => {
+ creator = createMakeSource();
+});
+afterEach(() => {
+ creator = null;
+});
+function makeSource(name, props) {
+ if (!creator) {
+ throw new Error("makeSource() cannot be called outside of a test");
+ }
+
+ return creator(name, props);
+}
+
+function makeOriginalSource(source) {
+ return {
+ id: `${source.id}/originalSource`,
+ url: `${source.url}-original`,
+ sourceActor: {
+ thread: "FakeThread",
+ },
+ };
+}
+
+function makeFuncLocation(startLine, endLine) {
+ if (!endLine) {
+ endLine = startLine + 1;
+ }
+ return {
+ start: {
+ line: startLine,
+ },
+ end: {
+ line: endLine,
+ },
+ };
+}
+
+function makeSymbolDeclaration(name, start, end, klass) {
+ return {
+ id: `${name}:${start}`,
+ name,
+ location: makeFuncLocation(start, end),
+ klass,
+ };
+}
+
+/**
+ * @memberof utils/test-head
+ * @static
+ */
+function waitForState(store, predicate) {
+ return new Promise(resolve => {
+ let ret = predicate(store.getState());
+ if (ret) {
+ resolve(ret);
+ }
+
+ const unsubscribe = store.subscribe(() => {
+ ret = predicate(store.getState());
+ if (ret) {
+ unsubscribe();
+ // NOTE: memoizableAction adds an additional tick for validating context
+ setTimeout(() => resolve(ret));
+ }
+ });
+ });
+}
+
+function watchForState(store, predicate) {
+ let sawState = false;
+ const checkState = function () {
+ if (!sawState && predicate(store.getState())) {
+ sawState = true;
+ }
+ return sawState;
+ };
+
+ let unsubscribe;
+ if (!checkState()) {
+ unsubscribe = store.subscribe(() => {
+ if (checkState()) {
+ unsubscribe();
+ }
+ });
+ }
+
+ return function read() {
+ if (unsubscribe) {
+ unsubscribe();
+ }
+
+ return sawState;
+ };
+}
+
+function getTelemetryEvents(eventName) {
+ return window.dbg._telemetry.events[eventName] || [];
+}
+
+function waitATick(callback) {
+ return new Promise(resolve => {
+ setTimeout(() => {
+ callback();
+ resolve();
+ });
+ });
+}
+
+export {
+ actions,
+ selectors,
+ reducers,
+ createStore,
+ commonLog,
+ getTelemetryEvents,
+ makeFrame,
+ createSourceObject,
+ createOriginalSourceObject,
+ createMakeSource,
+ makeSourceURL,
+ makeSource,
+ makeOriginalSource,
+ makeSymbolDeclaration,
+ waitForState,
+ watchForState,
+ waitATick,
+};
diff --git a/devtools/client/debugger/src/utils/test-mockup.js b/devtools/client/debugger/src/utils/test-mockup.js
new file mode 100644
index 0000000000..3c3851a3d6
--- /dev/null
+++ b/devtools/client/debugger/src/utils/test-mockup.js
@@ -0,0 +1,270 @@
+/* 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/>. */
+
+/**
+ * This file is for use by unit tests for isolated debugger components that do
+ * not need to interact with the redux store. When these tests need to construct
+ * debugger objects, these interfaces should be used instead of plain object
+ * literals.
+ */
+
+import * as asyncValue from "./async-value";
+
+import { initialState } from "../reducers/index";
+
+import { getDisplayURL } from "./sources-tree/getURL";
+import { createLocation } from "./location";
+
+function makeMockSource(url = "url", id = "source", thread = "FakeThread") {
+ return {
+ id,
+ url,
+ displayURL: getDisplayURL(url),
+ thread,
+ isPrettyPrinted: false,
+ isWasm: false,
+ extensionName: null,
+ isExtension: false,
+ isOriginal: id.includes("originalSource"),
+ };
+}
+
+function makeMockDisplaySource(
+ url = "url",
+ id = "source",
+ thread = "FakeThread"
+) {
+ return makeMockSource(url, id, thread);
+}
+
+function makeMockSourceWithContent(
+ url,
+ id,
+ contentType = "text/javascript",
+ text = ""
+) {
+ const source = makeMockSource(url, id);
+
+ return {
+ ...source,
+ content: text
+ ? asyncValue.fulfilled({
+ type: "text",
+ value: text,
+ contentType,
+ })
+ : null,
+ };
+}
+
+function makeMockSourceAndContent(
+ url,
+ id,
+ contentType = "text/javascript",
+ text = ""
+) {
+ const source = makeMockSource(url, id);
+
+ return {
+ ...source,
+ content: {
+ type: "text",
+ value: text,
+ contentType,
+ },
+ };
+}
+
+function makeFullfilledMockSourceContent(
+ text = "",
+ contentType = "text/javascript"
+) {
+ return asyncValue.fulfilled({
+ type: "text",
+ value: text,
+ contentType,
+ });
+}
+
+function makeMockWasmSource() {
+ return {
+ id: "wasm-source-id",
+ url: "url",
+ displayURL: getDisplayURL("url"),
+ thread: "FakeThread",
+ isPrettyPrinted: false,
+ isWasm: true,
+ extensionName: null,
+ isExtension: false,
+ isOriginal: false,
+ };
+}
+
+function makeMockWasmSourceWithContent(text) {
+ const source = makeMockWasmSource();
+
+ return {
+ ...source,
+ content: asyncValue.fulfilled({
+ type: "wasm",
+ value: text,
+ }),
+ };
+}
+
+function makeMockScope(actor = "scope-actor", type = "block", parent = null) {
+ return {
+ actor,
+ parent,
+ bindings: {
+ arguments: [],
+ variables: {},
+ },
+ object: null,
+ function: null,
+ type,
+ scopeKind: "",
+ };
+}
+
+function mockScopeAddVariable(scope, name) {
+ if (!scope.bindings) {
+ throw new Error("no scope bindings");
+ }
+ scope.bindings.variables[name] = { value: null };
+}
+
+function makeMockBreakpoint(source = makeMockSource(), line = 1, column) {
+ const location = column
+ ? { sourceId: source.id, source: { id: source.id }, line, column }
+ : { sourceId: source.id, source: { id: source.id }, line };
+ return {
+ id: "breakpoint",
+ location,
+ generatedLocation: location,
+ disabled: false,
+ text: "text",
+ originalText: "text",
+ options: {},
+ };
+}
+
+function makeMockFrame(
+ id = "frame",
+ source = makeMockSource("url"),
+ scope = makeMockScope(),
+ line = 4,
+ displayName = `display-${id}`,
+ index = 0
+) {
+ const sourceActor = {
+ id: `${source.id}-actor`,
+ actor: `${source.id}-actor`,
+ source: source.id,
+ sourceObject: source,
+ };
+ const location = createLocation({ source, sourceActor, line });
+ return {
+ id,
+ thread: "FakeThread",
+ displayName,
+ location,
+ generatedLocation: location,
+ source,
+ scope,
+ this: {},
+ index,
+ asyncCause: null,
+ state: "on-stack",
+ type: "call",
+ };
+}
+
+function makeMockFrameWithURL(url) {
+ return makeMockFrame(undefined, makeMockSource(url));
+}
+
+function makeWhyNormal(frameReturnValue = undefined) {
+ if (frameReturnValue) {
+ return { type: "why-normal", frameFinished: { return: frameReturnValue } };
+ }
+ return { type: "why-normal" };
+}
+
+function makeWhyThrow(frameThrowValue) {
+ return { type: "why-throw", frameFinished: { throw: frameThrowValue } };
+}
+
+function makeMockExpression(value) {
+ return {
+ input: "input",
+ value,
+ from: "from",
+ updating: false,
+ };
+}
+
+// Mock contexts for use in tests that do not create a redux store.
+const mockcx = { navigateCounter: 0 };
+const mockthreadcx = {
+ navigateCounter: 0,
+ thread: "FakeThread",
+ pauseCounter: 0,
+ isPaused: false,
+};
+
+function makeMockThread(fields) {
+ return {
+ actor: "test",
+ url: "example.com",
+ type: "worker",
+ name: "test",
+ ...fields,
+ };
+}
+
+function makeMockState(state) {
+ return {
+ ...initialState(),
+ ...state,
+ };
+}
+
+function formatTree(tree, depth = 0, str = "") {
+ const whitespace = new Array(depth * 2).join(" ");
+
+ if (tree.type === "directory") {
+ str += `${whitespace} - ${tree.name} path=${tree.path} \n`;
+ tree.contents.forEach(t => {
+ str = formatTree(t, depth + 1, str);
+ });
+ } else {
+ str += `${whitespace} - ${tree.name} path=${tree.path} source_id=${tree.contents.id} \n`;
+ }
+
+ return str;
+}
+
+export {
+ makeMockDisplaySource,
+ makeMockSource,
+ makeMockSourceWithContent,
+ makeMockSourceAndContent,
+ makeMockWasmSource,
+ makeMockWasmSourceWithContent,
+ makeMockScope,
+ mockScopeAddVariable,
+ makeMockBreakpoint,
+ makeMockFrame,
+ makeMockFrameWithURL,
+ makeWhyNormal,
+ makeWhyThrow,
+ makeMockExpression,
+ mockcx,
+ mockthreadcx,
+ makeMockState,
+ makeMockThread,
+ makeFullfilledMockSourceContent,
+ formatTree,
+};
diff --git a/devtools/client/debugger/src/utils/tests/DevToolsUtils.spec.js b/devtools/client/debugger/src/utils/tests/DevToolsUtils.spec.js
new file mode 100644
index 0000000000..be405d8628
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/DevToolsUtils.spec.js
@@ -0,0 +1,41 @@
+/* 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 { reportException, executeSoon } from "../DevToolsUtils.js";
+
+describe("DevToolsUtils", () => {
+ describe("reportException", () => {
+ beforeEach(() => {
+ global.console = { error: jest.fn() };
+ });
+
+ it("calls console.error", () => {
+ reportException("caller", ["you broke it"]);
+ expect(console.error).toHaveBeenCalled();
+ });
+
+ it("returns a description of caller and exception text", () => {
+ const who = "who",
+ exception = "this is an error",
+ msgTxt = " threw an exception: ";
+
+ reportException(who, [exception]);
+
+ expect(console.error).toHaveBeenCalledWith(`${who}${msgTxt}`, [
+ exception,
+ ]);
+ });
+ });
+
+ describe("executeSoon", () => {
+ it("calls setTimeout with 0 ms", () => {
+ global.setTimeout = jest.fn();
+ const fnc = () => {};
+
+ executeSoon(fnc);
+
+ expect(setTimeout).toHaveBeenCalledWith(fnc, 0);
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/__snapshots__/ast.spec.js.snap b/devtools/client/debugger/src/utils/tests/__snapshots__/ast.spec.js.snap
new file mode 100644
index 0000000000..7fe8b4f716
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/__snapshots__/ast.spec.js.snap
@@ -0,0 +1,53 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`find the best expression for the token should find the expression for the property 1`] = `
+Object {
+ "computed": false,
+ "expression": "obj.b",
+ "location": Object {
+ "end": Position {
+ "column": 17,
+ "line": 6,
+ },
+ "start": Position {
+ "column": 16,
+ "line": 6,
+ },
+ },
+ "name": "b",
+}
+`;
+
+exports[`find the best expression for the token should find the identifier 1`] = `
+Object {
+ "expression": "key",
+ "location": Object {
+ "end": Position {
+ "column": 13,
+ "line": 1,
+ },
+ "start": Position {
+ "column": 10,
+ "line": 1,
+ },
+ },
+ "name": "key",
+}
+`;
+
+exports[`find the best expression for the token should find the identifier for computed member expressions 1`] = `
+Object {
+ "expression": "key",
+ "location": Object {
+ "end": Position {
+ "column": 9,
+ "line": 5,
+ },
+ "start": Position {
+ "column": 6,
+ "line": 5,
+ },
+ },
+ "name": "key",
+}
+`;
diff --git a/devtools/client/debugger/src/utils/tests/__snapshots__/expressions.spec.js.snap b/devtools/client/debugger/src/utils/tests/__snapshots__/expressions.spec.js.snap
new file mode 100644
index 0000000000..a0d6b17bf6
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/__snapshots__/expressions.spec.js.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`expressions wrap expression should wrap an expression 1`] = `
+"try {
+ foo
+} catch (e) {
+ e
+}"
+`;
+
+exports[`expressions wrap expression should wrap expression with a comment 1`] = `
+"try {
+ foo // yo yo
+} catch (e) {
+ e
+}"
+`;
+
+exports[`expressions wrap expression should wrap quotes 1`] = `
+"try {
+ \\"2\\"
+} catch (e) {
+ e
+}"
+`;
diff --git a/devtools/client/debugger/src/utils/tests/__snapshots__/function.spec.js.snap b/devtools/client/debugger/src/utils/tests/__snapshots__/function.spec.js.snap
new file mode 100644
index 0000000000..8d83a48d3b
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/__snapshots__/function.spec.js.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`function findFunctionText finds class function 1`] = `
+"bar() {
+ 2 + 2;
+}"
+`;
+
+exports[`function findFunctionText finds function 1`] = `
+"async function exSlowFoo() {
+ return \\"yay in a bit\\";
+}"
+`;
+
+exports[`function findFunctionText finds function signature 1`] = `
+"async function exSlowFoo() {
+ return \\"yay in a bit\\";
+}"
+`;
+
+exports[`function findFunctionText finds property function 1`] = `
+"function name() {
+ 2 + 2;
+}"
+`;
diff --git a/devtools/client/debugger/src/utils/tests/__snapshots__/indentation.spec.js.snap b/devtools/client/debugger/src/utils/tests/__snapshots__/indentation.spec.js.snap
new file mode 100644
index 0000000000..883d48a7ff
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/__snapshots__/indentation.spec.js.snap
@@ -0,0 +1,27 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`indentation mad indentation 1`] = `
+"try {
+console.log(\\"yo\\")
+} catch (e) {
+console.log(\\"yo\\")
+ }"
+`;
+
+exports[`indentation one function 1`] = `
+"function foo() {
+ console.log(\\"yo\\")
+}"
+`;
+
+exports[`indentation one line 1`] = `"foo"`;
+
+exports[`indentation simple 1`] = `"foo"`;
+
+exports[`indentation try catch 1`] = `
+"try {
+ console.log(\\"yo\\")
+} catch (e) {
+ console.log(\\"yo\\")
+}"
+`;
diff --git a/devtools/client/debugger/src/utils/tests/assert.spec.js b/devtools/client/debugger/src/utils/tests/assert.spec.js
new file mode 100644
index 0000000000..031665c5d3
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/assert.spec.js
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import assert from "../assert.js";
+
+let testAssertMessageHead, testAssertMessage;
+
+describe("assert", () => {
+ beforeEach(() => {
+ testAssertMessageHead = "Assertion failure: ";
+ testAssertMessage = "Test assert.js Message";
+ });
+
+ describe("when condition is truthy", () => {
+ it("does not throw an Error", () => {
+ expect(() => {
+ assert(true, testAssertMessage);
+ }).not.toThrow();
+ });
+ });
+
+ describe("when condition is falsy", () => {
+ it("throws an Error displaying the passed message", () => {
+ expect(() => {
+ assert(false, testAssertMessage);
+ }).toThrow(new Error(testAssertMessageHead + testAssertMessage));
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/ast.spec.js b/devtools/client/debugger/src/utils/tests/ast.spec.js
new file mode 100644
index 0000000000..55e9e429d5
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/ast.spec.js
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { findBestMatchExpression } from "../ast";
+
+import { getSymbols } from "../../workers/parser/getSymbols";
+import { populateSource } from "../../workers/parser/tests/helpers";
+
+describe("find the best expression for the token", () => {
+ const source = populateSource("computed-props");
+ const symbols = getSymbols(source.id);
+
+ it("should find the identifier", () => {
+ const expression = findBestMatchExpression(symbols, {
+ line: 1,
+ column: 13,
+ });
+ expect(expression).toMatchSnapshot();
+ });
+
+ it("should find the expression for the property", () => {
+ const expression = findBestMatchExpression(symbols, {
+ line: 6,
+ column: 16,
+ });
+ expect(expression).toMatchSnapshot();
+ });
+
+ it("should find the identifier for computed member expressions", () => {
+ const expression = findBestMatchExpression(symbols, { line: 5, column: 6 });
+ expect(expression).toMatchSnapshot();
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/build-query.spec.js b/devtools/client/debugger/src/utils/tests/build-query.spec.js
new file mode 100644
index 0000000000..dbd0eba4a4
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/build-query.spec.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 buildQuery from "../build-query";
+
+describe("build-query", () => {
+ it("case-sensitive, whole-word, regex search", () => {
+ const query = buildQuery(
+ "hi.*",
+ {
+ caseSensitive: true,
+ wholeWord: true,
+ regexMatch: true,
+ },
+ {}
+ );
+
+ expect(query.source).toBe("\\bhi.*\\b");
+ expect(query.flags).toBe("");
+ expect(query.ignoreCase).toBe(false);
+ });
+
+ it("case-sensitive, whole-word, regex search, global", () => {
+ const query = buildQuery(
+ "hi.*",
+ {
+ caseSensitive: true,
+ wholeWord: true,
+ regexMatch: true,
+ },
+ { isGlobal: true }
+ );
+
+ expect(query.source).toBe("\\bhi.*\\b");
+ expect(query.flags).toBe("g");
+ expect(query.ignoreCase).toBe(false);
+ });
+
+ it("case-insensitive, non-whole, string search", () => {
+ const query = buildQuery(
+ "hi",
+ {
+ caseSensitive: false,
+ wholeWord: false,
+ regexMatch: false,
+ },
+ {}
+ );
+
+ expect(query.source).toBe("hi");
+ expect(query.flags).toBe("i");
+ expect(query.ignoreCase).toBe(true);
+ });
+
+ it("case-insensitive, non-whole, string search, global", () => {
+ const query = buildQuery(
+ "hi",
+ {
+ caseSensitive: false,
+ wholeWord: false,
+ regexMatch: false,
+ },
+ { isGlobal: true }
+ );
+
+ expect(query.source).toBe("hi");
+ expect(query.flags).toBe("gi");
+ expect(query.ignoreCase).toBe(true);
+ });
+
+ it("case-sensitive string search", () => {
+ const query = buildQuery(
+ "hi",
+ {
+ caseSensitive: true,
+ wholeWord: false,
+ regexMatch: false,
+ },
+ {}
+ );
+
+ expect(query.source).toBe("hi");
+ expect(query.flags).toBe("");
+ expect(query.ignoreCase).toBe(false);
+ });
+
+ it("string search with wholeWord", () => {
+ const query = buildQuery(
+ "hi",
+ {
+ caseSensitive: false,
+ wholeWord: true,
+ regexMatch: false,
+ },
+ {}
+ );
+
+ expect(query.source).toBe("\\bhi\\b");
+ expect(query.flags).toBe("i");
+ expect(query.ignoreCase).toBe(true);
+ });
+
+ it("case-insensitive, regex search", () => {
+ const query = buildQuery(
+ "hi.*",
+ {
+ caseSensitive: false,
+ wholeWord: false,
+ regexMatch: true,
+ },
+ {}
+ );
+
+ expect(query.source).toBe("hi.*");
+ expect(query.flags).toBe("i");
+ expect(query.global).toBe(false);
+ expect(query.ignoreCase).toBe(true);
+ });
+
+ it("string search with wholeWord and case sensitivity", () => {
+ const query = buildQuery(
+ "hi",
+ {
+ caseSensitive: true,
+ wholeWord: true,
+ regexMatch: false,
+ },
+ {}
+ );
+
+ expect(query.source).toBe("\\bhi\\b");
+ expect(query.flags).toBe("");
+ expect(query.global).toBe(false);
+ expect(query.ignoreCase).toBe(false);
+ });
+
+ it("string search with wholeWord and case sensitivity, global", () => {
+ const query = buildQuery(
+ "hi",
+ {
+ caseSensitive: true,
+ wholeWord: true,
+ regexMatch: false,
+ },
+ { isGlobal: true }
+ );
+
+ expect(query.source).toBe("\\bhi\\b");
+ expect(query.flags).toBe("g");
+ expect(query.global).toBe(true);
+ expect(query.ignoreCase).toBe(false);
+ });
+
+ it("string search with regex chars escaped", () => {
+ const query = buildQuery(
+ "hi.*",
+ {
+ caseSensitive: true,
+ wholeWord: true,
+ regexMatch: false,
+ },
+ {}
+ );
+
+ expect(query.source).toBe("\\bhi\\.\\*\\b");
+ expect(query.flags).toBe("");
+ expect(query.global).toBe(false);
+ expect(query.ignoreCase).toBe(false);
+ });
+
+ it("string search with regex chars escaped, global", () => {
+ const query = buildQuery(
+ "hi.*",
+ {
+ caseSensitive: true,
+ wholeWord: true,
+ regexMatch: false,
+ },
+ { isGlobal: true }
+ );
+
+ expect(query.source).toBe("\\bhi\\.\\*\\b");
+ expect(query.flags).toBe("g");
+ expect(query.global).toBe(true);
+ expect(query.ignoreCase).toBe(false);
+ });
+
+ it("ignore spaces w/o spaces", () => {
+ const query = buildQuery(
+ "hi",
+ {
+ caseSensitive: true,
+ wholeWord: false,
+ regexMatch: false,
+ },
+ { ignoreSpaces: true }
+ );
+
+ expect(query.source).toBe("hi");
+ expect(query.flags).toBe("");
+ expect(query.global).toBe(false);
+ expect(query.ignoreCase).toBe(false);
+ });
+
+ it("ignore spaces w/o spaces, global", () => {
+ const query = buildQuery(
+ "hi",
+ {
+ caseSensitive: true,
+ wholeWord: false,
+ regexMatch: false,
+ },
+ { isGlobal: true, ignoreSpaces: true }
+ );
+
+ expect(query.source).toBe("hi");
+ expect(query.flags).toBe("g");
+ expect(query.global).toBe(true);
+ expect(query.ignoreCase).toBe(false);
+ });
+
+ it("ignore spaces w/ spaces", () => {
+ const query = buildQuery(
+ " ",
+ {
+ caseSensitive: true,
+ wholeWord: false,
+ regexMatch: false,
+ },
+ { ignoreSpaces: true }
+ );
+
+ expect(query.source).toBe("(?!\\s*.*)");
+ expect(query.flags).toBe("");
+ expect(query.global).toBe(false);
+ expect(query.ignoreCase).toBe(false);
+ });
+
+ it("ignore spaces w/ spaces, global", () => {
+ const query = buildQuery(
+ " ",
+ {
+ caseSensitive: true,
+ wholeWord: false,
+ regexMatch: false,
+ },
+ { isGlobal: true, ignoreSpaces: true }
+ );
+
+ expect(query.source).toBe("(?!\\s*.*)");
+ expect(query.flags).toBe("g");
+ expect(query.global).toBe(true);
+ expect(query.ignoreCase).toBe(false);
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/clipboard.spec.js b/devtools/client/debugger/src/utils/tests/clipboard.spec.js
new file mode 100644
index 0000000000..dcba730149
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/clipboard.spec.js
@@ -0,0 +1,45 @@
+/* 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 { copyToTheClipboard } from "../clipboard";
+
+let clipboardTestCopyString, expectedCopyEvent;
+const addEventListener = jest.fn();
+const execCommand = jest.fn();
+const removeEventListener = jest.fn();
+
+describe("copyToTheClipboard()", () => {
+ beforeEach(() => {
+ expectedCopyEvent = "copy";
+ clipboardTestCopyString = "content intended for clipboard";
+
+ global.document.addEventListener = addEventListener;
+ global.document.execCommand = execCommand;
+ global.document.removeEventListener = removeEventListener;
+ });
+
+ it("listens for 'copy' event", () => {
+ copyToTheClipboard(clipboardTestCopyString);
+
+ expect(document.addEventListener).toHaveBeenCalledWith(
+ expectedCopyEvent,
+ expect.anything()
+ );
+ });
+
+ it("calls document.execCommand() with 'copy' command", () => {
+ copyToTheClipboard(clipboardTestCopyString);
+
+ expect(execCommand).toHaveBeenCalledWith(expectedCopyEvent, false, null);
+ });
+
+ it("removes event listener for 'copy' event", () => {
+ copyToTheClipboard(clipboardTestCopyString);
+
+ expect(document.removeEventListener).toHaveBeenCalledWith(
+ expectedCopyEvent,
+ expect.anything()
+ );
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/expressions.spec.js b/devtools/client/debugger/src/utils/tests/expressions.spec.js
new file mode 100644
index 0000000000..b7c91c4e07
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/expressions.spec.js
@@ -0,0 +1,67 @@
+/* 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 {
+ wrapExpression,
+ getExpressionResultGripAndFront,
+} from "../expressions";
+import { makeMockExpression } from "../test-mockup";
+
+function createError(type, preview) {
+ return makeMockExpression({
+ result: { getGrip: () => ({ class: type, isError: true, preview }) },
+ });
+}
+
+describe("expressions", () => {
+ describe("wrap expression", () => {
+ it("should wrap an expression", () => {
+ expect(wrapExpression("foo")).toMatchSnapshot();
+ });
+
+ it("should wrap expression with a comment", () => {
+ expect(wrapExpression("foo // yo yo")).toMatchSnapshot();
+ });
+
+ it("should wrap quotes", () => {
+ expect(wrapExpression('"2"')).toMatchSnapshot();
+ });
+ });
+
+ describe("sanitize input", () => {
+ it("sanitizes quotes", () => {
+ expect('foo"').toEqual('foo"');
+ });
+
+ it("sanitizes 2 quotes", () => {
+ expect('"3"').toEqual('"3"');
+ });
+
+ it("evaluates \\u{61} as a", () => {
+ expect("\u{61}").toEqual("a");
+ });
+
+ it("evaluates N\\u{61}N as NaN", () => {
+ expect("N\u{61}N").toEqual("NaN");
+ });
+ });
+
+ describe("getValue", () => {
+ it("Reference Errors should be shown as (unavailable)", () => {
+ const { expressionResultGrip } = getExpressionResultGripAndFront(
+ createError("ReferenceError", { name: "ReferenceError" })
+ );
+ expect(expressionResultGrip).toEqual({
+ unavailable: true,
+ });
+ });
+
+ it("Errors messages should be shown", () => {
+ const { expressionResultGrip } = getExpressionResultGripAndFront(
+ createError("Error", { name: "Foo", message: "YO" })
+ );
+ expect(expressionResultGrip).toEqual("Foo: YO");
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/function.spec.js b/devtools/client/debugger/src/utils/tests/function.spec.js
new file mode 100644
index 0000000000..7376aa1280
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/function.spec.js
@@ -0,0 +1,61 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { findFunctionText } from "../function";
+
+import { getSymbols } from "../../workers/parser/getSymbols";
+import { populateOriginalSource } from "../../workers/parser/tests/helpers";
+
+describe("function", () => {
+ describe("findFunctionText", () => {
+ it("finds function", () => {
+ const source = populateOriginalSource("func");
+ const symbols = getSymbols(source.id);
+ const text = findFunctionText(14, source, source.content, symbols);
+ expect(text).toMatchSnapshot();
+ });
+
+ it("finds function signature", () => {
+ const source = populateOriginalSource("func");
+ const symbols = getSymbols(source.id);
+
+ const text = findFunctionText(13, source, source.content, symbols);
+ expect(text).toMatchSnapshot();
+ });
+
+ it("misses function closing brace", () => {
+ const source = populateOriginalSource("func");
+ const symbols = getSymbols(source.id);
+
+ const text = findFunctionText(15, source, source.content, symbols);
+
+ // TODO: we should try and match the closing bracket.
+ expect(text).toEqual(null);
+ });
+
+ it("finds property function", () => {
+ const source = populateOriginalSource("func");
+ const symbols = getSymbols(source.id);
+
+ const text = findFunctionText(29, source, source.content, symbols);
+ expect(text).toMatchSnapshot();
+ });
+
+ it("finds class function", () => {
+ const source = populateOriginalSource("func");
+ const symbols = getSymbols(source.id);
+
+ const text = findFunctionText(33, source, source.content, symbols);
+ expect(text).toMatchSnapshot();
+ });
+
+ it("cant find function", () => {
+ const source = populateOriginalSource("func");
+ const symbols = getSymbols(source.id);
+
+ const text = findFunctionText(20, source, source.content, symbols);
+ expect(text).toEqual(null);
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/indentation.spec.js b/devtools/client/debugger/src/utils/tests/indentation.spec.js
new file mode 100644
index 0000000000..5ee7419371
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/indentation.spec.js
@@ -0,0 +1,61 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { correctIndentation, getIndentation } from "../indentation";
+
+describe("indentation", () => {
+ it("simple", () => {
+ expect(
+ correctIndentation(`
+ foo
+ `)
+ ).toMatchSnapshot();
+ });
+
+ it("one line", () => {
+ expect(correctIndentation("foo")).toMatchSnapshot();
+ });
+
+ it("one function", () => {
+ const text = `
+ function foo() {
+ console.log("yo")
+ }
+ `;
+
+ expect(correctIndentation(text)).toMatchSnapshot();
+ });
+
+ it("try catch", () => {
+ const text = `
+ try {
+ console.log("yo")
+ } catch (e) {
+ console.log("yo")
+ }
+ `;
+
+ expect(correctIndentation(text)).toMatchSnapshot();
+ });
+
+ it("mad indentation", () => {
+ const text = `
+ try {
+ console.log("yo")
+ } catch (e) {
+ console.log("yo")
+ }
+ `;
+
+ expect(correctIndentation(text)).toMatchSnapshot();
+ });
+});
+
+describe("indentation length", () => {
+ it("leading spaces", () => {
+ const line = " console.log('Hello World');";
+
+ expect(getIndentation(line)).toEqual(16);
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/isMinified.spec.js b/devtools/client/debugger/src/utils/tests/isMinified.spec.js
new file mode 100644
index 0000000000..1c49f96737
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/isMinified.spec.js
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { isMinified } from "../isMinified";
+import { makeMockSourceWithContent } from "../test-mockup";
+
+describe("isMinified", () => {
+ it("no indents", () => {
+ const sourceWithContent = makeMockSourceWithContent(
+ undefined,
+ undefined,
+ undefined,
+ "function base(boo) {\n}"
+ );
+ expect(isMinified(sourceWithContent, sourceWithContent.content)).toBe(true);
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/location.spec.js b/devtools/client/debugger/src/utils/tests/location.spec.js
new file mode 100644
index 0000000000..2be71f3cae
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/location.spec.js
@@ -0,0 +1,31 @@
+/* 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 { sortSelectedLocations } from "../location";
+
+function loc(line, column) {
+ return {
+ location: { sourceId: "foo", line, column },
+ generatedLocation: { sourceId: "foo", line, column },
+ };
+}
+describe("location.spec.js", () => {
+ it("sorts lines", () => {
+ const a = loc(3, 5);
+ const b = loc(1, 10);
+ expect(sortSelectedLocations([a, b], { id: "foo" })).toEqual([b, a]);
+ });
+
+ it("sorts columns", () => {
+ const a = loc(3, 10);
+ const b = loc(3, 5);
+ expect(sortSelectedLocations([a, b], { id: "foo" })).toEqual([b, a]);
+ });
+
+ it("prioritizes undefined columns", () => {
+ const a = loc(3, 10);
+ const b = loc(3, undefined);
+ expect(sortSelectedLocations([a, b], { id: "foo" })).toEqual([b, a]);
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/log.spec.js b/devtools/client/debugger/src/utils/tests/log.spec.js
new file mode 100644
index 0000000000..ea7e4ca4d2
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/log.spec.js
@@ -0,0 +1,35 @@
+/* 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 { prefs } from "../prefs";
+import { log } from "../log.js";
+
+let logArgFirst, logArgSecond, logArgArray;
+
+describe("log()", () => {
+ beforeEach(() => {
+ logArgFirst = "my info";
+ logArgSecond = "my other info";
+ logArgArray = [logArgFirst, logArgSecond];
+ global.console = { log: jest.fn() };
+ });
+
+ afterEach(() => {
+ prefs.logging = false;
+ });
+
+ describe("when logging pref is true", () => {
+ it("prints arguments", () => {
+ prefs.logging = true;
+ log(logArgArray);
+
+ expect(global.console.log).toHaveBeenCalledWith(logArgArray);
+ });
+
+ it("does not print by default", () => {
+ log(logArgArray);
+ expect(global.console.log).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/memoize.spec.js b/devtools/client/debugger/src/utils/tests/memoize.spec.js
new file mode 100644
index 0000000000..fb72958516
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/memoize.spec.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import memoize from "../memoize";
+
+const a = { number: 3 };
+const b = { number: 4 };
+const c = { number: 5 };
+const d = { number: 6 };
+
+function add(...numberObjects) {
+ return numberObjects.reduce((prev, cur) => prev + cur.number, 0);
+}
+
+describe("memozie", () => {
+ it("should work for one arg as key", () => {
+ const mAdd = memoize(add);
+ mAdd(a);
+ expect(mAdd(a)).toEqual(3);
+ mAdd(b);
+ expect(mAdd(b)).toEqual(4);
+ });
+
+ it("should only be called once", () => {
+ const spy = jest.fn(() => 2);
+ const mAdd = memoize(spy);
+
+ mAdd(a);
+ mAdd(a);
+ mAdd(a);
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+
+ it("should work for two args as key", () => {
+ const mAdd = memoize(add);
+ expect(mAdd(a, b)).toEqual(7);
+ expect(mAdd(a, b)).toEqual(7);
+ expect(mAdd(a, c)).toEqual(8);
+ });
+
+ it("should work with many args as key", () => {
+ const mAdd = memoize(add);
+ expect(mAdd(a, b, c)).toEqual(12);
+ expect(mAdd(a, b, d)).toEqual(13);
+ expect(mAdd(a, b, c)).toEqual(12);
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/memoizeLast.spec.js b/devtools/client/debugger/src/utils/tests/memoizeLast.spec.js
new file mode 100644
index 0000000000..a5622510e3
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/memoizeLast.spec.js
@@ -0,0 +1,31 @@
+/* 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 { memoizeLast } from "../memoizeLast";
+
+const a = { number: 3 };
+const b = { number: 4 };
+
+function add(...numberObjects) {
+ return numberObjects.reduce((prev, cur) => prev + cur.number, 0);
+}
+
+describe("memozie", () => {
+ it("should re-calculate when a value changes", () => {
+ const mAdd = memoizeLast(add);
+ mAdd(a);
+ expect(mAdd(a)).toEqual(3);
+ mAdd(b);
+ expect(mAdd(b)).toEqual(4);
+ });
+
+ it("should only run once", () => {
+ const mockAdd = jest.fn(add);
+ const mAdd = memoizeLast(mockAdd);
+ mAdd(a);
+ mAdd(a);
+
+ expect(mockAdd.mock.calls[0]).toEqual([{ number: 3 }]);
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/path.spec.js b/devtools/client/debugger/src/utils/tests/path.spec.js
new file mode 100644
index 0000000000..58bdf046f0
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/path.spec.js
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { basename, dirname, isURL, isAbsolute, join } from "../path";
+
+const fullTestURL = "https://www.example.com/some/endpoint";
+const absoluteTestPath = "/some/absolute/path/to/resource";
+const aTestName = "name";
+
+describe("basename()", () => {
+ it("returns the basename of the path", () => {
+ expect(basename(fullTestURL)).toBe("endpoint");
+ });
+});
+
+describe("dirname()", () => {
+ it("returns the current directory in a path", () => {
+ expect(dirname(fullTestURL)).toBe("https://www.example.com/some");
+ });
+});
+
+describe("isURL()", () => {
+ it("returns true if a string contains characters denoting a scheme", () => {
+ expect(isURL(fullTestURL)).toBe(true);
+ });
+
+ it("returns false if string does not denote a scheme", () => {
+ expect(isURL(absoluteTestPath)).toBe(false);
+ });
+});
+
+describe("isAbsolute()", () => {
+ it("returns true if a string begins with a slash", () => {
+ expect(isAbsolute(absoluteTestPath)).toBe(true);
+ });
+
+ it("returns false if a string does not begin with a slash", () => {
+ expect(isAbsolute(fullTestURL)).toBe(false);
+ });
+});
+
+describe("join()", () => {
+ it("concatenates a base path and a directory name", () => {
+ expect(join(absoluteTestPath, aTestName)).toBe(
+ "/some/absolute/path/to/resource/name"
+ );
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/quick-open.spec.js b/devtools/client/debugger/src/utils/tests/quick-open.spec.js
new file mode 100644
index 0000000000..37d4b99fe6
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/quick-open.spec.js
@@ -0,0 +1,35 @@
+/* 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 cases from "jest-in-case";
+import { parseQuickOpenQuery, parseLineColumn } from "../quick-open";
+
+cases(
+ "parseQuickOpenQuery utility",
+ ({ type, query }) => expect(parseQuickOpenQuery(query)).toEqual(type),
+ [
+ { name: "empty query defaults to sources", type: "sources", query: "" },
+ { name: "sources query", type: "sources", query: "test" },
+ { name: "functions query", type: "functions", query: "@test" },
+ { name: "variables query", type: "variables", query: "#test" },
+ { name: "goto line", type: "goto", query: ":30" },
+ { name: "goto line:column", type: "goto", query: ":30:60" },
+ { name: "goto source line", type: "gotoSource", query: "test:30:60" },
+ { name: "shortcuts", type: "shortcuts", query: "?" },
+ ]
+);
+
+cases(
+ "parseLineColumn utility",
+ ({ query, location }) => expect(parseLineColumn(query)).toEqual(location),
+ [
+ { name: "empty query", query: "", location: null },
+ { name: "just line", query: ":30", location: { line: 30 } },
+ {
+ name: "line and column",
+ query: ":30:90",
+ location: { column: 90, line: 30 },
+ },
+ ]
+);
diff --git a/devtools/client/debugger/src/utils/tests/result-list.spec.js b/devtools/client/debugger/src/utils/tests/result-list.spec.js
new file mode 100644
index 0000000000..a0ce0376a1
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/result-list.spec.js
@@ -0,0 +1,32 @@
+/* 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 { scrollList } from "../result-list.js";
+
+describe("scrollList", () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ /* eslint-disable jest/expect-expect */
+ it("just returns if element not found", () => {
+ const li = document.createElement("li");
+ scrollList([li], 1);
+ });
+ /* eslint-enable jest/expect-expect */
+
+ it("calls scrollIntoView ", () => {
+ const ul = document.createElement("ul");
+ const li = document.createElement("li");
+
+ li.scrollIntoView = jest.fn();
+ ul.appendChild(li);
+
+ scrollList([li], 0);
+
+ jest.runAllTimers();
+
+ expect(li.scrollIntoView).toHaveBeenCalled();
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/source.spec.js b/devtools/client/debugger/src/utils/tests/source.spec.js
new file mode 100644
index 0000000000..484c8ce570
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/source.spec.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/>. */
+
+import {
+ getFilename,
+ getTruncatedFileName,
+ getFileURL,
+ getDisplayPath,
+ getSourceLineCount,
+ isJavaScript,
+ isDescendantOfRoot,
+ removeThreadActorId,
+ isUrlExtension,
+ getLineText,
+} from "../source.js";
+
+import {
+ makeMockSource,
+ makeMockSourceWithContent,
+ makeMockSourceAndContent,
+ makeMockWasmSourceWithContent,
+ makeMockThread,
+ makeFullfilledMockSourceContent,
+} from "../test-mockup";
+import { isFulfilled } from "../async-value.js";
+
+describe("sources", () => {
+ const unicode = "\u6e2c";
+ const encodedUnicode = encodeURIComponent(unicode);
+
+ describe("getFilename", () => {
+ it("should give us a default of (index)", () => {
+ expect(
+ getFilename(makeMockSource("http://localhost.com:7999/increment/"))
+ ).toBe("(index)");
+ });
+ it("should give us the filename", () => {
+ expect(
+ getFilename(
+ makeMockSource("http://localhost.com:7999/increment/hello.html")
+ )
+ ).toBe("hello.html");
+ });
+ it("should give us the readable Unicode filename if encoded", () => {
+ expect(
+ getFilename(
+ makeMockSource(
+ `http://localhost.com:7999/increment/${encodedUnicode}.html`
+ )
+ )
+ ).toBe(`${unicode}.html`);
+ });
+ it("should give us the filename excluding the query strings", () => {
+ expect(
+ getFilename(
+ makeMockSource(
+ "http://localhost.com:7999/increment/hello.html?query_strings"
+ )
+ )
+ ).toBe("hello.html");
+ });
+ it("should give us the proper filename for pretty files", () => {
+ expect(
+ getFilename(
+ makeMockSource(
+ "http://localhost.com:7999/increment/hello.html:formatted"
+ )
+ )
+ ).toBe("hello.html");
+ });
+ });
+
+ describe("getTruncatedFileName", () => {
+ it("should truncate the file name when it is more than 30 chars", () => {
+ expect(
+ getTruncatedFileName(
+ makeMockSource(
+ "really-really-really-really-really-really-long-name.html"
+ ),
+ "",
+ 30
+ )
+ ).toBe("really-really…long-name.html");
+ });
+ it("should first decode the filename and then truncate it", () => {
+ expect(
+ getTruncatedFileName(
+ makeMockSource(`${encodedUnicode.repeat(30)}.html`),
+ "",
+ 30
+ )
+ ).toBe("測測測測測測測測測測測測測…測測測測測測測測測.html");
+ });
+ });
+
+ describe("getDisplayPath", () => {
+ it("should give us the path for files with same name", () => {
+ const sources = [
+ makeMockSource("http://localhost.com:7999/increment/xyz/hello.html"),
+ makeMockSource("http://localhost.com:7999/increment/abc/hello.html"),
+ makeMockSource("http://localhost.com:7999/increment/hello.html"),
+ ];
+ expect(
+ getDisplayPath(
+ makeMockSource("http://localhost.com:7999/increment/abc/hello.html"),
+ sources
+ )
+ ).toBe("abc");
+ });
+
+ it(`should give us the path for files with same name
+ in directories with same name`, () => {
+ const sources = [
+ makeMockSource(
+ "http://localhost.com:7999/increment/xyz/web/hello.html"
+ ),
+ makeMockSource(
+ "http://localhost.com:7999/increment/abc/web/hello.html"
+ ),
+ makeMockSource("http://localhost.com:7999/increment/hello.html"),
+ ];
+ expect(
+ getDisplayPath(
+ makeMockSource(
+ "http://localhost.com:7999/increment/abc/web/hello.html"
+ ),
+ sources
+ )
+ ).toBe("abc/web");
+ });
+
+ it("should give no path for files with unique name", () => {
+ const sources = [
+ makeMockSource("http://localhost.com:7999/increment/xyz.html"),
+ makeMockSource("http://localhost.com:7999/increment/abc.html"),
+ makeMockSource("http://localhost.com:7999/increment/hello.html"),
+ ];
+ expect(
+ getDisplayPath(
+ makeMockSource("http://localhost.com:7999/increment/abc/web.html"),
+ sources
+ )
+ ).toBe(undefined);
+ });
+ it("should not show display path for pretty file", () => {
+ const sources = [
+ makeMockSource("http://localhost.com:7999/increment/abc/web/hell.html"),
+ makeMockSource(
+ "http://localhost.com:7999/increment/abc/web/hello.html"
+ ),
+ makeMockSource(
+ "http://localhost.com:7999/increment/xyz.html:formatted"
+ ),
+ ];
+ expect(
+ getDisplayPath(
+ makeMockSource(
+ "http://localhost.com:7999/increment/abc/web/hello.html:formatted"
+ ),
+ sources
+ )
+ ).toBe(undefined);
+ });
+ it(`should give us the path for files with same name when both
+ are pretty and different path`, () => {
+ const sources = [
+ makeMockSource(
+ "http://localhost.com:7999/increment/xyz/web/hello.html:formatted"
+ ),
+ makeMockSource(
+ "http://localhost.com:7999/increment/abc/web/hello.html:formatted"
+ ),
+ makeMockSource(
+ "http://localhost.com:7999/increment/hello.html:formatted"
+ ),
+ ];
+ expect(
+ getDisplayPath(
+ makeMockSource(
+ "http://localhost.com:7999/increment/abc/web/hello.html:formatted"
+ ),
+ sources
+ )
+ ).toBe("abc/web");
+ });
+ });
+
+ describe("getFileURL", () => {
+ it("should give us the file url", () => {
+ expect(
+ getFileURL(
+ makeMockSource("http://localhost.com:7999/increment/hello.html")
+ )
+ ).toBe("http://localhost.com:7999/increment/hello.html");
+ });
+ it("should truncate the file url when it is more than 50 chars", () => {
+ expect(
+ getFileURL(
+ makeMockSource("http://localhost-long.com:7999/increment/hello.html")
+ )
+ ).toBe("…ttp://localhost-long.com:7999/increment/hello.html");
+ });
+ it("should first decode the file URL and then truncate it", () => {
+ expect(
+ getFileURL(makeMockSource(`http://${encodedUnicode.repeat(39)}.html`))
+ ).toBe(`…ttp://${unicode.repeat(39)}.html`);
+ });
+ });
+
+ describe("isJavaScript", () => {
+ it("is not JavaScript", () => {
+ {
+ const source = makeMockSourceAndContent("foo.html", undefined, "");
+ expect(isJavaScript(source, source.content)).toBe(false);
+ }
+ {
+ const source = makeMockSourceAndContent(
+ undefined,
+ undefined,
+ "text/html"
+ );
+ expect(isJavaScript(source, source.content)).toBe(false);
+ }
+ });
+
+ it("is JavaScript", () => {
+ {
+ const source = makeMockSourceAndContent("foo.js");
+ expect(isJavaScript(source, source.content)).toBe(true);
+ }
+ {
+ const source = makeMockSourceAndContent("bar.jsm");
+ expect(isJavaScript(source, source.content)).toBe(true);
+ }
+ {
+ const source = makeMockSourceAndContent(
+ undefined,
+ undefined,
+ "text/javascript"
+ );
+ expect(isJavaScript(source, source.content)).toBe(true);
+ }
+ {
+ const source = makeMockSourceAndContent(
+ undefined,
+ undefined,
+ "application/javascript"
+ );
+ expect(isJavaScript(source, source.content)).toBe(true);
+ }
+ });
+ });
+
+ describe("getSourceLineCount", () => {
+ it("should give us the amount bytes for wasm source", () => {
+ const { content } = makeMockWasmSourceWithContent({
+ binary: "\x00asm\x01\x00\x00\x00",
+ });
+ expect(getSourceLineCount(content.value)).toEqual(8);
+ });
+
+ it("should give us the amout of lines for js source", () => {
+ const { content } = makeMockSourceWithContent(
+ undefined,
+ undefined,
+ "text/javascript",
+ "function foo(){\n}"
+ );
+ if (!content || !isFulfilled(content)) {
+ throw new Error("Unexpected content value");
+ }
+ expect(getSourceLineCount(content.value)).toEqual(2);
+ });
+ });
+
+ describe("isDescendantOfRoot", () => {
+ const threads = [
+ makeMockThread({ actor: "server0.conn1.child1/thread19" }),
+ ];
+
+ it("should detect normal source urls", () => {
+ const source = makeMockSource(
+ "resource://activity-stream/vendor/react.js"
+ );
+ const rootWithoutThreadActor = removeThreadActorId(
+ "resource://activity-stream",
+ threads
+ );
+ expect(isDescendantOfRoot(source, rootWithoutThreadActor)).toBe(true);
+ });
+
+ it("should detect source urls under chrome:// as root", () => {
+ const source = makeMockSource(
+ "chrome://browser/content/contentSearchUI.js"
+ );
+ const rootWithoutThreadActor = removeThreadActorId("chrome://", threads);
+ expect(isDescendantOfRoot(source, rootWithoutThreadActor)).toBe(true);
+ });
+
+ it("should detect source urls if root is a thread actor Id", () => {
+ const source = makeMockSource(
+ "resource://activity-stream/vendor/react-dom.js"
+ );
+ const rootWithoutThreadActor = removeThreadActorId(
+ "server0.conn1.child1/thread19",
+ threads
+ );
+ expect(isDescendantOfRoot(source, rootWithoutThreadActor)).toBe(true);
+ });
+ });
+
+ describe("isUrlExtension", () => {
+ it("should detect mozilla extension", () => {
+ expect(isUrlExtension("moz-extension://id/js/content.js")).toBe(true);
+ });
+ it("should detect chrome extension", () => {
+ expect(isUrlExtension("chrome-extension://id/js/content.js")).toBe(true);
+ });
+ it("should return false for non-extension assets", () => {
+ expect(isUrlExtension("https://example.org/init.js")).toBe(false);
+ });
+ });
+
+ describe("getLineText", () => {
+ it("first line", () => {
+ const text = getLineText(
+ "fake-source",
+ makeFullfilledMockSourceContent("aaa\nbbb\nccc"),
+ 1
+ );
+
+ expect(text).toEqual("aaa");
+ });
+
+ it("last line", () => {
+ const text = getLineText(
+ "fake-source",
+ makeFullfilledMockSourceContent("aaa\nbbb\nccc"),
+ 3
+ );
+
+ expect(text).toEqual("ccc");
+ });
+
+ it("one line", () => {
+ const text = getLineText(
+ "fake-source",
+ makeFullfilledMockSourceContent("aaa"),
+ 1
+ );
+
+ expect(text).toEqual("aaa");
+ });
+
+ it("bad line", () => {
+ const text = getLineText(
+ "fake-source",
+ makeFullfilledMockSourceContent("aaa\nbbb\nccc"),
+
+ 5
+ );
+
+ expect(text).toEqual("");
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/telemetry.spec.js b/devtools/client/debugger/src/utils/tests/telemetry.spec.js
new file mode 100644
index 0000000000..7223641afd
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/telemetry.spec.js
@@ -0,0 +1,13 @@
+/* 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 { recordEvent } from "../telemetry";
+
+describe("telemetry.recordEvent()", () => {
+ it("Receives the correct telemetry information", () => {
+ recordEvent("foo", { bar: 1 });
+
+ expect(window.dbg._telemetry.events.foo).toStrictEqual([{ bar: 1 }]);
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/text.spec.js b/devtools/client/debugger/src/utils/tests/text.spec.js
new file mode 100644
index 0000000000..5786bc6232
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/text.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/>. */
+
+import { truncateMiddleText } from "../text";
+
+describe("text", () => {
+ it("should truncate the text in the middle", () => {
+ const sourceText = "this is a very long text and ends here";
+ expect(truncateMiddleText(sourceText, 30)).toMatch(
+ "this is a ver… and ends here"
+ );
+ });
+ it("should keep the text as it is", () => {
+ const sourceText = "this is a short text ends here";
+ expect(truncateMiddleText(sourceText, 30)).toMatch(
+ "this is a short text ends here"
+ );
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/ui.spec.js b/devtools/client/debugger/src/utils/tests/ui.spec.js
new file mode 100644
index 0000000000..aa091e6798
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/ui.spec.js
@@ -0,0 +1,15 @@
+/* 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 { isVisible } from "../ui";
+
+describe("ui", () => {
+ it("should return #mount width", () => {
+ if (!document.body) {
+ throw new Error("no document body");
+ }
+ document.body.innerHTML = "<div id='mount'></div>";
+ expect(isVisible()).toBe(false);
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/url.spec.js b/devtools/client/debugger/src/utils/tests/url.spec.js
new file mode 100644
index 0000000000..d842acf7db
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/url.spec.js
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { parse } from "../url";
+
+describe("url", () => {
+ describe("parse", () => {
+ it("parses an absolute URL", () => {
+ const val = parse("http://example.com:8080/path/file.js");
+
+ expect(val.protocol).toBe("http:");
+ expect(val.host).toBe("example.com:8080");
+ expect(val.pathname).toBe("/path/file.js");
+ expect(val.search).toBe("");
+ expect(val.hash).toBe("");
+ });
+
+ it("parses an absolute URL with query params", () => {
+ const val = parse("http://example.com:8080/path/file.js?param");
+
+ expect(val.protocol).toBe("http:");
+ expect(val.host).toBe("example.com:8080");
+ expect(val.pathname).toBe("/path/file.js");
+ expect(val.search).toBe("?param");
+ expect(val.hash).toBe("");
+ });
+
+ it("parses an absolute URL with a fragment", () => {
+ const val = parse("http://example.com:8080/path/file.js#hash");
+
+ expect(val.protocol).toBe("http:");
+ expect(val.host).toBe("example.com:8080");
+ expect(val.pathname).toBe("/path/file.js");
+ expect(val.search).toBe("");
+ expect(val.hash).toBe("#hash");
+ });
+
+ it("parses an absolute URL with query params and a fragment", () => {
+ const val = parse("http://example.com:8080/path/file.js?param#hash");
+
+ expect(val.protocol).toBe("http:");
+ expect(val.host).toBe("example.com:8080");
+ expect(val.pathname).toBe("/path/file.js");
+ expect(val.search).toBe("?param");
+ expect(val.hash).toBe("#hash");
+ });
+
+ it("parses a partial URL", () => {
+ const val = parse("/path/file.js");
+
+ expect(val.protocol).toBe("");
+ expect(val.host).toBe("");
+ expect(val.pathname).toBe("/path/file.js");
+ expect(val.search).toBe("");
+ expect(val.hash).toBe("");
+ });
+
+ it("parses a partial URL with query params", () => {
+ const val = parse("/path/file.js?param");
+
+ expect(val.protocol).toBe("");
+ expect(val.host).toBe("");
+ expect(val.pathname).toBe("/path/file.js");
+ expect(val.search).toBe("?param");
+ expect(val.hash).toBe("");
+ });
+
+ it("parses a partial URL with a fragment", () => {
+ const val = parse("/path/file.js#hash");
+
+ expect(val.protocol).toBe("");
+ expect(val.host).toBe("");
+ expect(val.pathname).toBe("/path/file.js");
+ expect(val.search).toBe("");
+ expect(val.hash).toBe("#hash");
+ });
+
+ it("parses a partial URL with query params and a fragment", () => {
+ const val = parse("/path/file.js?param#hash");
+
+ expect(val.protocol).toBe("");
+ expect(val.host).toBe("");
+ expect(val.pathname).toBe("/path/file.js");
+ expect(val.search).toBe("?param");
+ expect(val.hash).toBe("#hash");
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/utils.spec.js b/devtools/client/debugger/src/utils/tests/utils.spec.js
new file mode 100644
index 0000000000..f358ffc42a
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/utils.spec.js
@@ -0,0 +1,87 @@
+/* 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 { handleError, promisify, endTruncateStr, waitForMs } from "../utils";
+
+describe("handleError()", () => {
+ const testErrorText = "ERROR: ";
+ const testErrorObject = { oh: "noes" };
+
+ beforeEach(() => {
+ global.console = { log: jest.fn() };
+ });
+
+ it("logs error text with error value", () => {
+ handleError(testErrorObject);
+
+ expect(console.log).toHaveBeenCalledWith(testErrorText, testErrorObject);
+ });
+});
+
+describe("promisify()", () => {
+ let testPromise, testContext, testMethod, testArgs;
+
+ beforeEach(() => {
+ testContext = {};
+ testMethod = jest.fn();
+ testArgs = [];
+ });
+
+ it("returns a Promise", () => {
+ testPromise = promisify(testContext, testMethod, testArgs);
+
+ expect(testPromise instanceof Promise).toBe(true);
+ });
+
+ it("applies promisified method", () => {
+ testPromise = promisify(testContext, testMethod, testArgs);
+
+ expect(testMethod).toHaveBeenCalledWith(testArgs, expect.anything());
+ });
+});
+
+describe("endTruncateStr()", () => {
+ let testString;
+ const testSize = 11;
+
+ describe("when the string is larger than the specified size", () => {
+ it("returns an elipsis and characters at the end of the string", () => {
+ testString = "Mozilla Firefox is my favorite web browser";
+
+ expect(endTruncateStr(testString, testSize)).toBe("…web browser");
+ });
+ });
+
+ describe("when the string is not larger than the specified size", () => {
+ it("returns the string unchanged", () => {
+ testString = "Firefox";
+
+ expect(endTruncateStr(testString, testSize)).toBe(testString);
+ });
+ });
+});
+
+describe("waitForMs()", () => {
+ let testPromise;
+ const testMilliseconds = 10;
+
+ beforeEach(() => {
+ global.setTimeout = jest.fn();
+ });
+
+ it("returns a Promise", () => {
+ testPromise = waitForMs(testMilliseconds);
+
+ expect(testPromise instanceof Promise).toBe(true);
+ });
+
+ it("calls setTimeout() on the resolve of the Promise", () => {
+ testPromise = waitForMs(testMilliseconds);
+
+ expect(setTimeout).toHaveBeenCalledWith(
+ expect.anything(),
+ testMilliseconds
+ );
+ });
+});
diff --git a/devtools/client/debugger/src/utils/tests/wasm.spec.js b/devtools/client/debugger/src/utils/tests/wasm.spec.js
new file mode 100644
index 0000000000..e803393c12
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/wasm.spec.js
@@ -0,0 +1,96 @@
+/* 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 {
+ isWasm,
+ lineToWasmOffset,
+ wasmOffsetToLine,
+ clearWasmStates,
+ renderWasmText,
+} from "../wasm.js";
+
+import { makeMockWasmSourceWithContent } from "../test-mockup";
+
+describe("wasm", () => {
+ // Compiled version of `(module (func (nop)))`
+ const SIMPLE_WASM = {
+ binary:
+ "\x00asm\x01\x00\x00\x00\x01\x84\x80\x80\x80\x00\x01`\x00\x00" +
+ "\x03\x82\x80\x80\x80\x00\x01\x00\x06\x81\x80\x80\x80\x00\x00" +
+ "\n\x89\x80\x80\x80\x00\x01\x83\x80\x80\x80\x00\x00\x01\v",
+ };
+
+ // malformed binary which contains an unknown operator (\x09) which
+ // should cause the wasm parser to throw.
+ const MALFORMED_SIMPLE_WASM = {
+ binary:
+ "\x00asm\x01\x00\x00\x00\x09\x84\x80\x80\x80\x00\x01`\x00\x00" +
+ "\x03\x82\x80\x80\x80\x00\x01\x00\x06\x81\x80\x80\x80\x00\x00" +
+ "\n\x89\x80\x80\x80\x00\x01\x83\x80\x80\x80\x00\x00\x01\v",
+ };
+
+ const SIMPLE_WASM_TEXT = `(module
+ (func $func0
+ nop
+ )
+)`;
+ const SIMPLE_WASM_NOP_TEXT_LINE = 2;
+ const SIMPLE_WASM_NOP_OFFSET = 46;
+
+ describe("isWasm", () => {
+ it("should give us the false when wasm text was not registered", () => {
+ const sourceId = "source.0";
+ expect(isWasm(sourceId)).toEqual(false);
+ });
+ it("should give us the true when wasm text was registered", () => {
+ const source = makeMockWasmSourceWithContent(SIMPLE_WASM);
+ renderWasmText(source.id, source.content.value);
+ expect(isWasm(source.id)).toEqual(true);
+ // clear shall remove
+ clearWasmStates();
+ expect(isWasm(source.id)).toEqual(false);
+ });
+ });
+
+ describe("renderWasmText", () => {
+ it("render simple wasm", () => {
+ const source = makeMockWasmSourceWithContent(SIMPLE_WASM);
+ const lines = renderWasmText(source.id, source.content.value);
+ expect(lines.join("\n")).toEqual(SIMPLE_WASM_TEXT);
+ clearWasmStates();
+ });
+
+ it("should return error information when the parser throws", () => {
+ const source = makeMockWasmSourceWithContent(MALFORMED_SIMPLE_WASM);
+ const lines = renderWasmText(source.id, source.content.value);
+ expect(lines.join("\n")).toEqual(
+ "Error occured during wast conversion : Unknown operator: 6"
+ );
+ clearWasmStates();
+ });
+ });
+
+ describe("lineToWasmOffset", () => {
+ // Test data sanity check: checking if 'nop' is found in the SIMPLE_WASM.
+ expect(SIMPLE_WASM.binary[SIMPLE_WASM_NOP_OFFSET]).toEqual("\x01");
+
+ it("get simple wasm nop offset", () => {
+ const source = makeMockWasmSourceWithContent(SIMPLE_WASM);
+ renderWasmText(source.id, source.content.value);
+ const offset = lineToWasmOffset(source.id, SIMPLE_WASM_NOP_TEXT_LINE);
+ expect(offset).toEqual(SIMPLE_WASM_NOP_OFFSET);
+ clearWasmStates();
+ });
+ });
+
+ describe("wasmOffsetToLine", () => {
+ it("get simple wasm nop line", () => {
+ const source = makeMockWasmSourceWithContent(SIMPLE_WASM);
+ renderWasmText(source.id, source.content.value);
+ const line = wasmOffsetToLine(source.id, SIMPLE_WASM_NOP_OFFSET);
+ expect(line).toEqual(SIMPLE_WASM_NOP_TEXT_LINE);
+ clearWasmStates();
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/text.js b/devtools/client/debugger/src/utils/text.js
new file mode 100644
index 0000000000..19f99b3175
--- /dev/null
+++ b/devtools/client/debugger/src/utils/text.js
@@ -0,0 +1,58 @@
+/* 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/>. */
+
+/**
+ * Utils for keyboard command strings
+ * @module utils/text
+ */
+
+const isMacOS = Services.appinfo.OS === "Darwin";
+
+/**
+ * Formats key for use in tooltips
+ * For macOS we use the following unicode
+ *
+ * cmd ⌘ = \u2318
+ * shift ⇧ – \u21E7
+ * option (alt) ⌥ \u2325
+ *
+ * For Win/Lin this replaces CommandOrControl or CmdOrCtrl with Ctrl
+ *
+ * @memberof utils/text
+ * @static
+ */
+export function formatKeyShortcut(shortcut) {
+ if (isMacOS) {
+ return shortcut
+ .replace(/Shift\+/g, "\u21E7")
+ .replace(/Command\+|Cmd\+/g, "\u2318")
+ .replace(/CommandOrControl\+|CmdOrCtrl\+/g, "\u2318")
+ .replace(/Alt\+/g, "\u2325");
+ }
+ return shortcut
+ .replace(/CommandOrControl\+|CmdOrCtrl\+/g, `${L10N.getStr("ctrl")}+`)
+ .replace(/Shift\+/g, "Shift+");
+}
+
+/**
+ * Truncates the received text to the maxLength in the format:
+ * Original: 'this is a very long text and ends here'
+ * Truncated: 'this is a ver...and ends here'
+ * @param {String} sourceText - Source text
+ * @param {Number} maxLength - Max allowed length
+ * @memberof utils/text
+ * @static
+ */
+export function truncateMiddleText(sourceText, maxLength) {
+ let truncatedText = sourceText;
+ if (sourceText.length > maxLength) {
+ truncatedText = `${sourceText.substring(
+ 0,
+ Math.round(maxLength / 2) - 2
+ )}…${sourceText.substring(
+ sourceText.length - Math.round(maxLength / 2 - 1)
+ )}`;
+ }
+ return truncatedText;
+}
diff --git a/devtools/client/debugger/src/utils/ui.js b/devtools/client/debugger/src/utils/ui.js
new file mode 100644
index 0000000000..eab5bb1e07
--- /dev/null
+++ b/devtools/client/debugger/src/utils/ui.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/>. */
+
+/* Checks to see if the root element is available and
+ * if the element is visible. We check the width of the element
+ * because it is more reliable than either checking a focus state or
+ * the visibleState or hidden property.
+ */
+export function isVisible() {
+ const el = document.querySelector("#mount");
+ return !!(el && el.getBoundingClientRect().width > 0);
+}
+
+/* Gets the line numbers width in the code editor
+ */
+export function getLineNumberWidth(editor) {
+ const { gutters } = editor.display;
+ const lineNumbers = gutters.querySelector(".CodeMirror-linenumbers");
+ return lineNumbers?.clientWidth;
+}
+
+/**
+ * Forces the breakpoint gutter to be the same size as the line
+ * numbers gutter. Editor CSS will absolutely position the gutter
+ * beneath the line numbers. This makes it easy to be flexible with
+ * how we overlay breakpoints.
+ */
+export function resizeBreakpointGutter(editor) {
+ const { gutters } = editor.display;
+ const breakpoints = gutters.querySelector(".breakpoints");
+ if (breakpoints) {
+ breakpoints.style.width = `${getLineNumberWidth(editor)}px`;
+ }
+}
+
+/**
+ * Forces the left toggle button in source header to be the same size
+ * as the line numbers gutter.
+ */
+export function resizeToggleButton(editor) {
+ const toggleButton = document.querySelector(
+ ".source-header .toggle-button-start"
+ );
+ if (toggleButton) {
+ toggleButton.style.width = `${getLineNumberWidth(editor)}px`;
+ }
+}
diff --git a/devtools/client/debugger/src/utils/url.js b/devtools/client/debugger/src/utils/url.js
new file mode 100644
index 0000000000..2aabc31258
--- /dev/null
+++ b/devtools/client/debugger/src/utils/url.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/>. */
+
+const defaultUrl = {
+ hash: "",
+ host: "",
+ hostname: "",
+ href: "",
+ origin: "null",
+ password: "",
+ path: "",
+ pathname: "",
+ port: "",
+ protocol: "",
+ search: "",
+ // This should be a "URLSearchParams" object
+ searchParams: {},
+ username: "",
+};
+
+const parseCache = new Map();
+export function parse(url) {
+ if (parseCache.has(url)) {
+ return parseCache.get(url);
+ }
+
+ let urlObj;
+ try {
+ urlObj = new URL(url);
+ } catch (err) {
+ urlObj = { ...defaultUrl };
+ // If we're given simply a filename...
+ if (url) {
+ const hashStart = url.indexOf("#");
+ if (hashStart >= 0) {
+ urlObj.hash = url.slice(hashStart);
+ url = url.slice(0, hashStart);
+
+ if (urlObj.hash === "#") {
+ // The standard URL parser does not include the ? unless there are
+ // parameters included in the search value.
+ urlObj.hash = "";
+ }
+ }
+
+ const queryStart = url.indexOf("?");
+ if (queryStart >= 0) {
+ urlObj.search = url.slice(queryStart);
+ url = url.slice(0, queryStart);
+
+ if (urlObj.search === "?") {
+ // The standard URL parser does not include the ? unless there are
+ // parameters included in the search value.
+ urlObj.search = "";
+ }
+ }
+
+ urlObj.pathname = url;
+ }
+ }
+ // When provided a special URL like "webpack:///webpack/foo",
+ // prevents passing the three slashes in the path, and pass only onea.
+ // This will prevent displaying modules in empty-name sub folders.
+ urlObj.pathname = urlObj.pathname.replace(/\/+/, "/");
+ urlObj.path = urlObj.pathname + urlObj.search;
+
+ // Cache the result
+ parseCache.set(url, urlObj);
+ return urlObj;
+}
+
+export function sameOrigin(firstUrl, secondUrl) {
+ return parse(firstUrl).origin == parse(secondUrl).origin;
+}
diff --git a/devtools/client/debugger/src/utils/utils.js b/devtools/client/debugger/src/utils/utils.js
new file mode 100644
index 0000000000..74f0f026a4
--- /dev/null
+++ b/devtools/client/debugger/src/utils/utils.js
@@ -0,0 +1,59 @@
+/* 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/>. */
+
+const DevToolsUtils = require("devtools/shared/DevToolsUtils");
+
+/**
+ * Utils for utils, by utils
+ * @module utils/utils
+ */
+
+/**
+ * @memberof utils/utils
+ * @static
+ */
+export function handleError(err) {
+ console.log("ERROR: ", err);
+}
+
+/**
+ * @memberof utils/utils
+ * @static
+ */
+export function promisify(context, method, ...args) {
+ return new Promise((resolve, reject) => {
+ args.push(response => {
+ if (response.error) {
+ reject(response);
+ } else {
+ resolve(response);
+ }
+ });
+ method.apply(context, args);
+ });
+}
+
+/**
+ * @memberof utils/utils
+ * @static
+ */
+export function endTruncateStr(str, size) {
+ if (str.length > size) {
+ return `…${str.slice(str.length - size)}`;
+ }
+ return str;
+}
+
+export function waitForMs(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+export async function saveAsLocalFile(content, fileName) {
+ if (content.type !== "text") {
+ return null;
+ }
+
+ const data = new TextEncoder().encode(content.value);
+ return DevToolsUtils.saveAs(window, data, fileName);
+}
diff --git a/devtools/client/debugger/src/utils/wasm.js b/devtools/client/debugger/src/utils/wasm.js
new file mode 100644
index 0000000000..fa1c23c39c
--- /dev/null
+++ b/devtools/client/debugger/src/utils/wasm.js
@@ -0,0 +1,168 @@
+/* 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 { BinaryReader } from "wasmparser/dist/cjs/WasmParser";
+import {
+ WasmDisassembler,
+ NameSectionReader,
+} from "wasmparser/dist/cjs/WasmDis";
+
+var wasmStates = Object.create(null);
+
+function maybeWasmSectionNameResolver(data) {
+ try {
+ const parser = new BinaryReader();
+ parser.setData(data.buffer, 0, data.length);
+ const reader = new NameSectionReader();
+ reader.read(parser);
+ return reader.hasValidNames() ? reader.getNameResolver() : null;
+ } catch (ex) {
+ // Ignoring any errors during names section retrival.
+ return null;
+ }
+}
+
+/**
+ * @memberof utils/wasm
+ * @static
+ */
+export function getWasmText(sourceId, data) {
+ const nameResolver = maybeWasmSectionNameResolver(data);
+ const parser = new BinaryReader();
+ let result;
+ parser.setData(data.buffer, 0, data.length);
+ const dis = new WasmDisassembler();
+ if (nameResolver) {
+ dis.nameResolver = nameResolver;
+ }
+ dis.addOffsets = true;
+ try {
+ const done = dis.disassembleChunk(parser);
+ result = dis.getResult();
+ if (result.lines.length === 0) {
+ result = { lines: ["No luck with wast conversion"], offsets: [0], done };
+ }
+ } catch (e) {
+ result = {
+ lines: [`Error occured during wast conversion : ${e.message}`],
+ offsets: [0],
+ done: null,
+ };
+ }
+
+ const { offsets } = result;
+ const lines = [];
+ for (let i = 0; i < offsets.length; i++) {
+ lines[offsets[i]] = i;
+ }
+
+ wasmStates[sourceId] = { offsets, lines };
+
+ return { lines: result.lines, done: result.done };
+}
+
+/**
+ * @memberof utils/wasm
+ * @static
+ */
+export function getWasmLineNumberFormatter(sourceId) {
+ const codeOf0 = 48,
+ codeOfA = 65;
+ const buffer = [
+ codeOf0,
+ codeOf0,
+ codeOf0,
+ codeOf0,
+ codeOf0,
+ codeOf0,
+ codeOf0,
+ codeOf0,
+ ];
+ let last0 = 7;
+ return function (number) {
+ const offset = lineToWasmOffset(sourceId, number - 1);
+ if (offset == undefined) {
+ return "";
+ }
+ let i = 7;
+ for (let n = offset; n !== 0 && i >= 0; n >>= 4, i--) {
+ const nibble = n & 15;
+ buffer[i] = nibble < 10 ? codeOf0 + nibble : codeOfA - 10 + nibble;
+ }
+ for (let j = i; j > last0; j--) {
+ buffer[j] = codeOf0;
+ }
+ last0 = i;
+ return String.fromCharCode.apply(null, buffer);
+ };
+}
+
+/**
+ * @memberof utils/wasm
+ * @static
+ */
+export function isWasm(sourceId) {
+ return sourceId in wasmStates;
+}
+
+/**
+ * @memberof utils/wasm
+ * @static
+ */
+export function lineToWasmOffset(sourceId, number) {
+ const wasmState = wasmStates[sourceId];
+ if (!wasmState) {
+ return undefined;
+ }
+ let offset = wasmState.offsets[number];
+ while (offset === undefined && number > 0) {
+ offset = wasmState.offsets[--number];
+ }
+ return offset;
+}
+
+/**
+ * @memberof utils/wasm
+ * @static
+ */
+export function wasmOffsetToLine(sourceId, offset) {
+ const wasmState = wasmStates[sourceId];
+ if (!wasmState) {
+ return undefined;
+ }
+ return wasmState.lines[offset];
+}
+
+/**
+ * @memberof utils/wasm
+ * @static
+ */
+export function clearWasmStates() {
+ wasmStates = Object.create(null);
+}
+
+const wasmLines = new WeakMap();
+export function renderWasmText(sourceId, content) {
+ if (wasmLines.has(content)) {
+ return wasmLines.get(content) || [];
+ }
+
+ // binary does not survive as Uint8Array, converting from string
+ const { binary } = content.value;
+ const data = new Uint8Array(binary.length);
+ for (let i = 0; i < data.length; i++) {
+ data[i] = binary.charCodeAt(i);
+ }
+ const { lines } = getWasmText(sourceId, data);
+ const MAX_LINES = 1000000;
+ if (lines.length > MAX_LINES) {
+ lines.splice(MAX_LINES, lines.length - MAX_LINES);
+ lines.push(";; .... text is truncated due to the size");
+ }
+
+ wasmLines.set(content, lines);
+ return lines;
+}
diff --git a/devtools/client/debugger/src/utils/worker.js b/devtools/client/debugger/src/utils/worker.js
new file mode 100644
index 0000000000..8cd7071466
--- /dev/null
+++ b/devtools/client/debugger/src/utils/worker.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/>. */
+
+let msgId = 1;
+/**
+ * @memberof utils/utils
+ * @static
+ */
+function workerTask(worker, method) {
+ return function (...args) {
+ return new Promise((resolve, reject) => {
+ const id = msgId++;
+ worker.postMessage({ id, method, args });
+
+ const listener = ({ data: result }) => {
+ if (result.id !== id) {
+ return;
+ }
+
+ worker.removeEventListener("message", listener);
+ if (result.error) {
+ reject(result.error);
+ } else {
+ resolve(result.response);
+ }
+ };
+
+ worker.addEventListener("message", listener);
+ });
+ };
+}
+
+function workerHandler(publicInterface) {
+ return function onTask(msg) {
+ const { id, method, args } = msg.data;
+ const response = publicInterface[method].apply(null, args);
+
+ if (response instanceof Promise) {
+ response
+ .then(val => self.postMessage({ id, response: val }))
+ .catch(error => self.postMessage({ id, error }));
+ } else {
+ self.postMessage({ id, response });
+ }
+ };
+}
+
+export { workerTask, workerHandler };