summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/utils
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /devtools/client/debugger/src/utils
parentInitial commit. (diff)
downloadfirefox-upstream.tar.xz
firefox-upstream.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/debugger/src/utils')
-rw-r--r--devtools/client/debugger/src/utils/DevToolsUtils.js18
-rw-r--r--devtools/client/debugger/src/utils/assert.js12
-rw-r--r--devtools/client/debugger/src/utils/ast.js114
-rw-r--r--devtools/client/debugger/src/utils/async-value.js45
-rw-r--r--devtools/client/debugger/src/utils/bootstrap.js140
-rw-r--r--devtools/client/debugger/src/utils/breakpoint/astBreakpointLocation.js47
-rw-r--r--devtools/client/debugger/src/utils/breakpoint/breakpointPositions.js30
-rw-r--r--devtools/client/debugger/src/utils/breakpoint/index.js218
-rw-r--r--devtools/client/debugger/src/utils/breakpoint/moz.build12
-rw-r--r--devtools/client/debugger/src/utils/breakpoint/tests/__snapshots__/astBreakpointLocation.spec.js.snap67
-rw-r--r--devtools/client/debugger/src/utils/breakpoint/tests/astBreakpointLocation.spec.js68
-rw-r--r--devtools/client/debugger/src/utils/breakpoint/tests/index.spec.js30
-rw-r--r--devtools/client/debugger/src/utils/build-query.js75
-rw-r--r--devtools/client/debugger/src/utils/clipboard.js21
-rw-r--r--devtools/client/debugger/src/utils/connect.js8
-rw-r--r--devtools/client/debugger/src/utils/context.js88
-rw-r--r--devtools/client/debugger/src/utils/dbg.js100
-rw-r--r--devtools/client/debugger/src/utils/defer.js22
-rw-r--r--devtools/client/debugger/src/utils/editor/create-editor.js46
-rw-r--r--devtools/client/debugger/src/utils/editor/get-expression.js67
-rw-r--r--devtools/client/debugger/src/utils/editor/get-token-location.js22
-rw-r--r--devtools/client/debugger/src/utils/editor/index.js279
-rw-r--r--devtools/client/debugger/src/utils/editor/moz.build17
-rw-r--r--devtools/client/debugger/src/utils/editor/source-documents.js192
-rw-r--r--devtools/client/debugger/src/utils/editor/source-editor.css275
-rw-r--r--devtools/client/debugger/src/utils/editor/source-editor.js166
-rw-r--r--devtools/client/debugger/src/utils/editor/source-search.js350
-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.js27
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/editor.spec.js228
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/get-expression.spec.js162
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/get-token-location.spec.js33
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/source-search.spec.js184
-rw-r--r--devtools/client/debugger/src/utils/editor/token-events.js94
-rw-r--r--devtools/client/debugger/src/utils/editor/types.js9
-rw-r--r--devtools/client/debugger/src/utils/environment.js15
-rw-r--r--devtools/client/debugger/src/utils/evaluation-result.js32
-rw-r--r--devtools/client/debugger/src/utils/expressions.js70
-rw-r--r--devtools/client/debugger/src/utils/function.js44
-rw-r--r--devtools/client/debugger/src/utils/indentation.js42
-rw-r--r--devtools/client/debugger/src/utils/isMinified.js61
-rw-r--r--devtools/client/debugger/src/utils/location.js55
-rw-r--r--devtools/client/debugger/src/utils/log.js30
-rw-r--r--devtools/client/debugger/src/utils/memoizableAction.js95
-rw-r--r--devtools/client/debugger/src/utils/memoize.js69
-rw-r--r--devtools/client/debugger/src/utils/memoizeLast.js27
-rw-r--r--devtools/client/debugger/src/utils/moz.build57
-rw-r--r--devtools/client/debugger/src/utils/path.js26
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/annotateFrames.js80
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/collapseFrames.js69
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/displayName.js108
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/getFrameUrl.js11
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/getLibraryFromUrl.js149
-rw-r--r--devtools/client/debugger/src/utils/pause/frames/index.js11
-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.js24
-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.js129
-rw-r--r--devtools/client/debugger/src/utils/pause/index.js7
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/README.md191
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/buildGeneratedBindingList.js155
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/filtering.js53
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/findGeneratedBindingFromPosition.js329
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/getApplicableBindingsForOriginalPosition.js150
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/index.js589
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/locColumn.js17
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/mappingContains.js18
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/moz.build19
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/optimizedOut.js19
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/positionCmp.js27
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/rangeMetadata.js141
-rw-r--r--devtools/client/debugger/src/utils/pause/moz.build15
-rw-r--r--devtools/client/debugger/src/utils/pause/scopes/getScope.js131
-rw-r--r--devtools/client/debugger/src/utils/pause/scopes/getVariables.js48
-rw-r--r--devtools/client/debugger/src/utils/pause/scopes/index.js63
-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.js117
-rw-r--r--devtools/client/debugger/src/utils/pause/scopes/tests/scopes.spec.js139
-rw-r--r--devtools/client/debugger/src/utils/pause/scopes/types.js14
-rw-r--r--devtools/client/debugger/src/utils/pause/scopes/utils.js60
-rw-r--r--devtools/client/debugger/src/utils/pause/why.js68
-rw-r--r--devtools/client/debugger/src/utils/prefs.js180
-rw-r--r--devtools/client/debugger/src/utils/preview.js9
-rw-r--r--devtools/client/debugger/src/utils/project-search.js29
-rw-r--r--devtools/client/debugger/src/utils/quick-open.js159
-rw-r--r--devtools/client/debugger/src/utils/resource/base-query.js174
-rw-r--r--devtools/client/debugger/src/utils/resource/compare.js44
-rw-r--r--devtools/client/debugger/src/utils/resource/core.js180
-rw-r--r--devtools/client/debugger/src/utils/resource/index.js74
-rw-r--r--devtools/client/debugger/src/utils/resource/memoize.js54
-rw-r--r--devtools/client/debugger/src/utils/resource/moz.build17
-rw-r--r--devtools/client/debugger/src/utils/resource/query-cache.js148
-rw-r--r--devtools/client/debugger/src/utils/resource/query.js245
-rw-r--r--devtools/client/debugger/src/utils/resource/selector.js56
-rw-r--r--devtools/client/debugger/src/utils/resource/tests/crud.spec.js266
-rw-r--r--devtools/client/debugger/src/utils/resource/tests/query.spec.js1079
-rw-r--r--devtools/client/debugger/src/utils/result-list.js25
-rw-r--r--devtools/client/debugger/src/utils/selected-location.js23
-rw-r--r--devtools/client/debugger/src/utils/source-maps.js107
-rw-r--r--devtools/client/debugger/src/utils/source-queue.js44
-rw-r--r--devtools/client/debugger/src/utils/source.js573
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/addToTree.js187
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/collapseTree.js55
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/formatTree.js26
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/getDirectories.js71
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/getURL.js144
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/index.js20
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/moz.build19
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/sortTree.js38
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/addToTree.spec.js.snap90
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/collapseTree.spec.js.snap42
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/updateTree.spec.js.snap233
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/tests/addToTree.spec.js374
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/tests/collapseTree.spec.js127
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/tests/getDirectories.spec.js98
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/tests/getUrl.spec.js107
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/tests/treeOrder.spec.js25
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/tests/updateTree.spec.js148
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/tests/utils.spec.js223
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/treeOrder.js148
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/types.js35
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/updateTree.js262
-rw-r--r--devtools/client/debugger/src/utils/sources-tree/utils.js292
-rw-r--r--devtools/client/debugger/src/utils/tabs.js116
-rw-r--r--devtools/client/debugger/src/utils/task.js46
-rw-r--r--devtools/client/debugger/src/utils/telemetry.js83
-rw-r--r--devtools/client/debugger/src/utils/test-head.js301
-rw-r--r--devtools/client/debugger/src/utils/test-mockup.js282
-rw-r--r--devtools/client/debugger/src/utils/tests/DevToolsUtils.spec.js43
-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/__snapshots__/project-search.spec.js.snap23
-rw-r--r--devtools/client/debugger/src/utils/tests/assert.spec.js40
-rw-r--r--devtools/client/debugger/src/utils/tests/ast.spec.js36
-rw-r--r--devtools/client/debugger/src/utils/tests/build-query.spec.js259
-rw-r--r--devtools/client/debugger/src/utils/tests/clipboard.spec.js47
-rw-r--r--devtools/client/debugger/src/utils/tests/expressions.spec.js64
-rw-r--r--devtools/client/debugger/src/utils/tests/function.spec.js63
-rw-r--r--devtools/client/debugger/src/utils/tests/indentation.spec.js63
-rw-r--r--devtools/client/debugger/src/utils/tests/isMinified.spec.js20
-rw-r--r--devtools/client/debugger/src/utils/tests/location.spec.js31
-rw-r--r--devtools/client/debugger/src/utils/tests/log.spec.js37
-rw-r--r--devtools/client/debugger/src/utils/tests/memoize.spec.js50
-rw-r--r--devtools/client/debugger/src/utils/tests/memoizeLast.spec.js33
-rw-r--r--devtools/client/debugger/src/utils/tests/path.spec.js51
-rw-r--r--devtools/client/debugger/src/utils/tests/project-search.spec.js24
-rw-r--r--devtools/client/debugger/src/utils/tests/quick-open.spec.js37
-rw-r--r--devtools/client/debugger/src/utils/tests/result-list.spec.js32
-rw-r--r--devtools/client/debugger/src/utils/tests/source.spec.js602
-rw-r--r--devtools/client/debugger/src/utils/tests/telemetry.spec.js36
-rw-r--r--devtools/client/debugger/src/utils/tests/text.spec.js22
-rw-r--r--devtools/client/debugger/src/utils/tests/ui.spec.js17
-rw-r--r--devtools/client/debugger/src/utils/tests/url.spec.js100
-rw-r--r--devtools/client/debugger/src/utils/tests/utils.spec.js89
-rw-r--r--devtools/client/debugger/src/utils/tests/wasm.spec.js79
-rw-r--r--devtools/client/debugger/src/utils/text.js65
-rw-r--r--devtools/client/debugger/src/utils/timings.js48
-rw-r--r--devtools/client/debugger/src/utils/ui.js49
-rw-r--r--devtools/client/debugger/src/utils/url.js90
-rw-r--r--devtools/client/debugger/src/utils/utils.js68
-rw-r--r--devtools/client/debugger/src/utils/wasm.js168
-rw-r--r--devtools/client/debugger/src/utils/worker.js59
166 files changed, 16804 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..b5babdde21
--- /dev/null
+++ b/devtools/client/debugger/src/utils/DevToolsUtils.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/>. */
+
+// @flow
+
+import assert from "./assert";
+
+export function reportException(who: string, exception: any[]): void {
+ const msg = `${who} threw an exception: `;
+ console.error(msg, exception);
+}
+
+export function executeSoon(fn: Function): void {
+ 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..cdd05366b0
--- /dev/null
+++ b/devtools/client/debugger/src/utils/assert.js
@@ -0,0 +1,12 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+import { isNodeTest } from "./environment";
+
+export default function assert(condition: boolean, message: string): void {
+ if (isNodeTest() && !condition) {
+ throw new Error(`Assertion failure: ${message}`);
+ }
+}
diff --git a/devtools/client/debugger/src/utils/ast.js b/devtools/client/debugger/src/utils/ast.js
new file mode 100644
index 0000000000..05a43ae6b1
--- /dev/null
+++ b/devtools/client/debugger/src/utils/ast.js
@@ -0,0 +1,114 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { SourceLocation, Position, PartialPosition } from "../types";
+import type { Symbols } from "../reducers/ast";
+
+import type {
+ AstLocation,
+ FunctionDeclaration,
+ ClassDeclaration,
+} from "../workers/parser";
+
+export function findBestMatchExpression(symbols: Symbols, tokenPos: Position) {
+ if (symbols.loading) {
+ 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: AstLocation, b: AstLocation): boolean {
+ return (
+ a.start.line > b.start.line ||
+ (a.start.line === b.start.line && a.start.column > b.start.column)
+ );
+}
+
+export function containsPosition(a: AstLocation, b: PartialPosition): boolean {
+ const bColumn = b.column || 0;
+ const startsBefore =
+ a.start.line < b.line ||
+ (a.start.line === b.line && a.start.column <= bColumn);
+ const endsAfter =
+ a.end.line > b.line || (a.end.line === b.line && a.end.column >= bColumn);
+
+ return startsBefore && endsAfter;
+}
+
+function findClosestofSymbol(declarations: any[], location: SourceLocation) {
+ 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: ?Symbols,
+ location: SourceLocation
+): FunctionDeclaration | null {
+ if (!symbols || symbols.loading) {
+ return null;
+ }
+
+ return findClosestofSymbol(symbols.functions, location);
+}
+
+export function findClosestClass(
+ symbols: Symbols,
+ location: SourceLocation
+): ClassDeclaration | null {
+ if (!symbols || symbols.loading) {
+ 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..f90b16c5ca
--- /dev/null
+++ b/devtools/client/debugger/src/utils/async-value.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/>. */
+
+// @flow
+
+export type FulfilledValue<+T> = {|
+ state: "fulfilled",
+ +value: T,
+|};
+export type RejectedValue = {|
+ state: "rejected",
+ value: mixed,
+|};
+export type PendingValue = {|
+ state: "pending",
+|};
+export type SettledValue<+T> = FulfilledValue<T> | RejectedValue;
+export type AsyncValue<+T> = SettledValue<T> | PendingValue;
+
+export function pending(): PendingValue {
+ return { state: "pending" };
+}
+export function fulfilled<+T>(value: T): FulfilledValue<T> {
+ return { state: "fulfilled", value };
+}
+export function rejected(value: mixed): RejectedValue {
+ return { state: "rejected", value };
+}
+
+export function asSettled<T>(
+ value: AsyncValue<T> | null
+): SettledValue<T> | null {
+ return value && value.state !== "pending" ? value : null;
+}
+
+export function isPending(value: AsyncValue<mixed>): boolean %checks {
+ return value.state === "pending";
+}
+export function isFulfilled(value: AsyncValue<mixed>): boolean %checks {
+ return value.state === "fulfilled";
+}
+export function isRejected(value: AsyncValue<mixed>): boolean %checks {
+ 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..52095de4cd
--- /dev/null
+++ b/devtools/client/debugger/src/utils/bootstrap.js
@@ -0,0 +1,140 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import 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";
+// $FlowIgnore
+import flags from "devtools/shared/flags";
+
+// $FlowIgnore
+const { AppConstants } = require("resource://gre/modules/AppConstants.jsm");
+
+import SourceMaps from "devtools-source-map";
+import * as search from "../workers/search";
+import * as prettyPrint from "../workers/pretty-print";
+import { ParserDispatcher } from "../workers/parser";
+
+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";
+
+import type { Panel } from "../client/firefox/types";
+
+let parser;
+
+type Workers = {
+ sourceMaps: typeof SourceMaps,
+ evaluationsParser: typeof ParserDispatcher,
+};
+
+export function bootstrapStore(
+ client: any,
+ workers: Workers,
+ panel: Panel,
+ initialState: Object
+): any {
+ 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: Workers): Object {
+ const workerPath = "resource://devtools/client/debugger/dist";
+
+ prettyPrint.start(`${workerPath}/pretty-print-worker.js`);
+ parser = new ParserDispatcher();
+
+ parser.start(`${workerPath}/parser-worker.js`);
+ search.start(`${workerPath}/search-worker.js`);
+ return { ...panelWorkers, prettyPrint, parser, search };
+}
+
+export function teardownWorkers(): void {
+ prettyPrint.stop();
+ parser.stop();
+ search.stop();
+}
+
+export function bootstrapApp(store: any, panel: Panel): void {
+ const mount = document.querySelector("#mount");
+ if (!mount) {
+ return;
+ }
+
+ const toolboxDoc = panel.panelWin.parent.document;
+
+ ReactDOM.render(
+ React.createElement(
+ Provider,
+ { store },
+ React.createElement(
+ ToolboxProvider,
+ { store: panel.getToolboxStore() },
+ React.createElement(App, { toolboxDoc })
+ )
+ ),
+ mount
+ );
+}
+
+function registerStoreObserver(store, subscriber) {
+ let oldState = store.getState();
+ store.subscribe(() => {
+ const state = store.getState();
+ subscriber(state, oldState);
+ oldState = state;
+ });
+}
+
+function updatePrefs(state: any, oldState: any): void {
+ const hasChanged = selector =>
+ selector(oldState) && selector(oldState) !== selector(state);
+
+ if (hasChanged(selectors.getPendingBreakpoints)) {
+ asyncStore.pendingBreakpoints = 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.getBlackBoxList)) {
+ asyncStore.tabsBlackBoxed = selectors.getBlackBoxList(state);
+ }
+}
diff --git a/devtools/client/debugger/src/utils/breakpoint/astBreakpointLocation.js b/devtools/client/debugger/src/utils/breakpoint/astBreakpointLocation.js
new file mode 100644
index 0000000000..6915ef74cc
--- /dev/null
+++ b/devtools/client/debugger/src/utils/breakpoint/astBreakpointLocation.js
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { findClosestFunction } from "../ast";
+
+import type { SourceLocation, Source, ASTLocation } from "../../types";
+import type { Symbols } from "../../reducers/ast";
+
+export function getASTLocation(
+ source: Source,
+ symbols: ?Symbols,
+ location: SourceLocation
+): ASTLocation {
+ if (source.isWasm || !symbols || symbols.loading) {
+ return { name: undefined, offset: location, index: 0 };
+ }
+
+ const scope = findClosestFunction(symbols, location);
+ if (scope) {
+ // we only record the line, but at some point we may
+ // also do column offsets
+ const line = location.line - scope.location.start.line;
+ return {
+ name: scope.name,
+ offset: { line, column: undefined },
+ index: scope.index,
+ };
+ }
+ return { name: undefined, offset: location, index: 0 };
+}
+
+export function findFunctionByName(
+ symbols: Symbols,
+ name: ?string,
+ index: number
+) {
+ if (symbols.loading) {
+ return null;
+ }
+
+ return symbols.functions.find(
+ node => node.name === name && node.index === index
+ );
+}
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..c561701484
--- /dev/null
+++ b/devtools/client/debugger/src/utils/breakpoint/breakpointPositions.js
@@ -0,0 +1,30 @@
+// @flow
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { comparePosition } from "../location";
+import { getSelectedLocation } from "../selected-location";
+import type {
+ BreakpointPosition,
+ BreakpointPositions,
+ SourceLocation,
+} from "../../types";
+
+export function findPosition(
+ positions: ?BreakpointPositions,
+ location: SourceLocation
+): ?BreakpointPosition {
+ 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..4644487176
--- /dev/null
+++ b/devtools/client/debugger/src/utils/breakpoint/index.js
@@ -0,0 +1,218 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import {
+ getBreakpoint,
+ getSource,
+ getSourceActorsForSource,
+} from "../../selectors";
+import { isGenerated } from "../source";
+import { sortSelectedLocations } from "../location";
+import assert from "../assert";
+import { features } from "../prefs";
+
+export * from "./astBreakpointLocation";
+export * from "./breakpointPositions";
+
+import type {
+ Source,
+ SourceActor,
+ SourceLocation,
+ SourceActorLocation,
+ PendingLocation,
+ Breakpoint,
+ BreakpointLocation,
+ PendingBreakpoint,
+} from "../../types";
+
+import type { State } from "../../reducers/types";
+
+// Return the first argument that is a string, or null if nothing is a
+// string.
+export function firstString(...args: string[]): ?string {
+ for (const arg of args) {
+ if (typeof arg === "string") {
+ return arg;
+ }
+ }
+ return null;
+}
+
+// The ID for a Breakpoint is derived from its location in its Source.
+export function makeBreakpointId(location: SourceLocation): string {
+ const { sourceId, line, column } = location;
+ const columnString = column || "";
+ return `${sourceId}:${line}:${columnString}`;
+}
+
+export function getLocationWithoutColumn(location: SourceLocation): string {
+ const { sourceId, line } = location;
+ return `${sourceId}:${line}`;
+}
+
+type AnySourceLocation = SourceLocation | PendingLocation;
+
+export function makePendingLocationId(location: AnySourceLocation): string {
+ assertPendingLocation(location);
+ const { sourceUrl, line, column } = location;
+ const sourceUrlString = sourceUrl || "";
+ const columnString = column || "";
+
+ return `${sourceUrlString}:${line}:${columnString}`;
+}
+
+export function makeBreakpointLocation(
+ state: State,
+ location: SourceLocation
+): BreakpointLocation {
+ const source = getSource(state, location.sourceId);
+ if (!source) {
+ throw new Error("no source");
+ }
+ const breakpointLocation: any = {
+ 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 makeSourceActorLocation(
+ sourceActor: SourceActor,
+ location: SourceLocation
+) {
+ return {
+ sourceActor,
+ line: location.line,
+ column: location.column,
+ };
+}
+
+// The ID for a BreakpointActor is derived from its location in its SourceActor.
+export function makeBreakpointActorId(location: SourceActorLocation): string {
+ const { sourceActor, line, column } = location;
+ return `${sourceActor}:${line}:${column || ""}`;
+}
+
+export function assertBreakpoint(breakpoint: Breakpoint): void {
+ assertLocation(breakpoint.location);
+ assertLocation(breakpoint.generatedLocation);
+}
+
+export function assertPendingBreakpoint(
+ pendingBreakpoint: PendingBreakpoint
+): void {
+ assertPendingLocation(pendingBreakpoint.location);
+ assertPendingLocation(pendingBreakpoint.generatedLocation);
+}
+
+export function assertLocation(location: SourceLocation): void {
+ assertPendingLocation(location);
+ const { sourceId } = location;
+ assert(!!sourceId, "location must have a source id");
+}
+
+export function assertPendingLocation(location: PendingLocation): void {
+ assert(!!location, "location must exist");
+
+ const { sourceUrl } = location;
+
+ // sourceUrl is null when the source does not have a url
+ assert(sourceUrl !== undefined, "location must have a source url");
+ assert(location.hasOwnProperty("line"), "location must have a line");
+ assert(
+ location.hasOwnProperty("column") != null,
+ "location must have a column"
+ );
+}
+
+// syncing
+export function breakpointAtLocation(
+ breakpoints: Breakpoint[],
+ { line, column }: SourceLocation
+): ?Breakpoint {
+ return breakpoints.find(breakpoint => {
+ const sameLine = breakpoint.location.line === line;
+ if (!sameLine) {
+ return false;
+ }
+
+ // NOTE: when column breakpoints are disabled we want to find
+ // the first breakpoint
+ if (!features.columnBreakpoints) {
+ return true;
+ }
+
+ return breakpoint.location.column === column;
+ });
+}
+
+export function breakpointExists(
+ state: State,
+ location: SourceLocation
+): boolean {
+ const currentBp = getBreakpoint(state, location);
+ return !!currentBp && !currentBp.disabled;
+}
+
+export function createXHRBreakpoint(
+ path: string,
+ method: string,
+ overrides?: Object = {}
+) {
+ const properties = {
+ path,
+ method,
+ disabled: false,
+ loading: false,
+ text: L10N.getFormatStr("xhrBreakpoints.item.label", path),
+ };
+
+ return { ...properties, ...overrides };
+}
+
+function createPendingLocation(location: PendingLocation) {
+ const { sourceUrl, line, column } = location;
+ return { sourceUrl, line, column };
+}
+
+export function createPendingBreakpoint(bp: Breakpoint) {
+ const pendingLocation = createPendingLocation(bp.location);
+ const pendingGeneratedLocation = createPendingLocation(bp.generatedLocation);
+
+ assertPendingLocation(pendingLocation);
+
+ return {
+ options: bp.options,
+ disabled: bp.disabled,
+ location: pendingLocation,
+ astLocation: bp.astLocation,
+ generatedLocation: pendingGeneratedLocation,
+ };
+}
+
+export function getSelectedText(
+ breakpoint: Breakpoint,
+ selectedSource: ?Source
+): string {
+ return !!selectedSource && isGenerated(selectedSource)
+ ? breakpoint.text
+ : breakpoint.originalText;
+}
+
+export function sortSelectedBreakpoints(
+ breakpoints: Array<Breakpoint>,
+ selectedSource: ?Source
+): Array<Breakpoint> {
+ 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..24f31beb71
--- /dev/null
+++ b/devtools/client/debugger/src/utils/breakpoint/moz.build
@@ -0,0 +1,12 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += []
+
+CompiledModules(
+ "astBreakpointLocation.js",
+ "breakpointPositions.js",
+ "index.js",
+)
diff --git a/devtools/client/debugger/src/utils/breakpoint/tests/__snapshots__/astBreakpointLocation.spec.js.snap b/devtools/client/debugger/src/utils/breakpoint/tests/__snapshots__/astBreakpointLocation.spec.js.snap
new file mode 100644
index 0000000000..c907c080ad
--- /dev/null
+++ b/devtools/client/debugger/src/utils/breakpoint/tests/__snapshots__/astBreakpointLocation.spec.js.snap
@@ -0,0 +1,67 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ast invalid location returns name for an anon fn in global scope as undefined 1`] = `
+Object {
+ "index": 0,
+ "name": undefined,
+ "offset": Object {
+ "column": 0,
+ "line": 44,
+ },
+}
+`;
+
+exports[`ast invalid location returns the scope name for global scope as undefined 1`] = `
+Object {
+ "index": 0,
+ "name": undefined,
+ "offset": Object {
+ "column": 0,
+ "line": 10,
+ },
+}
+`;
+
+exports[`ast valid location returns name for a nested anon fn as the parent func 1`] = `
+Object {
+ "index": 0,
+ "name": "outer",
+ "offset": Object {
+ "column": undefined,
+ "line": 22,
+ },
+}
+`;
+
+exports[`ast valid location returns name for a nested named fn 1`] = `
+Object {
+ "index": 0,
+ "name": "inner",
+ "offset": Object {
+ "column": undefined,
+ "line": 1,
+ },
+}
+`;
+
+exports[`ast valid location returns name for an anon fn with a named variable 1`] = `
+Object {
+ "index": 0,
+ "name": "globalDeclaration",
+ "offset": Object {
+ "column": undefined,
+ "line": 1,
+ },
+}
+`;
+
+exports[`ast valid location returns the scope and offset 1`] = `
+Object {
+ "index": 0,
+ "name": "math",
+ "offset": Object {
+ "column": undefined,
+ "line": 5,
+ },
+}
+`;
diff --git a/devtools/client/debugger/src/utils/breakpoint/tests/astBreakpointLocation.spec.js b/devtools/client/debugger/src/utils/breakpoint/tests/astBreakpointLocation.spec.js
new file mode 100644
index 0000000000..a38a668d71
--- /dev/null
+++ b/devtools/client/debugger/src/utils/breakpoint/tests/astBreakpointLocation.spec.js
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { getASTLocation } from "../astBreakpointLocation.js";
+import {
+ populateSource,
+ populateOriginalSource,
+} from "../../../workers/parser/tests/helpers";
+import { getSymbols } from "../../../workers/parser/getSymbols";
+import cases from "jest-in-case";
+
+async function setup({ file, location, functionName, original }) {
+ const source = original ? populateOriginalSource(file) : populateSource(file);
+
+ const symbols = getSymbols(source.id);
+
+ const astLocation = getASTLocation(source, symbols, location);
+ expect(astLocation.name).toBe(functionName);
+ expect(astLocation).toMatchSnapshot();
+}
+
+describe("ast", () => {
+ cases("valid location", setup, [
+ {
+ name: "returns the scope and offset",
+ file: "math",
+ location: { line: 6, column: 0 },
+ functionName: "math",
+ },
+ {
+ name: "returns name for a nested anon fn as the parent func",
+ file: "outOfScope",
+ location: { line: 25, column: 0 },
+ functionName: "outer",
+ },
+ {
+ name: "returns name for a nested named fn",
+ file: "outOfScope",
+ location: { line: 5, column: 0 },
+ functionName: "inner",
+ },
+ {
+ name: "returns name for an anon fn with a named variable",
+ file: "outOfScope",
+ location: { line: 40, column: 0 },
+ functionName: "globalDeclaration",
+ },
+ ]);
+
+ cases("invalid location", setup, [
+ {
+ name: "returns the scope name for global scope as undefined",
+ file: "class",
+ original: true,
+ location: { line: 10, column: 0 },
+ functionName: undefined,
+ },
+ {
+ name: "returns name for an anon fn in global scope as undefined",
+ file: "outOfScope",
+ location: { line: 44, column: 0 },
+ functionName: undefined,
+ },
+ ]);
+});
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..f95b296835
--- /dev/null
+++ b/devtools/client/debugger/src/utils/breakpoint/tests/index.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/>. */
+
+// @flow
+
+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..9ad4789602
--- /dev/null
+++ b/devtools/client/debugger/src/utils/build-query.js
@@ -0,0 +1,75 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+import escapeRegExp from "lodash/escapeRegExp";
+
+import type { SearchModifiers } from "../types";
+
+type QueryOptions = {
+ isGlobal?: boolean,
+ ignoreSpaces?: boolean,
+};
+
+/**
+ * Ignore doing outline matches for less than 3 whitespaces
+ *
+ * @memberof utils/source-search
+ * @static
+ */
+function ignoreWhiteSpace(str: string): string {
+ return /^\s{0,2}$/.test(str) ? "(?!\\s*.*)" : str;
+}
+
+function wholeMatch(query: string, wholeWord: boolean): string {
+ if (query === "" || !wholeWord) {
+ return query;
+ }
+
+ return `\\b${query}\\b`;
+}
+
+function buildFlags(caseSensitive: boolean, isGlobal: boolean): ?RegExp$flags {
+ if (caseSensitive && isGlobal) {
+ return "g";
+ }
+
+ if (!caseSensitive && isGlobal) {
+ return "gi";
+ }
+
+ if (!caseSensitive && !isGlobal) {
+ return "i";
+ }
+}
+
+export default function buildQuery(
+ originalQuery: string,
+ modifiers: SearchModifiers,
+ { isGlobal = false, ignoreSpaces = false }: QueryOptions
+): RegExp {
+ const { caseSensitive, regexMatch, wholeWord } = modifiers;
+
+ if (originalQuery === "") {
+ return new RegExp(originalQuery);
+ }
+
+ let query = originalQuery;
+ if (ignoreSpaces) {
+ query = ignoreWhiteSpace(query);
+ }
+
+ if (!regexMatch) {
+ query = escapeRegExp(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..f2d5fd0a25
--- /dev/null
+++ b/devtools/client/debugger/src/utils/clipboard.js
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Clipboard function taken from
+ * https://searchfox.org/mozilla-central/source/devtools/shared/platform/clipboard.js
+ */
+
+// @flow
+
+export function copyToTheClipboard(string: string): void {
+ const doCopy = function(e: any) {
+ 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..7188149d1b
--- /dev/null
+++ b/devtools/client/debugger/src/utils/connect.js
@@ -0,0 +1,8 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+// @flow
+
+import { connect as reduxConnect } from "react-redux";
+
+export const connect: typeof reduxConnect = 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..500a38c751
--- /dev/null
+++ b/devtools/client/debugger/src/utils/context.js
@@ -0,0 +1,88 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+// @flow
+
+import type { ThreadId } from "../types";
+import type { State } from "../reducers/types";
+
+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.
+export type NavigateContext = {|
+ // Counter reflecting how many times the debugger has navigated to a new page
+ // and reset most of its state.
+ +navigateCounter: number,
+|};
+
+// A ThreadContext is invalidated if the target navigates, or if the current
+// thread changes, pauses, or resumes.
+export type ThreadContext = {|
+ +navigateCounter: number,
+
+ // The currently selected thread.
+ +thread: ThreadId,
+
+ // Counter reflecting how many times the selected thread has paused or
+ // resumed.
+ +pauseCounter: number,
+
+ // Whether the current thread is paused. This is determined from the other
+ // Context properties and is here for convenient access.
+ +isPaused: boolean,
+|};
+
+export type Context = NavigateContext | ThreadContext;
+
+export class ContextError extends Error {}
+
+export function validateNavigateContext(state: State, cx: Context): void {
+ const newcx = getThreadContext(state);
+
+ if (newcx.navigateCounter != cx.navigateCounter) {
+ throw new ContextError("Page has navigated");
+ }
+}
+
+export function validateThreadContext(state: State, cx: ThreadContext): void {
+ 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: State, cx: Context): void {
+ validateNavigateContext(state, cx);
+
+ if ("thread" in cx) {
+ validateThreadContext(state, (cx: any));
+ }
+}
+
+export function isValidThreadContext(state: State, cx: ThreadContext): boolean {
+ 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..4696c33101
--- /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/>. */
+
+// @flow
+
+import * as timings from "./timings";
+import { prefs, asyncStore, features } from "./prefs";
+import { getDocument } from "./editor/source-documents";
+import type { Source, URL } from "../types";
+import type { ThreadFront } from "../client/firefox/types";
+
+function getThreadFront(dbg: Object): ThreadFront {
+ return dbg.connection.targetList.targetFront.threadFront;
+}
+
+function findSource(dbg: any, url: URL): Source {
+ const sources = dbg.selectors.getSourceList();
+ return sources.find(s => (s.url || "").includes(url));
+}
+
+function findSources(dbg: any, url: URL): Source[] {
+ const sources = dbg.selectors.getSourceList();
+ return sources.filter(s => (s.url || "").includes(url));
+}
+
+function evaluate(dbg: Object, expression: any) {
+ return dbg.client.evaluate(expression);
+}
+
+function bindSelectors(obj: Object): Object {
+ 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(): Object {
+ const cm: any = 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: Object) {
+ const selectors = bindSelectors(obj);
+ const dbg: Object = {
+ ...obj,
+ selectors,
+ prefs,
+ asyncStore,
+ features,
+ timings,
+ getCM,
+ 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/defer.js b/devtools/client/debugger/src/utils/defer.js
new file mode 100644
index 0000000000..333068087a
--- /dev/null
+++ b/devtools/client/debugger/src/utils/defer.js
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+type Deferred<T> = {
+ promise: Promise<T>,
+ resolve: (arg: T) => mixed,
+ reject: (arg: mixed) => mixed,
+};
+
+export default function defer<T>(): Deferred<T> {
+ let resolve = () => {};
+ let reject = () => {};
+ const promise = new Promise((_res, _rej) => {
+ resolve = _res;
+ reject = _rej;
+ });
+
+ return { resolve, reject, promise };
+}
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..805e713240
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/create-editor.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import SourceEditor from "./source-editor";
+import { features, prefs } from "../prefs";
+
+export function createEditor(): SourceEditor {
+ 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(): SourceEditor {
+ 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..1a307ffd8e
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/get-expression.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/>. */
+
+// @flow
+
+import type { Position } from "../../types";
+
+type Token = {
+ startColumn: number,
+ endColumn: number,
+ type: string,
+};
+
+export function tokenAtTextPosition(
+ cm: any,
+ { line, column }: Position
+): Token | null {
+ 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: any, coord: Position) {
+ 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..dbcbf22d0d
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/get-token-location.js
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+import type { Position } from "../../types";
+
+export function getTokenLocation(
+ codeMirror: any,
+ tokenEl: HTMLElement
+): Position {
+ 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..c1e7deafa4
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/index.js
@@ -0,0 +1,279 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+export * from "./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 { findNext, findPrev } from "./source-search";
+
+import { isWasm, lineToWasmOffset, wasmOffsetToLine } from "../wasm";
+
+import type { AstLocation } from "../../workers/parser";
+import type { EditorPosition, EditorRange } from "../editor/types";
+import type {
+ SearchModifiers,
+ Source,
+ SourceLocation,
+ SourceId,
+} from "../../types";
+type Editor = Object;
+
+let editor: ?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 traverseResults(
+ e: Event,
+ ctx: any,
+ query: string,
+ dir: string,
+ modifiers: SearchModifiers
+) {
+ e.stopPropagation();
+ e.preventDefault();
+
+ if (dir == "prev") {
+ findPrev(ctx, query, true, modifiers);
+ } else if (dir == "next") {
+ findNext(ctx, query, true, modifiers);
+ }
+}
+
+export function toEditorLine(sourceId: SourceId, lineOrOffset: number): number {
+ 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: SourceId, line: number): number {
+ if (isWasm(sourceId)) {
+ return lineToWasmOffset(sourceId, line) || 0;
+ }
+
+ return line + 1;
+}
+
+export function toEditorPosition(location: SourceLocation): EditorPosition {
+ return {
+ line: toEditorLine(location.sourceId, location.line),
+ column: isWasm(location.sourceId) || !location.column ? 0 : location.column,
+ };
+}
+
+export function toEditorRange(
+ sourceId: SourceId,
+ location: AstLocation
+): EditorRange {
+ const { start, end } = location;
+ return {
+ start: toEditorPosition({ ...start, sourceId }),
+ end: toEditorPosition({ ...end, sourceId }),
+ };
+}
+
+export function toSourceLine(sourceId: SourceId, line: number): ?number {
+ return isWasm(sourceId) ? lineToWasmOffset(sourceId, line) : line + 1;
+}
+
+export function scrollToColumn(codeMirror: any, line: number, column: number) {
+ 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: any, top: number, left: number) {
+ 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 }: Object,
+ // Offset represents an allowance of characters or lines offscreen to improve
+ // perceived performance of column breakpoint rendering
+ offsetHorizontalCharacters: number = 100,
+ offsetVerticalLines: number = 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 }: Object,
+ className: string,
+ { start, end }: EditorRange
+) {
+ return codeMirror.markText(
+ { ch: start.column, line: start.line },
+ { ch: end.column, line: end.line },
+ { className }
+ );
+}
+
+export function lineAtHeight(
+ { codeMirror }: Object,
+ sourceId: SourceId,
+ event: MouseEvent
+) {
+ const _editorLine = codeMirror.lineAtHeight(event.clientY);
+ return toSourceLine(sourceId, _editorLine);
+}
+
+export function getSourceLocationFromMouseEvent(
+ { codeMirror }: Object,
+ source: Source,
+ e: MouseEvent
+) {
+ const { line, ch } = codeMirror.coordsChar({
+ left: e.clientX,
+ top: e.clientY,
+ });
+ const sourceId = source.id;
+
+ return {
+ sourceId,
+ line: fromEditorLine(sourceId, line),
+ column: isWasm(sourceId) ? 0 : ch + 1,
+ };
+}
+
+export function forEachLine(codeMirror: Object, iter: Function) {
+ codeMirror.operation(() => {
+ codeMirror.doc.iter(0, codeMirror.lineCount(), iter);
+ });
+}
+
+export function removeLineClass(
+ codeMirror: Object,
+ line: number,
+ className: string
+) {
+ codeMirror.removeLineClass(line, "wrapClass", className);
+}
+
+export function clearLineClass(codeMirror: Object, className: string) {
+ forEachLine(codeMirror, line => {
+ removeLineClass(codeMirror, line, className);
+ });
+}
+
+export function getTextForLine(codeMirror: Object, line: number): string {
+ return codeMirror.getLine(line - 1).trim();
+}
+
+export function getCursorLine(codeMirror: Object): number {
+ return codeMirror.getCursor().line;
+}
+
+export function getCursorColumn(codeMirror: Object): number {
+ return codeMirror.getCursor().ch;
+}
+
+export function getTokenEnd(codeMirror: Object, line: number, column: number) {
+ 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..0ad4c0a689
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/source-documents.js
@@ -0,0 +1,192 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { getMode } from "../source";
+
+import { isWasm, getWasmLineNumberFormatter, renderWasmText } from "../wasm";
+import { isMinified } from "../isMinified";
+import { resizeBreakpointGutter, resizeToggleButton } from "../ui";
+import SourceEditor from "./source-editor";
+
+import type {
+ SourceId,
+ Source,
+ SourceContent,
+ SourceWithContent,
+ SourceDocuments,
+} from "../../types";
+import type { SymbolDeclarations } from "../../workers/parser";
+
+let sourceDocs: SourceDocuments = {};
+
+export function getDocument(key: string): Object {
+ return sourceDocs[key];
+}
+
+export function hasDocument(key: string): boolean {
+ return !!getDocument(key);
+}
+
+export function setDocument(key: string, doc: any): void {
+ sourceDocs[key] = doc;
+}
+
+export function removeDocument(key: string): void {
+ delete sourceDocs[key];
+}
+
+export function clearDocuments(): void {
+ sourceDocs = {};
+}
+
+function resetLineNumberFormat(editor: SourceEditor): void {
+ const cm = editor.codeMirror;
+ cm.setOption("lineNumberFormatter", number => number);
+ resizeBreakpointGutter(cm);
+ resizeToggleButton(cm);
+}
+
+export function updateLineNumberFormat(
+ editor: SourceEditor,
+ sourceId: SourceId
+): void {
+ if (!isWasm(sourceId)) {
+ return resetLineNumberFormat(editor);
+ }
+ const cm = editor.codeMirror;
+ const lineNumberFormatter = getWasmLineNumberFormatter(sourceId);
+ cm.setOption("lineNumberFormatter", lineNumberFormatter);
+ resizeBreakpointGutter(cm);
+ resizeToggleButton(cm);
+}
+
+export function updateDocument(editor: SourceEditor, source: Source): void {
+ 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: Function) {
+ for (const key in sourceDocs) {
+ if (sourceDocs[key].cm == null) {
+ continue;
+ } else {
+ updater(sourceDocs[key]);
+ }
+ }
+}
+
+export function clearEditor(editor: SourceEditor) {
+ const doc = editor.createDocument();
+ editor.replaceDocument(doc);
+ editor.setText("");
+ editor.setMode({ name: "text" });
+ resetLineNumberFormat(editor);
+}
+
+export function showLoading(editor: SourceEditor): void {
+ let doc = getDocument("loading");
+
+ if (doc) {
+ editor.replaceDocument(doc);
+ } else {
+ doc = editor.createDocument();
+ setDocument("loading", doc);
+ doc.setValue(L10N.getStr("loadingText"));
+ editor.replaceDocument(doc);
+ editor.setMode({ name: "text" });
+ }
+}
+
+export function showErrorMessage(editor: Object, msg: string): void {
+ 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();
+ editor.replaceDocument(doc);
+ editor.setText(error);
+ editor.setMode({ name: "text" });
+ resetLineNumberFormat(editor);
+}
+
+function setEditorText(
+ editor: Object,
+ sourceId: SourceId,
+ content: SourceContent
+): void {
+ if (content.type === "wasm") {
+ const wasmLines = renderWasmText(sourceId, content);
+ // cm will try to split into lines anyway, saving memory
+ const wasmText = { split: () => wasmLines, match: () => false };
+ editor.setText(wasmText);
+ } else {
+ editor.setText(content.value);
+ }
+}
+
+function setMode(
+ editor,
+ source: SourceWithContent,
+ content: SourceContent,
+ symbols
+): void {
+ // Disable modes for minified files with 1+ million characters Bug 1569829
+ if (
+ content.type === "text" &&
+ isMinified(source) &&
+ content.value.length > 1000000
+ ) {
+ return;
+ }
+
+ const mode = getMode(source, content, 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: Object,
+ source: SourceWithContent,
+ content: SourceContent,
+ symbols?: SymbolDeclarations
+): void {
+ if (hasDocument(source.id)) {
+ const doc = getDocument(source.id);
+ if (editor.codeMirror.doc === doc) {
+ setMode(editor, source, content, symbols);
+ return;
+ }
+
+ editor.replaceDocument(doc);
+ updateLineNumberFormat(editor, source.id);
+ setMode(editor, source, content, symbols);
+ return doc;
+ }
+
+ const doc = editor.createDocument();
+ setDocument(source.id, doc);
+ editor.replaceDocument(doc);
+
+ setEditorText(editor, source.id, content);
+ setMode(editor, source, content, symbols);
+ 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..380256b58d
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/source-editor.css
@@ -0,0 +1,275 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+: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;
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: contain;
+ /* background-image: url("chrome://devtools/skin/images/editor-error.png"); */
+ 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..a27d658cc9
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/source-editor.js
@@ -0,0 +1,166 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+/**
+ * CodeMirror source editor utils
+ * @module utils/source-editor
+ */
+
+const CodeMirror = require("codemirror");
+
+// $FlowIgnore
+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");
+
+// $FlowIgnore
+require("raw!chrome://devtools/content/debugger/src/utils/editor/source-editor.css");
+
+// NOTE: we should eventually use debugger-html context type mode
+type Mode = string | Object;
+export type AlignOpts = "top" | "center" | "bottom";
+
+// 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;
+
+type SourceEditorOpts = {
+ enableCodeFolding: boolean,
+ extraKeys: Object,
+ gutters: string[],
+ foldGutter: boolean,
+ lineNumbers: boolean,
+ lineWrapping: boolean,
+ matchBrackets: boolean,
+ mode: string,
+ readOnly: boolean,
+ showAnnotationRuler: boolean,
+ theme: string,
+ value: string,
+};
+
+export default class SourceEditor {
+ opts: SourceEditorOpts;
+ editor: any;
+
+ constructor(opts: SourceEditorOpts): void {
+ this.opts = opts;
+ }
+
+ appendToLocalElement(node: any): void {
+ this.editor = CodeMirror(node, this.opts);
+ }
+
+ destroy(): void {
+ // Unlink the current document.
+ if (this.editor.doc) {
+ this.editor.doc.cm = null;
+ }
+ }
+
+ get codeMirror(): Object {
+ return this.editor;
+ }
+
+ get CodeMirror(): Object {
+ return CodeMirror;
+ }
+
+ setText(str: string): void {
+ this.editor.setValue(str);
+ }
+
+ getText(): string {
+ return this.editor.getValue();
+ }
+
+ setMode(value: Mode): void {
+ this.editor.setOption("mode", value);
+ }
+
+ /**
+ * Replaces the current document with a new source document
+ * @memberof utils/source-editor
+ */
+ replaceDocument(doc: any): void {
+ this.editor.swapDoc(doc);
+ }
+
+ /**
+ * Creates a CodeMirror Document
+ * @returns CodeMirror.Doc
+ * @memberof utils/source-editor
+ */
+ createDocument(): Object {
+ 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: number, align: AlignOpts = "top"): void {
+ 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: number): void {
+ 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..044ed5b3fa
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/source-search.js
@@ -0,0 +1,350 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import buildQuery from "../build-query";
+
+import type { SearchModifiers } from "../../types";
+
+/**
+ * @memberof utils/source-search
+ * @static
+ */
+function getSearchCursor(cm, query: string, pos, modifiers: SearchModifiers) {
+ const regexQuery = buildQuery(query, modifiers, { isGlobal: true });
+ return cm.getSearchCursor(regexQuery, pos);
+}
+
+/**
+ * @memberof utils/source-search
+ * @static
+ */
+function SearchState(): void {
+ this.posFrom = this.posTo = this.query = null;
+ this.overlay = null;
+ this.results = [];
+}
+
+/**
+ * @memberof utils/source-search
+ * @static
+ */
+function getSearchState(cm: any, query: string) {
+ const state = cm.state.search || (cm.state.search = new SearchState());
+ return state;
+}
+
+function isWhitespace(query): boolean {
+ return !query.match(/\S/);
+}
+
+/**
+ * This returns a mode object used by CoeMirror'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/doc/manual.html#modeapi
+ *
+ * Also the token function code is mainly based of work done
+ * by the chrome devtools team. Thanks guys! :)
+ *
+ * @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";
+ } else 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();
+ }
+ },
+ };
+}
+
+/**
+ * @memberof utils/source-search
+ * @static
+ */
+function updateOverlay(cm, state, query, modifiers): void {
+ cm.removeOverlay(state.overlay);
+ state.overlay = searchOverlay(query, modifiers);
+ cm.addOverlay(state.overlay, { opaque: false });
+}
+
+function updateCursor(cm, state, keepSelection): void {
+ 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: number,
+ currentIndex: number,
+ rev: boolean
+): number {
+ 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: SearchModifiers,
+ focusFirstResult?: boolean = true
+) {
+ const { cm, ed } = ctx;
+ if (!cm) {
+ return;
+ }
+ const defaultIndex = { line: -1, ch: -1 };
+
+ return cm.operation(function() {
+ if (!query || isWhitespace(query)) {
+ clearSearch(cm, query);
+ return;
+ }
+
+ 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: Object,
+ rev: boolean,
+ query: string,
+ keepSelection: boolean,
+ modifiers: SearchModifiers,
+ line: number,
+ ch: number
+) {
+ const { cm } = ctx;
+ if (!cm) {
+ return;
+ }
+
+ 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): void {
+ 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: any, query: string): void {
+ 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: any, query: string): void {
+ 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: any,
+ query: string,
+ keepSelection: boolean,
+ modifiers: SearchModifiers,
+ focusFirstResult?: boolean
+) {
+ 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: any,
+ query: string,
+ keepSelection: boolean,
+ modifiers: SearchModifiers
+) {
+ 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: any,
+ query: string,
+ keepSelection: boolean,
+ modifiers: SearchModifiers
+) {
+ 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..b7a6f79305
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/tests/create-editor.spec.js
@@ -0,0 +1,27 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { 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..ac47d67dbe
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/tests/editor.spec.js
@@ -0,0 +1,228 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import {
+ traverseResults,
+ toEditorLine,
+ toEditorPosition,
+ toEditorRange,
+ toSourceLine,
+ scrollToColumn,
+ markText,
+ lineAtHeight,
+ getSourceLocationFromMouseEvent,
+ forEachLine,
+ removeLineClass,
+ clearLineClass,
+ getTextForLine,
+ getCursorLine,
+} from "../index";
+
+import { makeMockSource } from "../../test-mockup";
+
+describe("traverseResults", () => {
+ const e: any = { stopPropagation: jest.fn(), preventDefault: jest.fn() };
+ const ctx = {};
+ const query = "Awesome books";
+ const modifiers = {
+ caseSensitive: false,
+ regexMatch: false,
+ wholeWord: false,
+ };
+ it("traverses next", () => {
+ traverseResults(e, ctx, query, "next", modifiers);
+ expect(e.stopPropagation).toHaveBeenCalled();
+ expect(e.preventDefault).toHaveBeenCalled();
+ // expect(findNext).toHaveBeenCalledWith(ctx, query, true, modifiers);
+ });
+
+ it("traverses previous", () => {
+ e.stopPropagation.mockClear();
+ e.preventDefault.mockClear();
+ traverseResults(e, ctx, query, "prev", modifiers);
+ expect(e.stopPropagation).toHaveBeenCalled();
+ expect(e.preventDefault).toHaveBeenCalled();
+ // expect(findPrev).toHaveBeenCalledWith(ctx, query, true, modifiers);
+ });
+});
+
+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 = { sourceId: "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: { line: 100, column: 25 },
+ end: { 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: any = { 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: any = { clientX: 30, clientY: 60 };
+ expect(getSourceLocationFromMouseEvent(editor, source, e)).toEqual({
+ sourceId: "test-123",
+ line: 7,
+ column: 31,
+ });
+ 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,
+ "wrapClass",
+ 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..9ffe67b436
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/tests/get-expression.spec.js
@@ -0,0 +1,162 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import CodeMirror from "codemirror";
+import { getExpressionFromCoords } from "../get-expression";
+
+describe("get-expression", () => {
+ let isCreateTextRangeDefined;
+
+ beforeAll(() => {
+ if ((document.body: any).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: any).createTextRange = () => ({
+ getBoundingClientRect: jest.fn(),
+ getClientRects: () => ({}),
+ });
+ }
+ });
+
+ afterAll(() => {
+ if (!isCreateTextRangeDefined) {
+ delete (document.body: any).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..761a6ef838
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/tests/get-token-location.spec.js
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { getTokenLocation } from "../get-token-location";
+
+describe("getTokenLocation", () => {
+ const codemirror = {
+ coordsChar: jest.fn(() => ({
+ line: 1,
+ ch: "C",
+ })),
+ };
+ const token: any = {
+ 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-search.spec.js b/devtools/client/debugger/src/utils/editor/tests/source-search.spec.js
new file mode 100644
index 0000000000..922a301790
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/tests/source-search.spec.js
@@ -0,0 +1,184 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+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..700f7285a3
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/token-events.js
@@ -0,0 +1,94 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { getTokenLocation } from ".";
+import { isEqual } from "lodash";
+
+function isInvalidTarget(target: HTMLElement): boolean {
+ 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: ?HTMLElement) {
+ if (!target || target.closest(".popover")) {
+ return true;
+ }
+
+ return false;
+}
+
+export function onMouseOver(codeMirror: any): any {
+ let prevTokenPos = null;
+
+ function onMouseLeave(event) {
+ if (invalidLeaveTarget(event.relatedTarget)) {
+ return addMouseLeave(event.target);
+ }
+
+ prevTokenPos = null;
+ dispatch(codeMirror, "tokenleave", event);
+ }
+
+ function addMouseLeave(target) {
+ target.addEventListener("mouseleave", onMouseLeave, {
+ capture: true,
+ once: true,
+ });
+ }
+
+ return (enterEvent: any) => {
+ const { target } = enterEvent;
+
+ if (isInvalidTarget(target)) {
+ return;
+ }
+
+ const tokenPos = getTokenLocation(codeMirror, target);
+
+ if (!isEqual(prevTokenPos, tokenPos)) {
+ addMouseLeave(target);
+
+ dispatch(codeMirror, "tokenenter", {
+ event: enterEvent,
+ target,
+ tokenPos,
+ });
+ prevTokenPos = tokenPos;
+ }
+ };
+}
diff --git a/devtools/client/debugger/src/utils/editor/types.js b/devtools/client/debugger/src/utils/editor/types.js
new file mode 100644
index 0000000000..b72f4914c7
--- /dev/null
+++ b/devtools/client/debugger/src/utils/editor/types.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/>. */
+
+// @flow
+
+export type EditorPosition = { line: number, column: number };
+
+export type EditorRange = { end: EditorPosition, start: EditorPosition };
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..8f2a59cd22
--- /dev/null
+++ b/devtools/client/debugger/src/utils/evaluation-result.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/>. */
+
+// @flow
+
+import type {
+ Grip,
+ ObjectFront,
+ LongStringFront,
+ ExpressionResult,
+} from "../client/firefox/types";
+
+function isFront(result: ExpressionResult): boolean %checks {
+ return !!result && typeof result === "object" && !!result.getGrip;
+}
+
+export function getGrip(
+ result: ExpressionResult
+): Grip | string | number | null {
+ if (isFront(result)) {
+ return result.getGrip();
+ }
+
+ return result;
+}
+
+export function getFront(
+ result: ExpressionResult
+): ObjectFront | LongStringFront | null {
+ 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..bfc5a0a9e0
--- /dev/null
+++ b/devtools/client/debugger/src/utils/expressions.js
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { correctIndentation } from "./indentation";
+import { getGrip } from "./evaluation-result";
+import type { Expression } from "../types";
+import type { Grip } from "../client/firefox/types";
+
+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: string): string {
+ return correctIndentation(`
+ try {
+ ${input}
+ } catch (e) {
+ e
+ }
+ `);
+}
+
+function isUnavailable(value): boolean {
+ return (
+ value &&
+ !!value.isError &&
+ (value.class === "ReferenceError" || value.class === "TypeError")
+ );
+}
+
+export function getValue(
+ expression: Expression
+): Grip | string | number | null | Object {
+ const { value, exception, error } = expression;
+
+ if (error) {
+ return error;
+ }
+
+ if (!value) {
+ return UNAVAILABLE_GRIP;
+ }
+
+ if (exception) {
+ if (isUnavailable(exception)) {
+ return UNAVAILABLE_GRIP;
+ }
+ return exception;
+ }
+
+ const valueGrip = getGrip(value.result);
+
+ if (valueGrip && typeof valueGrip === "object" && valueGrip.isError) {
+ if (isUnavailable(valueGrip)) {
+ return UNAVAILABLE_GRIP;
+ }
+
+ const { name, message } = valueGrip.preview;
+ return `${name}: ${message}`;
+ }
+
+ return valueGrip;
+}
diff --git a/devtools/client/debugger/src/utils/function.js b/devtools/client/debugger/src/utils/function.js
new file mode 100644
index 0000000000..a32bd95fac
--- /dev/null
+++ b/devtools/client/debugger/src/utils/function.js
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+import { isFulfilled } from "./async-value";
+import { findClosestFunction } from "./ast";
+import { correctIndentation } from "./indentation";
+import type { SourceWithContent } from "../types";
+import type { Symbols } from "../reducers/ast";
+
+export function findFunctionText(
+ line: number,
+ source: SourceWithContent,
+ symbols: ?Symbols
+): ?string {
+ const func = findClosestFunction(symbols, {
+ sourceId: source.id,
+ line,
+ column: Infinity,
+ });
+
+ if (
+ source.isWasm ||
+ !func ||
+ !source.content ||
+ !isFulfilled(source.content) ||
+ source.content.value.type !== "text"
+ ) {
+ return null;
+ }
+
+ const {
+ location: { start, end },
+ } = func;
+ const lines = source.content.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..5a773c28bb
--- /dev/null
+++ b/devtools/client/debugger/src/utils/indentation.js
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+export function getIndentation(line: ?string): number {
+ if (!line) {
+ return 0;
+ }
+
+ const lineMatch = line.match(/^\s*/);
+ if (!lineMatch) {
+ return 0;
+ }
+
+ return lineMatch[0].length;
+}
+
+function getMaxIndentation(lines: string[]): number {
+ 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: string): string {
+ 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..1a80bdea6a
--- /dev/null
+++ b/devtools/client/debugger/src/utils/isMinified.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/>. */
+
+// @flow
+
+import type { SourceWithContent } from "../types";
+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: SourceWithContent) {
+ if (_minifiedCache.has(source.id)) {
+ return _minifiedCache.get(source.id);
+ }
+
+ if (
+ !source.content ||
+ !isFulfilled(source.content) ||
+ source.content.value.type !== "text"
+ ) {
+ return false;
+ }
+
+ let text = source.content.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..bcc1b7acb2
--- /dev/null
+++ b/devtools/client/debugger/src/utils/location.js
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+import { sortBy } from "lodash";
+import { getSelectedLocation } from "./selected-location";
+
+import type {
+ MappedLocation,
+ PartialPosition,
+ SourceLocation,
+ SourceId,
+ Source,
+} from "../types";
+
+type IncompleteLocation = {
+ sourceId: SourceId,
+ line?: number,
+ column?: number,
+ sourceUrl?: string,
+};
+
+export function comparePosition(a: ?PartialPosition, b: ?PartialPosition) {
+ return a && b && a.line == b.line && a.column == b.column;
+}
+
+export function createLocation({
+ sourceId,
+ // Line 0 represents no specific line chosen for action
+ line = 0,
+ column,
+ sourceUrl = "",
+}: IncompleteLocation): SourceLocation {
+ return {
+ sourceId,
+ line,
+ column,
+ sourceUrl,
+ };
+}
+
+export function sortSelectedLocations<T: MappedLocation>(
+ locations: $ReadOnlyArray<T>,
+ selectedSource: ?Source
+): Array<T> {
+ return sortBy(locations, [
+ // Priority: line number, undefined column, column number
+ location => getSelectedLocation(location, selectedSource).line,
+ location => {
+ const selectedLocation = getSelectedLocation(location, selectedSource);
+ return selectedLocation.column === undefined || selectedLocation.column;
+ },
+ ]);
+}
diff --git a/devtools/client/debugger/src/utils/log.js b/devtools/client/debugger/src/utils/log.js
new file mode 100644
index 0000000000..4aca6cc357
--- /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/>. */
+
+/* @flow */
+
+/**
+ *
+ * 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: any[]): void {
+ 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..1ec909d9e5
--- /dev/null
+++ b/devtools/client/debugger/src/utils/memoizableAction.js
@@ -0,0 +1,95 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { ThunkArgs } from "../actions/types";
+import { asSettled, type AsyncValue } from "./async-value";
+import { validateContext } from "./context";
+import type { Context } from "./context";
+
+type ArgsWithContext = { cx: Context };
+
+export type MemoizedAction<
+ Args,
+ Result
+> = Args => ThunkArgs => Promise<Result | null>;
+
+type MemoizableActionParams<Args, Result> = {
+ getValue: (args: Args, thunkArgs: ThunkArgs) => AsyncValue<Result> | null,
+ createKey: (args: Args, thunkArgs: ThunkArgs) => string,
+ action: (args: Args, thunkArgs: ThunkArgs) => Promise<mixed>,
+};
+
+/*
+ * 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<Args: ArgsWithContext, Result>(
+ name: string,
+ { getValue, createKey, action }: MemoizableActionParams<Args, Result>
+): MemoizedAction<Args, Result> {
+ 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..434a2894b5
--- /dev/null
+++ b/devtools/client/debugger/src/utils/memoize.js
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+type Value = any;
+type Key = any;
+type Store = WeakMap<Key, Store | Value>;
+
+function hasValue(keys: Key[], store: Store): boolean {
+ let currentStore = store;
+ for (const key of keys) {
+ if (!currentStore || !currentStore.has(key)) {
+ return false;
+ }
+
+ currentStore = currentStore.get(key);
+ }
+ return true;
+}
+
+function getValue(keys: Key[], store: Store): Value {
+ let currentStore = store;
+ for (const key of keys) {
+ if (!currentStore) {
+ return null;
+ }
+ currentStore = currentStore.get(key);
+ }
+
+ return currentStore;
+}
+
+function setValue(keys: Key[], store: Store, value: Value): void {
+ 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: Function): Value {
+ const store = new WeakMap();
+
+ return function(...keys: Key[]) {
+ 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..c0d9241932
--- /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: any[];
+ let lastResult: any;
+
+ 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..a13d942073
--- /dev/null
+++ b/devtools/client/debugger/src/utils/moz.build
@@ -0,0 +1,57 @@
+# 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",
+ "resource",
+ "sources-tree",
+]
+
+CompiledModules(
+ "assert.js",
+ "ast.js",
+ "async-value.js",
+ "bootstrap.js",
+ "build-query.js",
+ "clipboard.js",
+ "connect.js",
+ "context.js",
+ "dbg.js",
+ "defer.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",
+ "project-search.js",
+ "quick-open.js",
+ "result-list.js",
+ "selected-location.js",
+ "source-maps.js",
+ "source-queue.js",
+ "source.js",
+ "tabs.js",
+ "task.js",
+ "telemetry.js",
+ "text.js",
+ "timings.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..dc074dd364
--- /dev/null
+++ b/devtools/client/debugger/src/utils/path.js
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+export function basename(path: string): string {
+ return path.split("/").pop();
+}
+
+export function dirname(path: string): string {
+ const idx = path.lastIndexOf("/");
+ return path.slice(0, idx);
+}
+
+export function isURL(str: string): boolean {
+ return str.includes("://");
+}
+
+export function isAbsolute(str: string): boolean {
+ return str[0] === "/";
+}
+
+export function join(base: string, dir: string): string {
+ 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..bd4114c5ab
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/annotateFrames.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/>. */
+
+// @flow
+
+import { flatMap, zip, range } from "lodash";
+
+import type { Frame } from "../../../types";
+import { getFrameUrl } from "./getFrameUrl";
+import { getLibraryFromUrl } from "./getLibraryFromUrl";
+
+type AnnotatedFrame =
+ | {|
+ ...Frame,
+ library: string,
+ |}
+ | Frame;
+
+export function annotateFrames(frames: Frame[]): AnnotatedFrame[] {
+ const annotatedFrames = frames.map(f => annotateFrame(f, frames));
+ return annotateBabelAsyncFrames(annotatedFrames);
+}
+
+function annotateFrame(frame: Frame, frames: Frame[]): AnnotatedFrame {
+ const library = getLibraryFromUrl(frame, frames);
+ if (library) {
+ return { ...frame, library };
+ }
+
+ return frame;
+}
+
+function annotateBabelAsyncFrames(frames: Frame[]): Frame[] {
+ const babelFrameIndexes = getBabelFrameIndexes(frames);
+ const isBabelFrame = frameIndex => babelFrameIndexes.includes(frameIndex);
+
+ return frames.map((frame, frameIndex) =>
+ isBabelFrame(frameIndex) ? { ...frame, library: "Babel" } : frame
+ );
+}
+
+// Receives an array of frames and looks for babel async
+// call stack groups.
+function getBabelFrameIndexes(frames) {
+ const startIndexes = frames.reduce((accumulator, frame, index) => {
+ if (
+ getFrameUrl(frame).match(/regenerator-runtime/i) &&
+ frame.displayName === "tryCatch"
+ ) {
+ return [...accumulator, index];
+ }
+ return accumulator;
+ }, []);
+
+ const endIndexes = frames.reduce((accumulator, frame, index) => {
+ if (
+ getFrameUrl(frame).match(/_microtask/i) &&
+ frame.displayName === "flush"
+ ) {
+ return [...accumulator, index];
+ }
+ if (frame.displayName === "_asyncToGenerator/<") {
+ return [...accumulator, index + 1];
+ }
+ return accumulator;
+ }, []);
+
+ if (startIndexes.length != endIndexes.length || startIndexes.length === 0) {
+ return frames;
+ }
+
+ // Receives an array of start and end index tuples and returns
+ // an array of async call stack index ranges.
+ // e.g. [[1,3], [5,7]] => [[1,2,3], [5,6,7]]
+ // $FlowIgnore
+ return flatMap(zip(startIndexes, endIndexes), ([startIndex, endIndex]) =>
+ range(startIndex, endIndex + 1)
+ );
+}
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..d39a26d574
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/collapseFrames.js
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { findIndex } from "lodash";
+
+// eslint-disable-next-line max-len
+import type { Frame } from "../../../types";
+import { getFrameUrl } from "./getFrameUrl";
+
+function collapseLastFrames(frames) {
+ const index = findIndex(frames, 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 };
+}
+
+type FrameGroup = Frame[];
+type GroupedFrames = Array<FrameGroup | Frame>;
+
+export function collapseFrames(frames: Frame[]): GroupedFrames {
+ // 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..dbd53d53cd
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/displayName.js
@@ -0,0 +1,108 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+// eslint-disable-next-line max-len
+import type { Frame } from "../../../types";
+
+// 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: string | void): string | void {
+ // 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: Frame): string {
+ const {
+ displayName,
+ originalDisplayName,
+ userDisplayName,
+ name,
+ } = (frame: any);
+ return originalDisplayName || userDisplayName || displayName || name;
+}
+
+type formatDisplayNameParams = {
+ shouldMapDisplayName: boolean,
+};
+export function formatDisplayName(
+ frame: Frame,
+ { shouldMapDisplayName = true }: formatDisplayNameParams = {},
+ l10n: typeof L10N
+): string {
+ const { library } = frame;
+ let displayName = getFrameDisplayName(frame);
+ if (library && shouldMapDisplayName) {
+ displayName = mapDisplayNames(frame, library);
+ }
+
+ return simplifyDisplayName(displayName) || l10n.getStr("anonymousFunction");
+}
+
+export function formatCopyName(frame: Frame, l10n: typeof L10N): string {
+ 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..6a151e14f7
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/getFrameUrl.js
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { Frame } from "../../../types";
+
+export function getFrameUrl(frame: 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..232079e3ec
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/getLibraryFromUrl.js
@@ -0,0 +1,149 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { Frame } from "../../../types";
+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: Frame,
+ callStack: Array<Frame> = []
+): ?string | void {
+ // @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..68d214162c
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/index.js
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+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..752f470e2f
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/tests/annotateFrames.spec.js
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+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..a362885e1d
--- /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/>. */
+
+// @flow
+
+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: any) };
+ 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"]],
+ };
+
+ 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..afb8d7afc2
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/frames/tests/getLibraryFromUrl.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/>. */
+
+// @flow
+
+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..a2dfcb8e5a
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/index.js
@@ -0,0 +1,7 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+export * 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..d8038c931b
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/buildGeneratedBindingList.js
@@ -0,0 +1,155 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { has } from "lodash";
+import type { SourceScope, BindingLocation } from "../../../workers/parser";
+import type { Scope, BindingContents } from "../../../types";
+import { clientCommands } from "../../../client/firefox";
+
+import { locColumn } from "./locColumn";
+import { getOptimizedOutGrip } from "./optimizedOut";
+
+export type GeneratedBindingLocation = {
+ name: string,
+ loc: BindingLocation,
+ desc: () => Promise<BindingContents | null>,
+};
+
+export function buildGeneratedBindingList(
+ scopes: Scope,
+ generatedAstScopes: SourceScope[],
+ thisBinding: ?BindingContents
+): Array<GeneratedBindingLocation> {
+ // 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 => has(b, name));
+
+ 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: SourceScope[]
+): Array<GeneratedBindingLocation> {
+ // 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: Array<GeneratedBindingLocation>) {
+ 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..a5af8e5583
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/filtering.js
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+function findInsertionLocation<T>(
+ array: Array<T>,
+ callback: T => number
+): number {
+ 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<T>(
+ array: Array<T>,
+ callback: T => number
+): Array<T> {
+ 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..fec33dd57d
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/findGeneratedBindingFromPosition.js
@@ -0,0 +1,329 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { locColumn } from "./locColumn";
+import { mappingContains } from "./mappingContains";
+
+import type { BindingContents } from "../../../types";
+// eslint-disable-next-line max-len
+import type { ApplicableBinding } from "./getApplicableBindingsForOriginalPosition";
+
+import { clientCommands } from "../../../client/firefox";
+
+export type GeneratedDescriptor = {
+ name: string,
+ // Falsy if the binding itself matched a location, but the location didn't
+ // have a value descriptor attached. Happens if the binding was 'this'
+ // or if there was a mismatch between client and generated scopes.
+ desc: ?BindingContents,
+
+ expression: string,
+};
+
+/**
+ * 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: Array<ApplicableBinding>
+): Promise<GeneratedDescriptor | null> {
+ // 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: Array<ApplicableBinding>
+): Promise<GeneratedDescriptor | null> {
+ // 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: Array<ApplicableBinding>,
+ importName: string
+): Promise<GeneratedDescriptor | null> {
+ // 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,
+}: ApplicableBinding): Promise<GeneratedDescriptor | null> {
+ // 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,
+}: ApplicableBinding): Promise<GeneratedDescriptor | null> {
+ 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: ?BindingContents) {
+ return desc && (!desc.value || typeof desc.value !== "object");
+}
+function isObjectValue(desc: ?BindingContents) {
+ 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: ?BindingContents,
+ property: string
+): Promise<?BindingContents> {
+ 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..1542b6bfd3
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/getApplicableBindingsForOriginalPosition.js
@@ -0,0 +1,150 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import typeof SourceMaps from "devtools-source-map";
+
+import type { BindingLocationType, BindingType } from "../../../workers/parser";
+import { positionCmp } from "./positionCmp";
+import { filterSortedArray } from "./filtering";
+import { mappingContains } from "./mappingContains";
+
+import type { Source, SourceLocation, PartialPosition } from "../../../types";
+
+import type { GeneratedBindingLocation } from "./buildGeneratedBindingList";
+
+export type ApplicableBinding = {
+ binding: GeneratedBindingLocation,
+ range: GeneratedRange,
+ firstInRange: boolean,
+ firstOnLine: boolean,
+};
+
+type GeneratedRange = {
+ start: PartialPosition,
+ end: PartialPosition,
+};
+
+export async function originalRangeStartsInside(
+ source: Source,
+ {
+ start,
+ end,
+ }: {
+ start: SourceLocation,
+ end: SourceLocation,
+ },
+ sourceMaps: SourceMaps
+) {
+ const endPosition = await sourceMaps.getGeneratedLocation(end);
+ const startPosition = await sourceMaps.getGeneratedLocation(start);
+
+ // 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: Array<GeneratedBindingLocation>,
+ source: Source,
+ {
+ start,
+ end,
+ }: {
+ start: SourceLocation,
+ end: SourceLocation,
+ },
+ bindingType: BindingType,
+ locationType: BindingLocationType,
+ sourceMaps: SourceMaps
+): Promise<Array<ApplicableBinding>> {
+ const ranges = await sourceMaps.getGeneratedRanges(start);
+
+ const resultRanges: GeneratedRange[] = 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 sourceMaps.getGeneratedLocation(end);
+ const startPosition = await sourceMaps.getGeneratedLocation(start);
+
+ 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: Array<GeneratedBindingLocation>,
+ ranges: Array<GeneratedRange>
+): Array<ApplicableBinding> {
+ 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..fa94d7b944
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/index.js
@@ -0,0 +1,589 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import typeof SourceMaps from "devtools-source-map";
+
+import {
+ type SourceScope,
+ type BindingData,
+ type BindingLocation,
+} from "../../../workers/parser";
+import type { RenderableScope } from "../scopes/getScope";
+import { locColumn } from "./locColumn";
+import {
+ loadRangeMetadata,
+ findMatchingRange,
+ type MappedOriginalRange,
+} from "./rangeMetadata";
+
+// eslint-disable-next-line max-len
+import {
+ findGeneratedReference,
+ findGeneratedImportReference,
+ findGeneratedImportDeclaration,
+ type GeneratedDescriptor,
+} from "./findGeneratedBindingFromPosition";
+import {
+ buildGeneratedBindingList,
+ buildFakeBindingList,
+ type GeneratedBindingLocation,
+} from "./buildGeneratedBindingList";
+import {
+ originalRangeStartsInside,
+ getApplicableBindingsForOriginalPosition,
+} from "./getApplicableBindingsForOriginalPosition";
+import { getOptimizedOutGrip } from "./optimizedOut";
+
+import { log } from "../../log";
+import type { ThunkArgs } from "../../../actions/types";
+
+import type {
+ PartialPosition,
+ Scope,
+ Source,
+ SourceContent,
+ Frame,
+ BindingContents,
+ ScopeBindings,
+ MappedLocation,
+} from "../../../types";
+
+export type OriginalScope = RenderableScope;
+export type MappedFrameLocation = MappedLocation & {
+ this?: $ElementType<Frame, "this">,
+};
+
+export async function buildMappedScopes(
+ source: Source,
+ content: SourceContent,
+ frame: MappedFrameLocation,
+ scopes: ?Scope,
+ { client, parser, sourceMaps }: ThunkArgs
+): Promise<?{
+ mappings: {
+ [string]: string,
+ },
+ scope: OriginalScope,
+}> {
+ const originalAstScopes = await parser.getScopes(frame.location);
+ const generatedAstScopes = await parser.getScopes(frame.generatedLocation);
+
+ if (!originalAstScopes || !generatedAstScopes) {
+ return null;
+ }
+
+ const originalRanges = await loadRangeMetadata(
+ frame.location,
+ originalAstScopes,
+ sourceMaps
+ );
+
+ 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,
+ client,
+ sourceMaps
+ );
+
+ const globalLexicalScope = scopes
+ ? getGlobalFromScope(scopes)
+ : generateGlobalFromAst(generatedAstScopes);
+ const mappedGeneratedScopes = generateClientScope(
+ globalLexicalScope,
+ mappedOriginalScopes
+ );
+
+ return isReliableScope(mappedGeneratedScopes)
+ ? { mappings: expressionLookup, scope: mappedGeneratedScopes }
+ : null;
+}
+
+async function mapOriginalBindingsToGenerated(
+ source: Source,
+ content: SourceContent,
+ originalRanges: Array<MappedOriginalRange>,
+ originalAstScopes,
+ generatedAstBindings,
+ client,
+ sourceMaps
+) {
+ const expressionLookup = {};
+ const mappedOriginalScopes = [];
+
+ const cachedSourceMaps = batchScopeMappings(
+ originalAstScopes,
+ source,
+ sourceMaps
+ );
+
+ for (const item of originalAstScopes) {
+ const generatedBindings = {};
+
+ for (const name of Object.keys(item.bindings)) {
+ const binding = item.bindings[name];
+
+ const result = await findGeneratedBinding(
+ cachedSourceMaps,
+ client,
+ source,
+ content,
+ name,
+ binding,
+ originalRanges,
+ generatedAstBindings
+ );
+
+ 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: OriginalScope): boolean {
+ 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): boolean {
+ return ranges.every(
+ range => range.columnStart === 0 && range.columnEnd === Infinity
+ );
+}
+
+function batchScopeMappings(
+ originalAstScopes: Array<SourceScope>,
+ source: Source,
+ sourceMaps: SourceMaps
+) {
+ 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),
+ sourceMaps.getGeneratedRanges(loc.start)
+ );
+ precalculatedLocations.set(
+ buildLocationKey(loc.start),
+ sourceMaps.getGeneratedLocation(loc.start)
+ );
+ precalculatedLocations.set(
+ buildLocationKey(loc.end),
+ sourceMaps.getGeneratedLocation(loc.end)
+ );
+ }
+ }
+ }
+ }
+
+ return {
+ async getGeneratedRanges(pos) {
+ const key = buildLocationKey(pos);
+
+ if (!precalculatedRanges.has(key)) {
+ log("Bad precalculated mapping");
+ return sourceMaps.getGeneratedRanges(pos);
+ }
+ return precalculatedRanges.get(key);
+ },
+ async getGeneratedLocation(pos) {
+ const key = buildLocationKey(pos);
+
+ if (!precalculatedLocations.has(key)) {
+ log("Bad precalculated mapping");
+ return sourceMaps.getGeneratedLocation(pos);
+ }
+ return precalculatedLocations.get(key);
+ },
+ };
+}
+function buildLocationKey(loc: PartialPosition): string {
+ return `${loc.line}:${locColumn(loc)}`;
+}
+
+function generateClientScope(
+ globalLexicalScope: OriginalScope,
+ originalScopes: Array<SourceScope & { generatedBindings: ScopeBindings }>
+): OriginalScope {
+ // 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): OriginalScope => {
+ 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: Scope): OriginalScope {
+ // 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: ?OriginalScope = null;
+ for (let s = scopes; s.parent; s = s.parent) {
+ // $FlowIgnore - Flow doesn't like casting 'parent'.
+ globalLexicalScope = s;
+ }
+ if (!globalLexicalScope) {
+ throw new Error("Assertion failure - there should always be a scope");
+ }
+ return globalLexicalScope;
+}
+
+function generateGlobalFromAst(
+ generatedScopes: Array<SourceScope>
+): OriginalScope {
+ 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()])
+ ),
+ },
+ // $FlowIgnore - Flow doesn't like casting 'parent'.
+ parent: {
+ actor: "generatedActor0",
+ object: getOptimizedOutGrip(),
+ scopeKind: "",
+ type: "object",
+ },
+ };
+}
+
+function hasValidIdent(
+ range: MappedOriginalRange,
+ pos: BindingLocation
+): boolean {
+ 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(
+ sourceMaps: any,
+ client: any,
+ source: Source,
+ content: SourceContent,
+ name: string,
+ originalBinding: BindingData,
+ originalRanges: Array<MappedOriginalRange>,
+ generatedAstBindings: Array<GeneratedBindingLocation>
+): Promise<?{
+ grip: BindingContents,
+ expression: string | null,
+}> {
+ // 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,
+ sourceMaps
+ );
+ if (applicableBindings.length > 0) {
+ 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(source, pos, sourceMaps))
+ ) {
+ applicableBindings = [];
+ }
+ return applicableBindings;
+ };
+
+ const { refs } = originalBinding;
+
+ let hadApplicableBindings = false;
+ let genContent: GeneratedDescriptor | null = 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..35f5061dd3
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/locColumn.js
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { PartialPosition } from "../../../types";
+
+export function locColumn(loc: PartialPosition): number {
+ 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..8147fb1354
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/mappingContains.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/>. */
+
+// @flow
+
+import type { PartialPosition } from "../../../types";
+import { positionCmp } from "./positionCmp";
+
+export function mappingContains(
+ mapped: { +start: PartialPosition, +end: PartialPosition },
+ item: { +start: PartialPosition, +end: PartialPosition }
+): boolean {
+ 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..a1f7dfcf10
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/optimizedOut.js
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { BindingContents } from "../../../types";
+
+export function getOptimizedOutGrip(): BindingContents {
+ 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..683ebf5b53
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/positionCmp.js
@@ -0,0 +1,27 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { PartialPosition } from "../../../types";
+import { locColumn } from "./locColumn";
+
+/**
+ * * === 0 - Positions are equal.
+ * * < 0 - first position before second position
+ * * > 0 - first position after second position
+ */
+export function positionCmp(p1: PartialPosition, p2: PartialPosition): number {
+ 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..bbd13ab588
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/rangeMetadata.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/>. */
+
+// @flow
+
+import typeof SourceMaps from "devtools-source-map";
+
+import { locColumn } from "./locColumn";
+import { positionCmp } from "./positionCmp";
+import { filterSortedArray } from "./filtering";
+
+import type { SourceScope } from "../../../workers/parser";
+import type { PartialPosition, SourceLocation } from "../../../types";
+
+type SourceOriginalRange = {
+ line: number,
+ columnStart: number,
+ columnEnd: number,
+};
+
+// * 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
+type MappedOriginalRangeType = "match" | "contains" | "multiple" | "empty";
+export type MappedOriginalRange = {
+ type: MappedOriginalRangeType,
+ singleDeclaration: boolean,
+ line: number,
+ columnStart: number,
+ columnEnd: number,
+};
+
+export async function loadRangeMetadata(
+ location: SourceLocation,
+ originalAstScopes: Array<SourceScope>,
+ sourceMaps: SourceMaps
+): Promise<Array<MappedOriginalRange>> {
+ const originalRanges: Array<SourceOriginalRange> = await sourceMaps.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: Array<MappedOriginalRange>,
+ bindingRange: { +end: PartialPosition, +start: PartialPosition }
+): ?MappedOriginalRange {
+ 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..fae8a6bdf6
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/scopes/getScope.js
@@ -0,0 +1,131 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+// $FlowIgnore
+import { objectInspector } from "devtools/client/shared/components/reps/index";
+import { getBindingVariables } from "./getVariables";
+import { getFramePopVariables, getThisVariable } from "./utils";
+import { simplifyDisplayName } from "../../pause/frames";
+
+import type { Frame, Why, Scope } from "../../../types";
+
+import type { NamedValue } from "./types";
+
+export type RenderableScope = {
+ type: $ElementType<Scope, "type">,
+ scopeKind: $ElementType<Scope, "scopeKind">,
+ actor: $ElementType<Scope, "actor">,
+ bindings: $ElementType<Scope, "bindings">,
+ parent: ?RenderableScope,
+ object?: ?Object,
+ function?: ?{
+ displayName: string,
+ },
+ block?: ?{
+ displayName: string,
+ },
+};
+
+const {
+ utils: {
+ node: { NODE_TYPES },
+ },
+} = objectInspector;
+
+function getScopeTitle(type, scope: RenderableScope): string | void {
+ 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: RenderableScope,
+ selectedFrame: Frame,
+ frameScopes: RenderableScope,
+ why: Why,
+ scopeIndex: number
+): ?NamedValue {
+ 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: RenderableScope,
+ parentScope: RenderableScope,
+ item: NamedValue,
+ parentItem: NamedValue
+): NamedValue | void {
+ if (scope.scopeKind == "function lexical" && parentScope.type == "function") {
+ const contents = (item.contents: any).concat(parentItem.contents);
+ contents.sort((a, b) => a.name.localeCompare(b.name));
+
+ return {
+ name: parentItem.name,
+ path: parentItem.path,
+ contents,
+ type: NODE_TYPES.BLOCK,
+ };
+ }
+}
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..9fd2402a52
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/scopes/getVariables.js
@@ -0,0 +1,48 @@
+/* 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/>. */
+
+// @flow
+import { toPairs } from "lodash";
+
+import type { NamedValue } from "./types";
+import type { BindingContents, ScopeBindings } from "../../../types";
+
+// VarAndBindingsPair actually is [name: string, contents: BindingContents]
+type VarAndBindingsPair = [string, any];
+type VarAndBindingsPairs = Array<VarAndBindingsPair>;
+
+// Scope's bindings field which holds variables and arguments
+type ScopeBindingsWrapper = {
+ variables: ScopeBindings,
+ arguments: BindingContents[],
+};
+
+// Create the tree nodes representing all the variables and arguments
+// for the bindings from a scope.
+export function getBindingVariables(
+ bindings: ?ScopeBindingsWrapper,
+ parentName: string
+): NamedValue[] {
+ if (!bindings) {
+ return [];
+ }
+
+ const args: VarAndBindingsPairs = bindings.arguments.map(
+ arg => toPairs(arg)[0]
+ );
+
+ const variables: VarAndBindingsPairs = toPairs(bindings.variables);
+
+ return args.concat(variables).map(binding => {
+ const name = binding[0];
+ const contents = binding[1];
+ return {
+ name,
+ path: `${parentName}/${name}`,
+ contents,
+ };
+ });
+}
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..2b5ae9efe8
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/scopes/index.js
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { getScope, mergeScopes, type RenderableScope } from "./getScope";
+
+import type { Frame, Why, BindingContents } from "../../../types";
+
+export type NamedValue = {
+ name: string,
+ generatedName?: string,
+ path: string,
+ contents: BindingContents | NamedValue[],
+};
+
+export function getScopes(
+ why: ?Why,
+ selectedFrame: Frame,
+ frameScopes: ?RenderableScope
+): ?(NamedValue[]) {
+ 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..34d5631407
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/scopes/tests/getFramePopVariables.spec.js
@@ -0,0 +1,117 @@
+/* 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/>. */
+
+// @flow
+
+import { getFramePopVariables } from "../utils";
+import type { NamedValue } from "../types";
+
+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: NamedValue) {
+ return (v.contents: any).value;
+}
+
+function getContentsClass(v: NamedValue) {
+ 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..24fedefe45
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/scopes/tests/scopes.spec.js
@@ -0,0 +1,139 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { getScopes } from "..";
+import {
+ makeMockFrame,
+ makeMockScope,
+ makeWhyNormal,
+ makeWhyThrow,
+ mockScopeAddVariable,
+} from "../../../test-mockup";
+
+import type { Scope } from "../../../../types";
+import type { RenderableScope } from "../getScope";
+
+function convertScope(scope: Scope): RenderableScope {
+ return (scope: any);
+}
+
+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/types.js b/devtools/client/debugger/src/utils/pause/scopes/types.js
new file mode 100644
index 0000000000..4c44b4c530
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/scopes/types.js
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { BindingContents } from "../../../types";
+
+export type NamedValue = {
+ name: string,
+ generatedName?: string,
+ path: string,
+ contents: BindingContents | NamedValue[],
+};
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..b9f2924ced
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/scopes/utils.js
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { Why } from "../../../types";
+import type { NamedValue } from "./types";
+
+export function getFramePopVariables(why: Why, path: string): NamedValue[] {
+ const vars: Array<NamedValue> = [];
+
+ 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_: any, path: string): ?NamedValue {
+ 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: Object): string {
+ // 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..116d4c6007
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/why.js
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { Why } from "../../types";
+
+// Map protocol pause "why" reason to a valid L10N key
+// These are the known unhandled reasons:
+// "breakpointConditionThrown", "clientEvaluated"
+// "interrupted", "attached"
+const reasons = {
+ debuggerStatement: "whyPaused.debuggerStatement",
+ breakpoint: "whyPaused.breakpoint",
+ exception: "whyPaused.exception",
+ resumeLimit: "whyPaused.resumeLimit",
+ breakpointConditionThrown: "whyPaused.breakpointConditionThrown",
+ eventBreakpoint: "whyPaused.eventBreakpoint",
+ getWatchpoint: "whyPaused.getWatchpoint",
+ setWatchpoint: "whyPaused.setWatchpoint",
+ mutationBreakpoint: "whyPaused.mutationBreakpoint",
+ interrupted: "whyPaused.interrupted",
+
+ // V8
+ DOM: "whyPaused.breakpoint",
+ EventListener: "whyPaused.pauseOnDOMEvents",
+ XHR: "whyPaused.XHR",
+ promiseRejection: "whyPaused.promiseRejection",
+ assert: "whyPaused.assert",
+ debugCommand: "whyPaused.debugCommand",
+ other: "whyPaused.other",
+};
+
+export function getPauseReason(why?: ?Why): string | null {
+ if (!why) {
+ return null;
+ }
+
+ const reasonType = why.type;
+ if (!reasons[reasonType]) {
+ console.log("Please file an issue: reasonType=", reasonType);
+ }
+
+ return reasons[reasonType];
+}
+
+export function isException(why: Why) {
+ return why?.type === "exception";
+}
+
+export function isInterrupted(why: ?Why) {
+ return why?.type === "interrupted";
+}
+
+export function inDebuggerEval(why: ?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..871f6a2bad
--- /dev/null
+++ b/devtools/client/debugger/src/utils/prefs.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/>. */
+
+// @flow
+
+// $FlowIgnore
+const { PrefsHelper } = require("devtools/client/shared/prefs");
+
+import { isNode } from "./environment";
+import Services from "devtools-services";
+
+// 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.browsertoolbox.fission", false);
+ 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.component-visible", false);
+ pref("devtools.debugger.workers-visible", false);
+ 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.tabsBlackBoxed", "[]");
+ 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.file-search-case-sensitive", false);
+ pref("devtools.debugger.file-search-whole-word", false);
+ pref("devtools.debugger.file-search-regex-match", false);
+ 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.features.workers", true);
+ pref("devtools.debugger.features.async-stepping", false);
+ pref("devtools.debugger.features.wasm", true);
+ pref("devtools.debugger.features.shortcuts", true);
+ pref("devtools.debugger.features.root", true);
+ pref("devtools.debugger.features.map-scopes", true);
+ pref("devtools.debugger.features.remove-command-bar-options", true);
+ pref("devtools.debugger.features.code-folding", false);
+ pref("devtools.debugger.features.command-click", false);
+ pref("devtools.debugger.features.outline", true);
+ pref("devtools.debugger.features.column-breakpoints", true);
+ pref("devtools.debugger.features.skip-pausing", true);
+ 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.xhr-breakpoints", true);
+ pref("devtools.debugger.features.original-blackbox", true);
+ pref("devtools.debugger.features.event-listeners-breakpoints", true);
+ pref("devtools.debugger.features.dom-mutation-breakpoints", true);
+ pref("devtools.debugger.features.log-points", true);
+ pref("devtools.debugger.features.inline-preview", true);
+ pref("devtools.debugger.features.overlay-step-buttons", true);
+ pref("devtools.debugger.features.frame-step", true);
+ pref("devtools.editor.tabsize", 2);
+}
+
+export const prefs = new PrefsHelper("devtools", {
+ fission: ["Bool", "browsertoolbox.fission"],
+ 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"],
+ componentVisible: ["Bool", "debugger.component-visible"],
+ workersVisible: ["Bool", "debugger.workers-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", []],
+ fileSearchCaseSensitive: ["Bool", "debugger.file-search-case-sensitive"],
+ fileSearchWholeWord: ["Bool", "debugger.file-search-whole-word"],
+ fileSearchRegexMatch: ["Bool", "debugger.file-search-regex-match"],
+ 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"],
+});
+
+// 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", {
+ asyncStepping: ["Bool", "async-stepping"],
+ wasm: ["Bool", "wasm"],
+ shortcuts: ["Bool", "shortcuts"],
+ root: ["Bool", "root"],
+ columnBreakpoints: ["Bool", "column-breakpoints"],
+ mapScopes: ["Bool", "map-scopes"],
+ removeCommandBarOptions: ["Bool", "remove-command-bar-options"],
+ workers: ["Bool", "workers"],
+ windowlessWorkers: ["Bool", "windowless-workers"],
+ outline: ["Bool", "outline"],
+ codeFolding: ["Bool", "code-folding"],
+ skipPausing: ["Bool", "skip-pausing"],
+ autocompleteExpression: ["Bool", "autocomplete-expressions"],
+ mapExpressionBindings: ["Bool", "map-expression-bindings"],
+ mapAwaitExpression: ["Bool", "map-await-expression"],
+ componentPane: ["Bool", "component-pane"],
+ xhrBreakpoints: ["Bool", "xhr-breakpoints"],
+ originalBlackbox: ["Bool", "original-blackbox"],
+ eventListenersBreakpoints: ["Bool", "event-listeners-breakpoints"],
+ domMutationBreakpoints: ["Bool", "dom-mutation-breakpoints"],
+ logPoints: ["Bool", "log-points"],
+ commandClick: ["Bool", "command-click"],
+ showOverlay: ["Bool", "overlay"],
+ inlinePreview: ["Bool", "inline-preview"],
+ windowlessServiceWorkers: ["Bool", "windowless-service-workers"],
+ frameStep: ["Bool", "frame-step"],
+});
+
+// Import the asyncStore already spawned by the TargetMixin class
+// $FlowIgnore
+const ThreadUtils = require("devtools/client/shared/thread-utils");
+export const asyncStore = ThreadUtils.asyncStore;
+
+export function resetSchemaVersion(): void {
+ prefs.debuggerPrefsSchemaVersion = prefsSchemaVersion;
+}
+
+export function verifyPrefSchema(): void {
+ if (prefs.debuggerPrefsSchemaVersion < prefsSchemaVersion) {
+ asyncStore.pendingBreakpoints = {};
+ asyncStore.tabs = [];
+ asyncStore.xhrBreakpoints = [];
+ asyncStore.eventListenerBreakpoints = undefined;
+ 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..75325b400e
--- /dev/null
+++ b/devtools/client/debugger/src/utils/preview.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/>. */
+
+// @flow
+
+export function isConsole(expression: string): boolean {
+ return /^console/.test(expression);
+}
diff --git a/devtools/client/debugger/src/utils/project-search.js b/devtools/client/debugger/src/utils/project-search.js
new file mode 100644
index 0000000000..fdea416a88
--- /dev/null
+++ b/devtools/client/debugger/src/utils/project-search.js
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+// Maybe reuse file search's functions?
+
+import React from "react";
+import type { Match } from "../components/ProjectSearch";
+
+export function highlightMatches(lineMatch: Match) {
+ const { value, matchIndex, match } = lineMatch;
+ const len = match.length;
+
+ return (
+ <span className="line-value">
+ <span className="line-match" key={0}>
+ {value.slice(0, matchIndex)}
+ </span>
+ <span className="query-match" key={1}>
+ {value.substr(matchIndex, len)}
+ </span>
+ <span className="line-match" key={2}>
+ {value.slice(matchIndex + len, value.length)}
+ </span>
+ </span>
+ );
+}
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..835244e20a
--- /dev/null
+++ b/devtools/client/debugger/src/utils/quick-open.js
@@ -0,0 +1,159 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+import { endTruncateStr } from "./utils";
+import {
+ isPretty,
+ getFilename,
+ getSourceClassnames,
+ getSourceQueryString,
+} from "./source";
+
+import type { Location as BabelLocation } from "@babel/types";
+import type { Symbols } from "../reducers/ast";
+import type { QuickOpenType } from "../reducers/quick-open";
+import type { Tab } from "../reducers/tabs";
+import type { Source } from "../types";
+import type {
+ SymbolDeclaration,
+ IdentifierDeclaration,
+} from "../workers/parser";
+
+export const MODIFIERS = {
+ "@": "functions",
+ "#": "variables",
+ ":": "goto",
+ "?": "shortcuts",
+};
+
+export function parseQuickOpenQuery(query: string): QuickOpenType {
+ 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: string) {
+ const [, line, column] = query.split(":");
+ const lineNumber = parseInt(line, 10);
+ const columnNumber = parseInt(column, 10);
+ if (!isNaN(lineNumber)) {
+ return {
+ line: lineNumber,
+ ...(!isNaN(columnNumber) ? { column: columnNumber } : null),
+ };
+ }
+}
+
+export function formatSourcesForList(
+ source: Source,
+ tabUrls: Set<$PropertyType<Tab, "url">>
+) {
+ const title = getFilename(source);
+ const relativeUrlWithQuery = `${source.relativeUrl}${getSourceQueryString(
+ source
+ ) || ""}`;
+ const subtitle = endTruncateStr(relativeUrlWithQuery, 100);
+ const value = relativeUrlWithQuery;
+ return {
+ value,
+ title,
+ subtitle,
+ icon: tabUrls.has(source.url)
+ ? "tab result-item-icon"
+ : `result-item-icon ${getSourceClassnames(source)}`,
+ id: source.id,
+ url: source.url,
+ };
+}
+
+export type QuickOpenResult = {|
+ id: string,
+ value: string,
+ title: string | React$Element<"div">,
+ subtitle?: string,
+ location?: BabelLocation,
+ url?: string,
+ icon?: string,
+|};
+
+export type FormattedSymbolDeclarations = {|
+ functions: Array<QuickOpenResult>,
+|};
+
+export function formatSymbol(
+ symbol: SymbolDeclaration | IdentifierDeclaration
+): QuickOpenResult {
+ 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: ?Symbols): FormattedSymbolDeclarations {
+ if (!symbols || symbols.loading) {
+ return { functions: [] };
+ }
+
+ const { functions } = symbols;
+
+ return {
+ functions: functions.map(formatSymbol),
+ };
+}
+
+export function formatShortcutResults(): Array<QuickOpenResult> {
+ 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: ":",
+ },
+ ];
+}
+
+export function formatSources(
+ sources: Source[],
+ tabUrls: Set<$PropertyType<Tab, "url">>
+): Array<QuickOpenResult> {
+ const formattedSources: Array<QuickOpenResult> = [];
+
+ for (let i = 0; i < sources.length; ++i) {
+ const source = sources[i];
+
+ if (!!source.relativeUrl && !isPretty(source)) {
+ formattedSources.push(formatSourcesForList(source, tabUrls));
+ }
+ }
+
+ return formattedSources;
+}
diff --git a/devtools/client/debugger/src/utils/resource/base-query.js b/devtools/client/debugger/src/utils/resource/base-query.js
new file mode 100644
index 0000000000..fda49b0901
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/base-query.js
@@ -0,0 +1,174 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import {
+ getResourceValues,
+ getValidatedResource,
+ makeIdentity,
+ type ResourceBound,
+ type Id,
+ type ResourceState,
+ type ResourceValues,
+ type ResourceIdentity,
+} from "./core";
+import { arrayShallowEqual } from "./compare";
+
+export type ResourceQuery<R: ResourceBound, Args, Reduced> = (
+ ResourceState<R>,
+ Args
+) => Reduced;
+
+export type QueryFilter<R: ResourceBound, Args> = (
+ ResourceValues<R>,
+ Args
+) => Array<Id<R>>;
+
+export type QueryMap<R: ResourceBound, Args, Mapped> =
+ | QueryMapNoArgs<R, Mapped>
+ | QueryMapWithArgs<R, Args, Mapped>;
+export type QueryMapNoArgs<R: ResourceBound, Mapped> = {
+ +needsArgs?: false,
+ (R, ResourceIdentity): Mapped,
+};
+export type QueryMapWithArgs<R: ResourceBound, Args, Mapped> = {
+ +needsArgs: true,
+ (R, ResourceIdentity, Args): Mapped,
+};
+
+export type QueryReduce<R: ResourceBound, Args, Mapped, Reduced> = (
+ $ReadOnlyArray<Mapped>,
+ $ReadOnlyArray<Id<R>>,
+ Args
+) => Reduced;
+
+export type QueryContext<Args> = {
+ args: Args,
+ identMap: WeakMap<ResourceIdentity, ResourceIdentity>,
+};
+export type QueryResult<Mapped, Reduced> = {
+ mapped: Array<Mapped>,
+ reduced: Reduced,
+};
+export type QueryResultCompare<Reduced> = (Reduced, Reduced) => boolean;
+
+export type QueryCacheHandler<R: ResourceBound, Args, Mapped, Reduced> = (
+ ResourceState<R>,
+ QueryContext<Args>,
+ QueryResult<Mapped, Reduced> | null
+) => QueryResult<Mapped, Reduced>;
+
+export type QueryCache<R: ResourceBound, Args, Mapped, Reduced> = (
+ handler: QueryCacheHandler<R, Args, Mapped, Reduced>
+) => ResourceQuery<R, Args, Reduced>;
+
+export function makeMapWithArgs<R: ResourceBound, Args, Mapped>(
+ map: (R, ResourceIdentity, Args) => Mapped
+): QueryMapWithArgs<R, Args, Mapped> {
+ const wrapper = (resource, identity, args) => map(resource, identity, args);
+ wrapper.needsArgs = true;
+ return wrapper;
+}
+
+export function makeResourceQuery<R: ResourceBound, Args, Mapped, Reduced>({
+ cache,
+ filter,
+ map,
+ reduce,
+ resultCompare,
+}: {|
+ cache: QueryCache<R, Args, Mapped, Reduced>,
+ filter: QueryFilter<R, Args>,
+ map: QueryMap<R, Args, Mapped>,
+ reduce: QueryReduce<R, Args, Mapped, Reduced>,
+ resultCompare: QueryResultCompare<Reduced>,
+|}): ResourceQuery<R, Args, Reduced> {
+ const loadResource = makeResourceMapper(map);
+
+ return cache((state, context, existing) => {
+ const ids = filter(getResourceValues(state), context.args);
+ const mapped = ids.map(id => loadResource(state, id, context));
+
+ if (existing && arrayShallowEqual(existing.mapped, mapped)) {
+ // If the items are exactly the same as the existing ones, we return
+ // early to reuse the existing result.
+ return existing;
+ }
+
+ const reduced = reduce(mapped, ids, context.args);
+
+ if (existing && resultCompare(existing.reduced, reduced)) {
+ return existing;
+ }
+
+ return { mapped, reduced };
+ });
+}
+
+type ResourceLoader<R: ResourceBound, Args, Mapped> = (
+ ResourceState<R>,
+ Id<R>,
+ QueryContext<Args>
+) => Mapped;
+
+function makeResourceMapper<R: ResourceBound, Args, Mapped>(
+ map: QueryMap<R, Args, Mapped>
+): ResourceLoader<R, Args, Mapped> {
+ return map.needsArgs
+ ? makeResourceArgsMapper(map)
+ : makeResourceNoArgsMapper(map);
+}
+
+/**
+ * Resources loaded when things care about arguments need to be given a
+ * special ResourceIdentity object that correlates with both the resource
+ * _and_ the arguments being passed to the query. That means they need extra
+ * logic when loading those resources.
+ */
+function makeResourceArgsMapper<R: ResourceBound, Args, Mapped>(
+ map: QueryMapWithArgs<R, Args, Mapped>
+): ResourceLoader<R, Args, Mapped> {
+ const mapper = (value, identity, context) =>
+ map(value, getIdentity(context.identMap, identity), context.args);
+ return (state, id, context) => getCachedResource(state, id, context, mapper);
+}
+
+function makeResourceNoArgsMapper<R: ResourceBound, Args, Mapped>(
+ map: QueryMapNoArgs<R, Mapped>
+): ResourceLoader<R, Args, Mapped> {
+ const mapper = (value, identity, context) => map(value, identity);
+ return (state, id, context) => getCachedResource(state, id, context, mapper);
+}
+
+function getCachedResource<R: ResourceBound, Args, Mapped>(
+ state: ResourceState<R>,
+ id: Id<R>,
+ context: QueryContext<Args>,
+ map: (
+ value: R,
+ identity: ResourceIdentity,
+ context: QueryContext<Args>
+ ) => Mapped
+): Mapped {
+ const validatedState = getValidatedResource(state, id);
+ if (!validatedState) {
+ throw new Error(`Resource ${id} does not exist`);
+ }
+
+ return map(validatedState.values[id], validatedState.identity[id], context);
+}
+
+function getIdentity(
+ identMap: WeakMap<ResourceIdentity, ResourceIdentity>,
+ identity: ResourceIdentity
+) {
+ let ident = identMap.get(identity);
+ if (!ident) {
+ ident = makeIdentity();
+ identMap.set(identity, ident);
+ }
+
+ return ident;
+}
diff --git a/devtools/client/debugger/src/utils/resource/compare.js b/devtools/client/debugger/src/utils/resource/compare.js
new file mode 100644
index 0000000000..5320e1f067
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/compare.js
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+export function strictEqual(value: mixed, other: mixed): boolean {
+ return value === other;
+}
+
+export function shallowEqual(value: mixed, other: mixed): boolean {
+ return (
+ value === other ||
+ (Array.isArray(value) &&
+ Array.isArray(other) &&
+ arrayShallowEqual(value, other)) ||
+ (isObject(value) && isObject(other) && objectShallowEqual(value, other))
+ );
+}
+
+export function arrayShallowEqual(
+ value: $ReadOnlyArray<mixed>,
+ other: $ReadOnlyArray<mixed>
+): boolean {
+ return value.length === other.length && value.every((k, i) => k === other[i]);
+}
+
+function objectShallowEqual(
+ value: { [string]: mixed },
+ other: { [string]: mixed }
+): boolean {
+ 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: mixed): boolean %checks {
+ return typeof value === "object" && !!value;
+}
diff --git a/devtools/client/debugger/src/utils/resource/core.js b/devtools/client/debugger/src/utils/resource/core.js
new file mode 100644
index 0000000000..dad5337447
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/core.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/>. */
+
+// @flow
+
+export type Resource<R: ResourceBound> = $ReadOnly<$Exact<R>>;
+
+export type ResourceBound = {
+ +id: string,
+};
+export type Id<R: ResourceBound> = $ElementType<R, "id">;
+
+type ResourceSubset<R: ResourceBound> = $ReadOnly<{
+ +id: Id<R>,
+ ...$Shape<$Rest<R, { +id: Id<R> }>>,
+}>;
+
+export opaque type ResourceIdentity: { [string]: mixed } = {||};
+export type ResourceValues<R: ResourceBound> = { [Id<R>]: R };
+
+export opaque type ResourceState<R: ResourceBound> = {
+ identity: { [Id<R>]: ResourceIdentity },
+ values: ResourceValues<R>,
+};
+
+export function createInitial<R: ResourceBound>(): ResourceState<R> {
+ return {
+ identity: {},
+ values: {},
+ };
+}
+
+export function insertResources<R: ResourceBound>(
+ state: ResourceState<R>,
+ resources: $ReadOnlyArray<R>
+): ResourceState<R> {
+ if (resources.length === 0) {
+ return state;
+ }
+
+ state = {
+ identity: { ...state.identity },
+ values: { ...state.values },
+ };
+
+ for (const resource of resources) {
+ const { id } = resource;
+ if (state.identity[id]) {
+ throw new Error(
+ `Resource "${id}" already exists, cannot insert ${JSON.stringify(
+ resource
+ )}`
+ );
+ }
+ if (state.values[id]) {
+ throw new Error(
+ `Resource state corrupt: ${id} has value but no identity`
+ );
+ }
+
+ state.identity[resource.id] = makeIdentity();
+ state.values[resource.id] = resource;
+ }
+ return state;
+}
+
+export function removeResources<R: ResourceBound>(
+ state: ResourceState<R>,
+ resources: $ReadOnlyArray<ResourceSubset<R> | Id<R>>
+): ResourceState<R> {
+ if (resources.length === 0) {
+ return state;
+ }
+
+ state = {
+ identity: { ...state.identity },
+ values: { ...state.values },
+ };
+
+ for (let id of resources) {
+ if (typeof id !== "string") {
+ id = id.id;
+ }
+
+ if (!state.identity[id]) {
+ throw new Error(`Resource "${id}" does not exists, cannot remove`);
+ }
+ if (!state.values[id]) {
+ throw new Error(
+ `Resource state corrupt: ${id} has identity but no value`
+ );
+ }
+
+ delete state.identity[id];
+ delete state.values[id];
+ }
+ return state;
+}
+
+export function updateResources<R: ResourceBound>(
+ state: ResourceState<R>,
+ resources: $ReadOnlyArray<ResourceSubset<R>>
+): ResourceState<R> {
+ if (resources.length === 0) {
+ return state;
+ }
+
+ let didCopyValues = false;
+
+ for (const subset of resources) {
+ const { id } = subset;
+
+ if (!state.identity[id]) {
+ throw new Error(`Resource "${id}" does not exists, cannot update`);
+ }
+ if (!state.values[id]) {
+ throw new Error(
+ `Resource state corrupt: ${id} has identity but no value`
+ );
+ }
+
+ const existing = state.values[id];
+ const updated = {};
+
+ for (const field of Object.keys(subset)) {
+ if (field === "id") {
+ continue;
+ }
+
+ if (subset[field] !== existing[field]) {
+ updated[field] = subset[field];
+ }
+ }
+
+ if (Object.keys(updated).length > 0) {
+ if (!didCopyValues) {
+ didCopyValues = true;
+ state = {
+ identity: state.identity,
+ values: { ...state.values },
+ };
+ }
+
+ state.values[id] = { ...existing, ...updated };
+ }
+ }
+
+ return state;
+}
+
+export function makeIdentity(): ResourceIdentity {
+ return ({}: any);
+}
+
+export function getValidatedResource<R: ResourceBound>(
+ state: ResourceState<R>,
+ id: Id<R>
+):
+ | (ResourceState<R> & {
+ values: Array<R>,
+ identity: Array<string>,
+ })
+ | null {
+ const value = state.values[id];
+ const identity = state.identity[id];
+ if ((value && !identity) || (!value && identity)) {
+ throw new Error(
+ `Resource state corrupt: ${id} has mismatched value and identity`
+ );
+ }
+
+ return value ? (state: any) : null;
+}
+
+export function getResourceValues<R: ResourceBound>(
+ state: ResourceState<R>
+): ResourceValues<R> {
+ return state.values;
+}
diff --git a/devtools/client/debugger/src/utils/resource/index.js b/devtools/client/debugger/src/utils/resource/index.js
new file mode 100644
index 0000000000..fb8b917d2d
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/index.js
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+export {
+ createInitial,
+ insertResources,
+ removeResources,
+ updateResources,
+} from "./core";
+export type {
+ Id,
+ Resource,
+ ResourceBound,
+ // Disabled pending eslint-plugin-import bug #1345
+ // eslint-disable-next-line import/named
+ ResourceState,
+ // Disabled pending eslint-plugin-import bug #1345
+ // eslint-disable-next-line import/named
+ ResourceIdentity,
+ ResourceValues,
+} from "./core";
+
+export {
+ hasResource,
+ getResourceIds,
+ getResource,
+ getMappedResource,
+} from "./selector";
+export type { ResourceMap } from "./selector";
+
+export { makeResourceQuery, makeMapWithArgs } from "./base-query";
+export type {
+ ResourceQuery,
+ QueryMap,
+ QueryMapNoArgs,
+ QueryMapWithArgs,
+ QueryFilter,
+ QueryReduce,
+ QueryResultCompare,
+} from "./base-query";
+
+export {
+ filterAllIds,
+ makeWeakQuery,
+ makeShallowQuery,
+ makeStrictQuery,
+ makeIdQuery,
+ makeLoadQuery,
+ makeFilterQuery,
+ makeReduceQuery,
+ makeReduceAllQuery,
+} from "./query";
+export type {
+ WeakQuery,
+ ShallowQuery,
+ StrictQuery,
+ IdQuery,
+ LoadQuery,
+ FilterQuery,
+ ReduceQuery,
+ ReduceAllQuery,
+} from "./query";
+
+export {
+ queryCacheWeak,
+ queryCacheShallow,
+ queryCacheStrict,
+} from "./query-cache";
+export type { WeakArgsBound, ShallowArgsBound } from "./query-cache";
+
+export { memoizeResourceShallow } from "./memoize";
diff --git a/devtools/client/debugger/src/utils/resource/memoize.js b/devtools/client/debugger/src/utils/resource/memoize.js
new file mode 100644
index 0000000000..b00501b4df
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/memoize.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/>. */
+
+// @flow
+
+import type { ResourceBound } from "./core";
+import type { QueryMap } from "./base-query";
+import { shallowEqual } from "./compare";
+
+/**
+ * Wraps a 'mapper' function to create a shallow-equality memoized version
+ * of the mapped result. The returned function will return the same value
+ * even if the input object is different, as long as the identity is the same
+ * and the mapped result is shallow-equal to the most recent mapped value.
+ */
+export function memoizeResourceShallow<
+ R: ResourceBound,
+ Args,
+ Mapped,
+ T: QueryMap<R, Args, Mapped>
+>(map: T): T {
+ const cache = new WeakMap();
+
+ const fn = (input, identity, args) => {
+ let existingEntry = cache.get(identity);
+
+ if (!existingEntry || existingEntry.input !== input) {
+ const mapper = (map: any);
+ const output = mapper(input, identity, args);
+
+ if (existingEntry) {
+ // If the new output is shallow-equal to the old output, we reuse
+ // the previous object instead to preserve object equality.
+ const newOutput = shallowEqual(output, existingEntry.output)
+ ? existingEntry.output
+ : output;
+
+ existingEntry.output = newOutput;
+ existingEntry.input = input;
+ } else {
+ existingEntry = {
+ input,
+ output,
+ };
+ cache.set(identity, existingEntry);
+ }
+ }
+
+ return existingEntry.output;
+ };
+ fn.needsArgs = map.needsArgs;
+ return (fn: any);
+}
diff --git a/devtools/client/debugger/src/utils/resource/moz.build b/devtools/client/debugger/src/utils/resource/moz.build
new file mode 100644
index 0000000000..7fa8b2a810
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/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(
+ "base-query.js",
+ "compare.js",
+ "core.js",
+ "index.js",
+ "memoize.js",
+ "query-cache.js",
+ "query.js",
+ "selector.js",
+)
diff --git a/devtools/client/debugger/src/utils/resource/query-cache.js b/devtools/client/debugger/src/utils/resource/query-cache.js
new file mode 100644
index 0000000000..6fa8c922ad
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/query-cache.js
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { ResourceBound, ResourceState } from "./core";
+import type {
+ ResourceQuery,
+ QueryCacheHandler,
+ QueryContext,
+ QueryResult,
+} from "./base-query";
+import { strictEqual, shallowEqual } from "./compare";
+
+export type WeakArgsBound =
+ | $ReadOnly<{ [string]: mixed }>
+ | $ReadOnlyArray<mixed>;
+
+export type ShallowArgsBound =
+ | $ReadOnly<{ [string]: mixed }>
+ | $ReadOnlyArray<mixed>;
+
+/**
+ * A query 'cache' function that uses the identity of the arguments object to
+ * cache data for the query itself.
+ */
+export function queryCacheWeak<
+ R: ResourceBound,
+ Args: WeakArgsBound,
+ Mapped,
+ Reduced
+>(
+ handler: QueryCacheHandler<R, Args, Mapped, Reduced>
+): ResourceQuery<R, Args, Reduced> {
+ const cache = new WeakMap();
+ return makeCacheFunction({
+ handler,
+ // The WeakMap will only return entries for the exact object,
+ // so there is no need to compare at all.
+ compareArgs: () => true,
+ getEntry: args => cache.get(args) || null,
+ setEntry: (args, entry) => {
+ cache.set(args, entry);
+ },
+ });
+}
+
+/**
+ * A query 'cache' function that uses shallow comparison to cache the most
+ * recent calculated result based on the value of the argument.
+ */
+export function queryCacheShallow<
+ R: ResourceBound,
+ // We require args to be an object here because if you're using a primitive
+ // then you should be using queryCacheStrict instead.
+ Args: ShallowArgsBound,
+ Mapped,
+ Reduced
+>(
+ handler: QueryCacheHandler<R, Args, Mapped, Reduced>
+): ResourceQuery<R, Args, Reduced> {
+ let latestEntry = null;
+ return makeCacheFunction({
+ handler,
+ compareArgs: shallowEqual,
+ getEntry: () => latestEntry,
+ setEntry: (args, entry) => {
+ latestEntry = entry;
+ },
+ });
+}
+
+/**
+ * A query 'cache' function that uses strict comparison to cache the most
+ * recent calculated result based on the value of the argument.
+ */
+export function queryCacheStrict<R: ResourceBound, Args, Mapped, Reduced>(
+ handler: QueryCacheHandler<R, Args, Mapped, Reduced>
+): ResourceQuery<R, Args, Reduced> {
+ let latestEntry = null;
+ return makeCacheFunction({
+ handler,
+ compareArgs: strictEqual,
+ getEntry: () => latestEntry,
+ setEntry: (args, entry) => {
+ latestEntry = entry;
+ },
+ });
+}
+
+type CacheEntry<R: ResourceBound, Args, Mapped, Reduced> = {
+ context: QueryContext<Args>,
+ state: ResourceState<R>,
+ result: QueryResult<Mapped, Reduced>,
+};
+
+type CacheFunctionInfo<R: ResourceBound, Args, Mapped, Reduced> = {|
+ // The handler to call when the args or the state are different from
+ // those in the entry for the arguments.
+ handler: QueryCacheHandler<R, Args, Mapped, Reduced>,
+
+ // Compare two sets of arguments to decide whether or not they should be
+ // treated as the same set of arguments from the standpoint of caching.
+ compareArgs: (a: Args, b: Args) => boolean,
+
+ getEntry: (args: Args) => CacheEntry<R, Args, Mapped, Reduced> | null,
+ setEntry: (args: Args, entry: CacheEntry<R, Args, Mapped, Reduced>) => void,
+|};
+function makeCacheFunction<R: ResourceBound, Args, Mapped, Reduced>(
+ info: CacheFunctionInfo<R, Args, Mapped, Reduced>
+): ResourceQuery<R, Args, Reduced> {
+ const { handler, compareArgs, getEntry, setEntry } = info;
+
+ return (state, args: Args) => {
+ let entry = getEntry(args);
+
+ const sameArgs = !!entry && compareArgs(entry.context.args, args);
+ const sameState = !!entry && entry.state === state;
+
+ if (!entry || !sameArgs || !sameState) {
+ const context =
+ !entry || !sameArgs
+ ? {
+ args,
+ identMap: new WeakMap(),
+ }
+ : entry.context;
+
+ const result = handler(state, context, entry ? entry.result : null);
+
+ if (entry) {
+ entry.context = context;
+ entry.state = state;
+ entry.result = result;
+ } else {
+ entry = {
+ context,
+ state,
+ result,
+ };
+ setEntry(args, entry);
+ }
+ }
+
+ return entry.result.reduced;
+ };
+}
diff --git a/devtools/client/debugger/src/utils/resource/query.js b/devtools/client/debugger/src/utils/resource/query.js
new file mode 100644
index 0000000000..b2edd0f5e5
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/query.js
@@ -0,0 +1,245 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { ResourceBound, Id, ResourceValues } from "./core";
+
+import {
+ makeResourceQuery,
+ type ResourceQuery,
+ type QueryFilter,
+ type QueryMap,
+ type QueryReduce,
+} from "./base-query";
+
+import {
+ queryCacheWeak,
+ queryCacheShallow,
+ queryCacheStrict,
+ type WeakArgsBound,
+ type ShallowArgsBound,
+} from "./query-cache";
+
+import { memoizeResourceShallow } from "./memoize";
+import { shallowEqual } from "./compare";
+
+export function filterAllIds<R: ResourceBound>(
+ values: ResourceValues<R>
+): Array<Id<R>> {
+ return Object.keys(values);
+}
+
+/**
+ * Create a query function to take a list of IDs and map each Reduceding
+ * resource object into a mapped form.
+ */
+export type WeakQuery<
+ R: ResourceBound,
+ Args: WeakArgsBound,
+ Reduced
+> = ResourceQuery<R, Args, Reduced>;
+export function makeWeakQuery<
+ R: ResourceBound,
+ Args: WeakArgsBound,
+ Mapped,
+ Reduced
+>({
+ filter,
+ map,
+ reduce,
+}: {|
+ filter: QueryFilter<R, Args>,
+ map: QueryMap<R, Args, Mapped>,
+ reduce: QueryReduce<R, Args, Mapped, Reduced>,
+|}): WeakQuery<R, Args, Reduced> {
+ return makeResourceQuery({
+ cache: queryCacheWeak,
+ filter,
+ map: memoizeResourceShallow(map),
+ reduce,
+ resultCompare: shallowEqual,
+ });
+}
+
+/**
+ * Create a query function to take a list of IDs and map each Reduceding
+ * resource object into a mapped form.
+ */
+export type ShallowQuery<R: ResourceBound, Args, Reduced> = ResourceQuery<
+ R,
+ Args,
+ Reduced
+>;
+export function makeShallowQuery<
+ R: ResourceBound,
+ Args: ShallowArgsBound,
+ Mapped,
+ Reduced
+>({
+ filter,
+ map,
+ reduce,
+}: {|
+ filter: QueryFilter<R, Args>,
+ map: QueryMap<R, Args, Mapped>,
+ reduce: QueryReduce<R, Args, Mapped, Reduced>,
+|}): ShallowQuery<R, Args, Reduced> {
+ return makeResourceQuery({
+ cache: queryCacheShallow,
+ filter,
+ map: memoizeResourceShallow(map),
+ reduce,
+ resultCompare: shallowEqual,
+ });
+}
+
+/**
+ * Create a query function to take a list of IDs and map each Reduceding
+ * resource object into a mapped form.
+ */
+export type StrictQuery<R: ResourceBound, Args, Reduced> = ResourceQuery<
+ R,
+ Args,
+ Reduced
+>;
+export function makeStrictQuery<R: ResourceBound, Args, Mapped, Reduced>({
+ filter,
+ map,
+ reduce,
+}: {|
+ filter: QueryFilter<R, Args>,
+ map: QueryMap<R, Args, Mapped>,
+ reduce: QueryReduce<R, Args, Mapped, Reduced>,
+|}): StrictQuery<R, Args, Reduced> {
+ return makeResourceQuery({
+ cache: queryCacheStrict,
+ filter,
+ map: memoizeResourceShallow(map),
+ reduce,
+ resultCompare: shallowEqual,
+ });
+}
+
+/**
+ * Create a query function to take a list of IDs and map each Reduceding
+ * resource object into a mapped form.
+ */
+export type IdQuery<R: ResourceBound, Mapped> = WeakQuery<
+ R,
+ Array<Id<R>>,
+ Array<Mapped>
+>;
+export function makeIdQuery<R: ResourceBound, Mapped>(
+ map: QueryMap<R, void, Mapped>
+): IdQuery<R, Mapped> {
+ return makeWeakQuery({
+ filter: (state, ids) => ids,
+ map: (r, identity) => map(r, identity),
+ reduce: items => items.slice(),
+ });
+}
+
+/**
+ * Create a query function to take a list of IDs and map each Reduceding
+ * resource object into a mapped form.
+ */
+export type LoadQuery<R: ResourceBound, Mapped> = WeakQuery<
+ R,
+ Array<Id<R>>,
+ $ReadOnly<{ [Id<R>]: Mapped }>
+>;
+export function makeLoadQuery<R: ResourceBound, Mapped>(
+ map: QueryMap<R, void, Mapped>
+): LoadQuery<R, Mapped> {
+ return makeWeakQuery({
+ filter: (state, ids) => ids,
+ map: (r, identity) => map(r, identity),
+ reduce: reduceMappedArrayToObject,
+ });
+}
+
+/**
+ * Create a query function that accepts an argument and can filter the
+ * resource items to a subset before mapping each reduced resource.
+ */
+export type FilterQuery<
+ R: ResourceBound,
+ Args: WeakArgsBound,
+ Mapped
+> = WeakQuery<R, Args, $ReadOnly<{ [Id<R>]: Mapped }>>;
+export function makeFilterQuery<R: ResourceBound, Args: WeakArgsBound, Mapped>(
+ filter: (R, Args) => boolean,
+ map: QueryMap<R, Args, Mapped>
+): FilterQuery<R, Args, Mapped> {
+ return makeWeakQuery({
+ filter: (values, args) => {
+ const ids = [];
+ for (const id of Object.keys(values)) {
+ if (filter(values[id], args)) {
+ ids.push(id);
+ }
+ }
+ return ids;
+ },
+ map,
+ reduce: reduceMappedArrayToObject,
+ });
+}
+
+/**
+ * Create a query function that accepts an argument and can filter the
+ * resource items to a subset before mapping each resulting resource.
+ */
+export type ReduceQuery<
+ R: ResourceBound,
+ Args: ShallowArgsBound,
+ Reduced
+> = ShallowQuery<R, Args, Reduced>;
+export function makeReduceQuery<
+ R: ResourceBound,
+ Args: ShallowArgsBound,
+ Mapped,
+ Reduced
+>(
+ map: QueryMap<R, Args, Mapped>,
+ reduce: QueryReduce<R, Args, Mapped, Reduced>
+): ReduceQuery<R, Args, Reduced> {
+ return makeShallowQuery({
+ filter: filterAllIds,
+ map,
+ reduce,
+ });
+}
+
+/**
+ * Create a query function that accepts an argument and can filter the
+ * resource items to a subset before mapping each resulting resource.
+ */
+export type ReduceAllQuery<R: ResourceBound, Reduced> = ShallowQuery<
+ R,
+ void,
+ Reduced
+>;
+export function makeReduceAllQuery<R: ResourceBound, Mapped, Reduced>(
+ map: QueryMap<R, void, Mapped>,
+ reduce: QueryReduce<R, void, Mapped, Reduced>
+): ReduceAllQuery<R, Reduced> {
+ return makeStrictQuery({
+ filter: filterAllIds,
+ map,
+ reduce,
+ });
+}
+
+function reduceMappedArrayToObject<Args, ID, Mapped>(
+ items: $ReadOnlyArray<Mapped>,
+ ids: $ReadOnlyArray<ID>,
+ args: Args
+): { [ID]: Mapped } {
+ return items.reduce((acc: { [ID]: Mapped }, item, i) => {
+ acc[ids[i]] = item;
+ return acc;
+ }, {});
+}
diff --git a/devtools/client/debugger/src/utils/resource/selector.js b/devtools/client/debugger/src/utils/resource/selector.js
new file mode 100644
index 0000000000..df5fae4422
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/selector.js
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import {
+ getValidatedResource,
+ getResourceValues,
+ type ResourceState,
+ type Id,
+ type ResourceBound,
+ type ResourceIdentity,
+} from "./core";
+
+export type ResourceMap<R: ResourceBound, Mapped> = (
+ R,
+ ResourceIdentity
+) => Mapped;
+
+export function hasResource<R: ResourceBound>(
+ state: ResourceState<R>,
+ id: Id<R>
+): boolean %checks {
+ return !!getValidatedResource(state, id);
+}
+
+export function getResourceIds<R: ResourceBound>(
+ state: ResourceState<R>
+): Array<Id<R>> {
+ return Object.keys(getResourceValues(state));
+}
+
+export function getResource<R: ResourceBound>(
+ state: ResourceState<R>,
+ id: Id<R>
+): R {
+ const validatedState = getValidatedResource(state, id);
+ if (!validatedState) {
+ throw new Error(`Resource ${id} does not exist`);
+ }
+ return validatedState.values[id];
+}
+
+export function getMappedResource<R: ResourceBound, Mapped>(
+ state: ResourceState<R>,
+ id: Id<R>,
+ map: ResourceMap<R, Mapped>
+): Mapped {
+ const validatedState = getValidatedResource(state, id);
+ if (!validatedState) {
+ throw new Error(`Resource ${id} does not exist`);
+ }
+
+ return map(validatedState.values[id], validatedState.identity[id]);
+}
diff --git a/devtools/client/debugger/src/utils/resource/tests/crud.spec.js b/devtools/client/debugger/src/utils/resource/tests/crud.spec.js
new file mode 100644
index 0000000000..474323e6e5
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/tests/crud.spec.js
@@ -0,0 +1,266 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+declare var describe: (name: string, func: () => void) => void;
+declare var it: (desc: string, func: () => void) => void;
+declare var expect: (value: any) => any;
+
+import {
+ createInitial,
+ insertResources,
+ removeResources,
+ updateResources,
+ hasResource,
+ getResourceIds,
+ getResource,
+ getMappedResource,
+ type Resource,
+ type ResourceIdentity,
+} from "..";
+
+type TestResource = Resource<{
+ id: string,
+ name: string,
+ data: number,
+ obj: {},
+}>;
+
+const makeResource = (id: string): TestResource => ({
+ id,
+ name: `name-${id}`,
+ data: 42,
+ obj: {},
+});
+
+const mapName = (resource: TestResource) => resource.name;
+const mapWithIdent = (resource: TestResource, identity: ResourceIdentity) => ({
+ resource,
+ identity,
+ obj: {},
+});
+
+const clone = <T>(v: T): T => (JSON.parse((JSON.stringify(v): any)): any);
+
+describe("resource CRUD operations", () => {
+ let r1, r2, r3;
+ let originalInitial;
+ let initialState;
+ beforeEach(() => {
+ r1 = makeResource("id-1");
+ r2 = makeResource("id-2");
+ r3 = makeResource("id-3");
+
+ initialState = createInitial();
+ originalInitial = clone(initialState);
+ });
+
+ describe("insert", () => {
+ it("should work", () => {
+ const state = insertResources(initialState, [r1, r2, r3]);
+
+ expect(initialState).toEqual(originalInitial);
+ expect(getResource(state, r1.id)).toBe(r1);
+ expect(getResource(state, r2.id)).toBe(r2);
+ expect(getResource(state, r3.id)).toBe(r3);
+ });
+
+ it("should throw on duplicate", () => {
+ const state = insertResources(initialState, [r1]);
+ expect(() => {
+ insertResources(state, [r1]);
+ }).toThrow(/already exists/);
+
+ expect(() => {
+ insertResources(state, [r2, r2]);
+ }).toThrow(/already exists/);
+ });
+
+ it("should be a no-op when given no resources", () => {
+ const state = insertResources(initialState, []);
+
+ expect(state).toBe(initialState);
+ });
+ });
+
+ describe("read", () => {
+ beforeEach(() => {
+ initialState = insertResources(initialState, [r1, r2, r3]);
+ });
+
+ it("should allow reading all IDs", () => {
+ expect(getResourceIds(initialState)).toEqual([r1.id, r2.id, r3.id]);
+ });
+
+ it("should allow checking for existing of an ID", () => {
+ expect(hasResource(initialState, r1.id)).toBe(true);
+ expect(hasResource(initialState, r2.id)).toBe(true);
+ expect(hasResource(initialState, r3.id)).toBe(true);
+ expect(hasResource(initialState, "unknownId")).toBe(false);
+ });
+
+ it("should allow reading an item", () => {
+ expect(getResource(initialState, r1.id)).toBe(r1);
+ expect(getResource(initialState, r2.id)).toBe(r2);
+ expect(getResource(initialState, r3.id)).toBe(r3);
+
+ expect(() => {
+ getResource(initialState, "unknownId");
+ }).toThrow(/does not exist/);
+ });
+
+ it("should allow reading and mapping an item", () => {
+ expect(getMappedResource(initialState, r1.id, mapName)).toBe(r1.name);
+ expect(getMappedResource(initialState, r2.id, mapName)).toBe(r2.name);
+ expect(getMappedResource(initialState, r3.id, mapName)).toBe(r3.name);
+
+ expect(() => {
+ getMappedResource(initialState, "unknownId", mapName);
+ }).toThrow(/does not exist/);
+ });
+
+ it("should allow reading and mapping an item with identity", () => {
+ const r1Ident = getMappedResource(initialState, r1.id, mapWithIdent);
+ const r2Ident = getMappedResource(initialState, r2.id, mapWithIdent);
+
+ const state = updateResources(initialState, [{ ...r1, obj: {} }]);
+
+ const r1NewIdent = getMappedResource(state, r1.id, mapWithIdent);
+ const r2NewIdent = getMappedResource(state, r2.id, mapWithIdent);
+
+ // The update changed the resource object, but not the identity.
+ expect(r1NewIdent.resource).not.toBe(r1Ident.resource);
+ expect(r1NewIdent.resource).toEqual(r1Ident.resource);
+ expect(r1NewIdent.identity).toBe(r1Ident.identity);
+
+ // The update did not change the r2 resource.
+ expect(r2NewIdent.resource).toBe(r2Ident.resource);
+ expect(r2NewIdent.identity).toBe(r2Ident.identity);
+ });
+ });
+
+ describe("update", () => {
+ beforeEach(() => {
+ initialState = insertResources(initialState, [r1, r2, r3]);
+ originalInitial = clone(initialState);
+ });
+
+ it("should work", () => {
+ const r1Ident = getMappedResource(initialState, r1.id, mapWithIdent);
+ const r2Ident = getMappedResource(initialState, r2.id, mapWithIdent);
+ const r3Ident = getMappedResource(initialState, r3.id, mapWithIdent);
+
+ const state = updateResources(initialState, [
+ {
+ id: r1.id,
+ data: 21,
+ },
+ {
+ id: r2.id,
+ name: "newName",
+ },
+ ]);
+
+ expect(initialState).toEqual(originalInitial);
+ expect(getResource(state, r1.id)).toEqual({ ...r1, data: 21 });
+ expect(getResource(state, r2.id)).toEqual({ ...r2, name: "newName" });
+ expect(getResource(state, r3.id)).toBe(r3);
+
+ const r1NewIdent = getMappedResource(state, r1.id, mapWithIdent);
+ const r2NewIdent = getMappedResource(state, r2.id, mapWithIdent);
+ const r3NewIdent = getMappedResource(state, r3.id, mapWithIdent);
+
+ // The update changed the resource object, but not the identity.
+ expect(r1NewIdent.resource).not.toBe(r1Ident.resource);
+ expect(r1NewIdent.resource).toEqual({
+ ...r1Ident.resource,
+ data: 21,
+ });
+ expect(r1NewIdent.identity).toBe(r1Ident.identity);
+
+ // The update changed the resource object, but not the identity.
+ expect(r2NewIdent.resource).toEqual({
+ ...r2Ident.resource,
+ name: "newName",
+ });
+ expect(r2NewIdent.identity).toBe(r2Ident.identity);
+
+ // The update did not change the r3 resource.
+ expect(r3NewIdent.resource).toBe(r3Ident.resource);
+ expect(r3NewIdent.identity).toBe(r3Ident.identity);
+ });
+
+ it("should throw if not found", () => {
+ expect(() => {
+ updateResources(initialState, [
+ {
+ ...r1,
+ id: "unknownId",
+ },
+ ]);
+ }).toThrow(/does not exists/);
+ });
+
+ it("should be a no-op when new fields are strict-equal", () => {
+ const state = updateResources(initialState, [r1]);
+ expect(state).toBe(initialState);
+ });
+
+ it("should be a no-op when given no resources", () => {
+ const state = updateResources(initialState, []);
+ expect(state).toBe(initialState);
+ });
+ });
+
+ describe("delete", () => {
+ beforeEach(() => {
+ initialState = insertResources(initialState, [r1, r2, r3]);
+ originalInitial = clone(initialState);
+ });
+
+ it("should work with objects", () => {
+ const state = removeResources(initialState, [r1]);
+
+ expect(initialState).toEqual(originalInitial);
+ expect(hasResource(state, r1.id)).toBe(false);
+ expect(hasResource(state, r2.id)).toBe(true);
+ expect(hasResource(state, r3.id)).toBe(true);
+ });
+
+ it("should work with object subsets", () => {
+ const state = removeResources(initialState, [{ id: r1.id }]);
+
+ expect(initialState).toEqual(originalInitial);
+ expect(hasResource(state, r1.id)).toBe(false);
+ expect(hasResource(state, r2.id)).toBe(true);
+ expect(hasResource(state, r3.id)).toBe(true);
+ });
+
+ it("should work with ids", () => {
+ const state = removeResources(initialState, [r1.id]);
+
+ expect(initialState).toEqual(originalInitial);
+ expect(hasResource(state, r1.id)).toBe(false);
+ expect(hasResource(state, r2.id)).toBe(true);
+ expect(hasResource(state, r3.id)).toBe(true);
+ });
+
+ it("should throw if not found", () => {
+ expect(() => {
+ removeResources(initialState, [makeResource("unknownId")]);
+ }).toThrow(/does not exist/);
+ expect(() => {
+ removeResources(initialState, [{ id: "unknownId" }]);
+ }).toThrow(/does not exist/);
+ expect(() => {
+ removeResources(initialState, ["unknownId"]);
+ }).toThrow(/does not exist/);
+ });
+
+ it("should be a no-op when given no resources", () => {
+ const state = removeResources(initialState, []);
+ expect(state).toBe(initialState);
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/resource/tests/query.spec.js b/devtools/client/debugger/src/utils/resource/tests/query.spec.js
new file mode 100644
index 0000000000..adca73f750
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/tests/query.spec.js
@@ -0,0 +1,1079 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import {
+ createInitial,
+ insertResources,
+ updateResources,
+ makeMapWithArgs,
+ makeWeakQuery,
+ makeShallowQuery,
+ makeStrictQuery,
+ type Id,
+ type Resource,
+ type ResourceValues,
+ type ResourceIdentity,
+ type QueryMapNoArgs,
+ type QueryMapWithArgs,
+ type QueryFilter,
+ type QueryReduce,
+} from "..";
+
+type TestResource = Resource<{
+ id: string,
+ name: string,
+ data: number,
+ obj: {},
+}>;
+
+const makeResource = (id: string): TestResource => ({
+ id,
+ name: `name-${id}`,
+ data: 42,
+ obj: {},
+});
+
+// Jest's mock type just wouldn't cooperate below, so this is a custom version
+// that does what I need.
+type MockedFn<InputFn, OutputFn> = OutputFn & {
+ mock: {
+ calls: Array<any>,
+ },
+ mockImplementation(fn: InputFn): void,
+};
+
+// We need to pass the 'needsArgs' prop through to the query fn so we use
+// this utility to do that and at the same time preserve typechecking.
+const mockFn = (f: any) => Object.assign((jest.fn(f): any), f);
+
+const mockFilter = <Args, F: QueryFilter<TestResource, Args>>(
+ callback: F
+): MockedFn<F, F> => mockFn(callback);
+const mockMapNoArgs = <Mapped, F: (TestResource, ResourceIdentity) => Mapped>(
+ callback: F
+): MockedFn<F, QueryMapNoArgs<TestResource, Mapped>> => mockFn(callback);
+const mockMapWithArgs = <
+ Args,
+ Mapped,
+ F: (TestResource, ResourceIdentity, Args) => Mapped
+>(
+ callback: F
+): MockedFn<F, QueryMapWithArgs<TestResource, Args, Mapped>> =>
+ mockFn(makeMapWithArgs(callback));
+const mockReduce = <
+ Args,
+ Mapped,
+ Reduced,
+ F: QueryReduce<TestResource, Args, Mapped, Reduced>
+>(
+ callback: F
+): MockedFn<F, F> => mockFn(callback);
+
+type TestArgs = Array<Id<TestResource>>;
+type TestReduced = { [Id<TestResource>]: TestResource };
+
+describe("resource query operations", () => {
+ let r1, r2, r3;
+ let initialState;
+ let mapNoArgs, mapWithArgs, reduce;
+
+ beforeEach(() => {
+ r1 = makeResource("id-1");
+ r2 = makeResource("id-2");
+ r3 = makeResource("id-3");
+
+ initialState = createInitial();
+
+ initialState = insertResources(initialState, [r1, r2, r3]);
+
+ mapNoArgs = mockMapNoArgs(
+ (resource: TestResource, ident: ResourceIdentity): TestResource =>
+ resource
+ );
+ mapWithArgs = mockMapWithArgs(
+ (
+ resource: TestResource,
+ ident: ResourceIdentity,
+ args: mixed
+ ): TestResource => resource
+ );
+ reduce = mockReduce(
+ (
+ mapped: $ReadOnlyArray<TestResource>,
+ ids: $ReadOnlyArray<string>,
+ args: mixed
+ ): TestReduced => {
+ return mapped.reduce((acc, item, i) => {
+ acc[ids[i]] = item;
+ return acc;
+ }, {});
+ }
+ );
+ });
+
+ describe("weak cache", () => {
+ let filter;
+
+ beforeEach(() => {
+ filter = mockFilter(
+ // eslint-disable-next-line max-nested-callbacks
+ (values: ResourceValues<TestResource>, args: TestArgs): TestArgs => args
+ );
+ });
+
+ describe("no args", () => {
+ let query;
+
+ beforeEach(() => {
+ query = makeWeakQuery({ filter, map: mapNoArgs, reduce });
+ });
+
+ it("should return same with same state and same args", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return same with updated other state and same args 1", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Updating r2 does not affect cached result that only cares about r2.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ obj: {},
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return same with updated other state and same args 2", () => {
+ // eslint-disable-next-line max-nested-callbacks
+ mapNoArgs.mockImplementation(resource => ({ ...resource, name: "" }));
+
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the map function ignores the name value, updating it should
+ // not reset the cached for this query.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return diff with updated id state and same args", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the mapper returns a value with name, changing a name will
+ // invalidate the cache.
+ const state = updateResources(initialState, [
+ {
+ id: r1.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).not.toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: { ...r1, name: "newName" },
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(3);
+ expect(reduce.mock.calls).toHaveLength(2);
+ });
+
+ it("should return diff with same state and diff args", () => {
+ const firstArgs = [r1.id, r2.id];
+ const secondArgs = [r1.id, r2.id];
+ const result1 = query(initialState, firstArgs);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, secondArgs);
+ expect(result2).not.toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(2);
+
+ // Same result from first query still available.
+ const result3 = query(initialState, firstArgs);
+ expect(result3).not.toBe(result2);
+ expect(result3).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(2);
+
+ // Same result from second query still available.
+ const result4 = query(initialState, secondArgs);
+ expect(result4).toBe(result2);
+ expect(result4).not.toBe(result3);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(2);
+ });
+ });
+
+ describe("with args", () => {
+ let query;
+
+ beforeEach(() => {
+ query = makeWeakQuery({ filter, map: mapWithArgs, reduce });
+ });
+
+ it("should return same with same state and same args", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return same with updated other state and same args 1", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Updating r2 does not affect cached result that only cares about r2.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ obj: {},
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return same with updated other state and same args 2", () => {
+ // eslint-disable-next-line max-nested-callbacks
+ mapWithArgs.mockImplementation(resource => ({ ...resource, name: "" }));
+
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the map function ignores the name value, updating it should
+ // not reset the cached for this query.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return diff with updated id state and same args", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the mapper returns a value with name, changing a name will
+ // invalidate the cache.
+ const state = updateResources(initialState, [
+ {
+ id: r1.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).not.toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: { ...r1, name: "newName" },
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(3);
+ expect(reduce.mock.calls).toHaveLength(2);
+ });
+
+ it("should return diff with same state and diff args", () => {
+ const firstArgs = [r1.id, r2.id];
+ const secondArgs = [r1.id, r2.id];
+
+ const result1 = query(initialState, firstArgs);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, secondArgs);
+ expect(result2).not.toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(4);
+ expect(reduce.mock.calls).toHaveLength(2);
+
+ // Same result from first query still available.
+ const result3 = query(initialState, firstArgs);
+ expect(result3).not.toBe(result2);
+ expect(result3).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(4);
+ expect(reduce.mock.calls).toHaveLength(2);
+
+ // Same result from second query still available.
+ const result4 = query(initialState, secondArgs);
+ expect(result4).toBe(result2);
+ expect(result4).not.toBe(result3);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(4);
+ expect(reduce.mock.calls).toHaveLength(2);
+ });
+ });
+ });
+
+ describe("shallow cache", () => {
+ let filter;
+
+ beforeEach(() => {
+ filter = mockFilter(
+ // eslint-disable-next-line max-nested-callbacks
+ (
+ values: ResourceValues<TestResource>,
+ { ids }: { ids: TestArgs }
+ ): TestArgs => ids
+ );
+ });
+
+ describe("no args", () => {
+ let query;
+
+ beforeEach(() => {
+ query = makeShallowQuery({ filter, map: mapNoArgs, reduce });
+ });
+
+ it("should return last with same state and same args", () => {
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids });
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, { ids });
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return last with updated other state and same args 1", () => {
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids });
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Updating r2 does not affect cached result that only cares about r2.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ obj: {},
+ },
+ ]);
+
+ const result2 = query(state, { ids });
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return last with updated other state and same args 2", () => {
+ // eslint-disable-next-line max-nested-callbacks
+ mapNoArgs.mockImplementation(resource => ({ ...resource, name: "" }));
+
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids });
+ expect(result1).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the map function ignores the name value, updating it should
+ // not reset the cached for this query.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, { ids });
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return new with updated id state and same args", () => {
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids });
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Updating r2 does not affect cached result that only cares about r2.
+ const state = updateResources(initialState, [
+ {
+ id: r2.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, { ids });
+ expect(result2).not.toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: { ...r2, name: "newName" },
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(3);
+ expect(reduce.mock.calls).toHaveLength(2);
+ });
+
+ it("should return diff with same state and diff args", () => {
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids, flag: true });
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, { ids, flag: false });
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result3 = query(initialState, { ids, flag: true });
+ expect(result3).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(3);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result4 = query(initialState, { ids, flag: false });
+ expect(result4).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(4);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+ });
+
+ describe("with args", () => {
+ let query;
+
+ beforeEach(() => {
+ query = makeShallowQuery({ filter, map: mapWithArgs, reduce });
+ });
+
+ it("should return last with same state and same args", () => {
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids });
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, { ids });
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return last with updated other state and same args 1", () => {
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids });
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Updating r2 does not affect cached result that only cares about r2.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ obj: {},
+ },
+ ]);
+
+ const result2 = query(state, { ids });
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return last with updated other state and same args 2", () => {
+ // eslint-disable-next-line max-nested-callbacks
+ mapWithArgs.mockImplementation(resource => ({ ...resource, name: "" }));
+
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids });
+ expect(result1).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the map function ignores the name value, updating it should
+ // not reset the cached for this query.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, { ids });
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return new with updated id state and same args", () => {
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids });
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Updating r2 does not affect cached result that only cares about r2.
+ const state = updateResources(initialState, [
+ {
+ id: r2.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, { ids });
+ expect(result2).not.toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: { ...r2, name: "newName" },
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(3);
+ expect(reduce.mock.calls).toHaveLength(2);
+ });
+
+ it("should return diff with same state and diff args", () => {
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids, flag: true });
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, { ids, flag: false });
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(4);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result3 = query(initialState, { ids, flag: true });
+ expect(result3).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(3);
+ expect(mapWithArgs.mock.calls).toHaveLength(6);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result4 = query(initialState, { ids, flag: false });
+ expect(result4).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(4);
+ expect(mapWithArgs.mock.calls).toHaveLength(8);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+ });
+ });
+
+ describe("strict cache", () => {
+ let filter;
+
+ beforeEach(() => {
+ filter = mockFilter(
+ // eslint-disable-next-line max-nested-callbacks
+ (values: ResourceValues<TestResource>, ids: Array<string>): TestArgs =>
+ ids
+ );
+ });
+
+ describe("no args", () => {
+ let query;
+
+ beforeEach(() => {
+ query = makeStrictQuery({ filter, map: mapNoArgs, reduce });
+ });
+
+ it("should return same with same state and same args", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return same with updated other state and same args 1", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Updating r2 does not affect cached result that only cares about r2.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ obj: {},
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return same with updated other state and same args 2", () => {
+ // eslint-disable-next-line max-nested-callbacks
+ mapNoArgs.mockImplementation(resource => ({ ...resource, name: "" }));
+
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the map function ignores the name value, updating it should
+ // not reset the cached for this query.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return diff with updated id state and same args", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the mapper returns a value with name, changing a name will
+ // invalidate the cache.
+ const state = updateResources(initialState, [
+ {
+ id: r1.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).not.toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: { ...r1, name: "newName" },
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(3);
+ expect(reduce.mock.calls).toHaveLength(2);
+ });
+
+ it("should return diff with same state and diff args", () => {
+ const firstArgs = [r1.id, r2.id];
+ const secondArgs = [r1.id, r2.id];
+ const result1 = query(initialState, firstArgs);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, secondArgs);
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result3 = query(initialState, firstArgs);
+ expect(result3).toBe(result2);
+ expect(filter.mock.calls).toHaveLength(3);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result4 = query(initialState, secondArgs);
+ expect(result4).toBe(result3);
+ expect(filter.mock.calls).toHaveLength(4);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+ });
+
+ describe("with args", () => {
+ let query;
+
+ beforeEach(() => {
+ query = makeStrictQuery({ filter, map: mapWithArgs, reduce });
+ });
+
+ it("should return same with same state and same args", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return same with updated other state and same args 1", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Updating r2 does not affect cached result that only cares about r2.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ obj: {},
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return same with updated other state and same args 2", () => {
+ // eslint-disable-next-line max-nested-callbacks
+ mapWithArgs.mockImplementation(resource => ({ ...resource, name: "" }));
+
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the map function ignores the name value, updating it should
+ // not reset the cached for this query.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return diff with updated id state and same args", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the mapper returns a value with name, changing a name will
+ // invalidate the cache.
+ const state = updateResources(initialState, [
+ {
+ id: r1.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).not.toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: { ...r1, name: "newName" },
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(3);
+ expect(reduce.mock.calls).toHaveLength(2);
+ });
+
+ it("should return diff with same state and diff args", () => {
+ const firstArgs = [r1.id, r2.id];
+ const secondArgs = [r1.id, r2.id];
+
+ const result1 = query(initialState, firstArgs);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, secondArgs);
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(4);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result3 = query(initialState, firstArgs);
+ expect(result3).toBe(result2);
+ expect(filter.mock.calls).toHaveLength(3);
+ expect(mapWithArgs.mock.calls).toHaveLength(6);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result4 = query(initialState, secondArgs);
+ expect(result4).toBe(result3);
+ expect(filter.mock.calls).toHaveLength(4);
+ expect(mapWithArgs.mock.calls).toHaveLength(8);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+ });
+ });
+});
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..11fae2f8fa
--- /dev/null
+++ b/devtools/client/debugger/src/utils/result-list.js
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+export function scrollList(resultList: Element[], index: number): void {
+ 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(() => {
+ 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..a07877449f
--- /dev/null
+++ b/devtools/client/debugger/src/utils/selected-location.js
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { isOriginalId } from "devtools-source-map";
+import type { SourceLocation, MappedLocation, Source } from "../types";
+
+export function getSelectedLocation(
+ mappedLocation: MappedLocation,
+ context: ?(Source | SourceLocation)
+): SourceLocation {
+ if (!context) {
+ return mappedLocation.location;
+ }
+
+ // $FlowIgnore
+ const sourceId = context.sourceId || context.id;
+ return isOriginalId(sourceId)
+ ? mappedLocation.location
+ : mappedLocation.generatedLocation;
+}
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..ef273539c1
--- /dev/null
+++ b/devtools/client/debugger/src/utils/source-maps.js
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import SourceMaps, { isOriginalId } from "devtools-source-map";
+import { getSource } from "../selectors";
+
+import type { SourceLocation, MappedLocation, Source } from "../types";
+
+export async function getGeneratedLocation(
+ state: Object,
+ source: Source,
+ location: SourceLocation,
+ sourceMaps: typeof SourceMaps
+): Promise<SourceLocation> {
+ if (!isOriginalId(location.sourceId)) {
+ return location;
+ }
+
+ const { line, sourceId, column } = await sourceMaps.getGeneratedLocation(
+ location
+ );
+
+ const generatedSource = getSource(state, sourceId);
+ if (!generatedSource) {
+ throw new Error(`Could not find generated source ${sourceId}`);
+ }
+
+ return {
+ line,
+ sourceId,
+ column: column === 0 ? undefined : column,
+ sourceUrl: generatedSource.url,
+ };
+}
+
+export async function getOriginalLocation(
+ generatedLocation: SourceLocation,
+ sourceMaps: typeof SourceMaps
+) {
+ if (isOriginalId(generatedLocation.sourceId)) {
+ return location;
+ }
+
+ return sourceMaps.getOriginalLocation(generatedLocation);
+}
+
+export async function getMappedLocation(
+ state: Object,
+ sourceMaps: typeof SourceMaps,
+ location: SourceLocation
+): Promise<MappedLocation> {
+ const source = getSource(state, location.sourceId);
+
+ if (!source) {
+ throw new Error(`no source ${location.sourceId}`);
+ }
+
+ if (isOriginalId(location.sourceId)) {
+ const generatedLocation = await getGeneratedLocation(
+ state,
+ source,
+ location,
+ sourceMaps
+ );
+ return { location, generatedLocation };
+ }
+
+ const generatedLocation = location;
+ const originalLocation = await sourceMaps.getOriginalLocation(
+ generatedLocation
+ );
+
+ return { location: originalLocation, generatedLocation };
+}
+
+export async function mapLocation(
+ state: Object,
+ sourceMaps: typeof SourceMaps,
+ location: SourceLocation
+): Promise<SourceLocation> {
+ const source = getSource(state, location.sourceId);
+
+ if (!source) {
+ return location;
+ }
+
+ if (isOriginalId(location.sourceId)) {
+ return getGeneratedLocation(state, source, location, sourceMaps);
+ }
+
+ return sourceMaps.getOriginalLocation(location);
+}
+
+export function isOriginalSource(source: ?Source) {
+ if (!source) {
+ return false;
+ }
+
+ if (!source.hasOwnProperty("isOriginal")) {
+ throw new Error("source must have an isOriginal property");
+ }
+
+ return source.isOriginal;
+}
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..78aac2d5dc
--- /dev/null
+++ b/devtools/client/debugger/src/utils/source-queue.js
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { throttle } from "lodash";
+import type { QueuedSourceData } from "../types";
+
+// This SourceQueue module is now only used for source mapped sources
+let newQueuedSources;
+let queuedSources;
+let currentWork;
+
+async function dispatchNewSources(): Promise<void> {
+ const sources = queuedSources;
+ queuedSources = [];
+ currentWork = await newQueuedSources(sources);
+}
+
+const queue = throttle(dispatchNewSources, 100);
+
+export default {
+ initialize: (actions: Object) => {
+ newQueuedSources = actions.newQueuedSources;
+ queuedSources = [];
+ },
+ queue: (source: QueuedSourceData) => {
+ queuedSources.push(source);
+ queue();
+ },
+ queueSources: (sources: QueuedSourceData[]) => {
+ if (sources.length > 0) {
+ queuedSources = queuedSources.concat(sources);
+ queue();
+ }
+ },
+
+ flush: () => Promise.all([queue.flush(), currentWork]),
+ clear: () => {
+ queuedSources = [];
+ 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..90459b6843
--- /dev/null
+++ b/devtools/client/debugger/src/utils/source.js
@@ -0,0 +1,573 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+/**
+ * Utils for working with Source URLs
+ * @module utils/source
+ */
+
+// $FlowIgnore
+const { getUnicodeUrl } = require("devtools/client/shared/unicode-url");
+
+import { isOriginalSource } from "../utils/source-maps";
+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 { getURL, getFileExtension } from "./sources-tree";
+import { features } from "./prefs";
+
+import type {
+ SourceId,
+ Source,
+ SourceActor,
+ SourceContent,
+ SourceLocation,
+ Thread,
+ URL,
+} from "../types";
+
+import { isFulfilled, type AsyncValue } from "./async-value";
+import type { Symbols, TabsSources } from "../reducers/types";
+
+type transformUrlCallback = string => string;
+
+export const sourceTypes = {
+ coffee: "coffeescript",
+ js: "javascript",
+ jsx: "react",
+ ts: "typescript",
+ tsx: "typescript",
+ vue: "vue",
+};
+
+const javascriptLikeExtensions = ["marko", "es6", "vue", "jsm"];
+
+function getPath(source: Source): Array<string> {
+ const { path } = getURL(source);
+ 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: ?Source) {
+ if (!source) {
+ return false;
+ }
+
+ if (!source.url) {
+ return false;
+ }
+
+ if (!features.originalBlackbox && isOriginalSource(source)) {
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * 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: Source, content: SourceContent): boolean {
+ const extension = getFileExtension(source).toLowerCase();
+ const contentType = content.type === "wasm" ? null : content.contentType;
+ return (
+ javascriptLikeExtensions.includes(extension) ||
+ !!(contentType && contentType.includes("javascript"))
+ );
+}
+
+/**
+ * @memberof utils/source
+ * @static
+ */
+export function isPretty(source: Source): boolean {
+ return isPrettyURL(source.url);
+}
+
+export function isPrettyURL(url: URL): boolean {
+ return url ? url.endsWith(":formatted") : false;
+}
+
+export function isThirdParty(source: Source): boolean {
+ const { url } = source;
+ if (!source || !url) {
+ return false;
+ }
+
+ return url.includes("node_modules") || url.includes("bower_components");
+}
+
+/**
+ * @memberof utils/source
+ * @static
+ */
+export function getPrettySourceURL(url: ?URL): string {
+ if (!url) {
+ url = "";
+ }
+ return `${url}:formatted`;
+}
+
+/**
+ * @memberof utils/source
+ * @static
+ */
+export function getRawSourceURL(url: URL): string {
+ return url && url.endsWith(":formatted")
+ ? url.slice(0, -":formatted".length)
+ : url;
+}
+
+function resolveFileURL(
+ url: URL,
+ transformUrl: transformUrlCallback = initialUrl => initialUrl,
+ truncate: boolean = true
+): string {
+ url = getRawSourceURL(url || "");
+ const name = transformUrl(url);
+ if (!truncate) {
+ return name;
+ }
+ return endTruncateStr(name, 50);
+}
+
+export function getFormattedSourceId(id: string): string {
+ const firstIndex = id.indexOf("/");
+ const secondIndex = id.indexOf("/", firstIndex);
+ return `SOURCE${id.slice(firstIndex, secondIndex)}`;
+}
+
+/**
+ * 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: Source,
+ rawSourceURL: URL = getRawSourceURL(source.url)
+): string {
+ const { id } = source;
+ if (!rawSourceURL) {
+ return getFormattedSourceId(id);
+ }
+
+ const { filename } = getURL(source);
+ return getRawSourceURL(filename);
+}
+
+/**
+ * Provides a middle-trunated filename
+ *
+ * @memberof utils/source
+ * @static
+ */
+export function getTruncatedFileName(
+ source: Source,
+ querystring: string = "",
+ length: number = 30
+): string {
+ 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: Source,
+ sources: Source[] | TabsSources
+): string | void {
+ 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 == 0) {
+ 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: Source, truncate: boolean = true): string {
+ const { url, id } = source;
+ if (!url) {
+ return getFormattedSourceId(id);
+ }
+
+ return resolveFileURL(url, getUnicodeUrl, truncate);
+}
+
+const contentTypeModeMap = {
+ "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" },
+};
+
+export function getSourcePath(url: URL): string {
+ 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: SourceContent): number {
+ 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;
+}
+
+/**
+ *
+ * Checks if a source is minified based on some heuristics
+ * @param key
+ * @param text
+ * @return boolean
+ * @memberof utils/source
+ * @static
+ */
+
+/**
+ *
+ * Returns Code Mirror mode for source content type
+ * @param contentType
+ * @return String
+ * @memberof utils/source
+ * @static
+ */
+// eslint-disable-next-line complexity
+export function getMode(
+ source: Source,
+ content: SourceContent,
+ symbols?: Symbols
+): { name: string, base?: Object } {
+ const extension = getFileExtension(source);
+
+ if (content.type !== "text") {
+ return { name: "text" };
+ }
+
+ const { contentType, value: text } = content;
+
+ if (extension === "jsx" || (symbols && symbols.hasJsx)) {
+ if (symbols && symbols.hasTypes) {
+ return { name: "text/typescript-jsx" };
+ }
+ return { name: "jsx" };
+ }
+
+ if (symbols && symbols.hasTypes) {
+ if (symbols.hasJsx) {
+ return { name: "text/typescript-jsx" };
+ }
+
+ return { name: "text/typescript" };
+ }
+
+ const languageMimeMap = [
+ { ext: "c", mode: "text/x-csrc" },
+ { ext: "kt", mode: "text/x-kotlin" },
+ { ext: "cpp", mode: "text/x-c++src" },
+ { ext: "m", mode: "text/x-objectivec" },
+ { ext: "rs", mode: "text/x-rustsrc" },
+ { ext: "hx", mode: "text/x-haxe" },
+ ];
+
+ // check for C and other non JS languages
+ const result = languageMimeMap.find(({ ext }) => extension === ext);
+ if (result !== undefined) {
+ return { name: result.mode };
+ }
+
+ // if the url ends with a known Javascript-like URL, provide JavaScript mode.
+ // uses the first part of the URL to ignore query string
+ if (javascriptLikeExtensions.find(ext => ext === extension)) {
+ return { name: "javascript" };
+ }
+
+ // 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 { name: "htmlmixed" };
+ }
+ return { name: "text" };
+ }
+
+ // // @flow or /* @flow */
+ if (text.match(/^\s*(\/\/ @flow|\/\* @flow \*\/)/)) {
+ return contentTypeModeMap["text/typescript"];
+ }
+
+ if (/script|elm|jsx|clojure|wasm|html/.test(contentType)) {
+ if (contentType in contentTypeModeMap) {
+ return contentTypeModeMap[contentType];
+ }
+
+ return contentTypeModeMap["text/javascript"];
+ }
+
+ if (isHTMLLike) {
+ return { name: "htmlmixed" };
+ }
+
+ return { name: "text" };
+}
+
+export function isInlineScript(source: SourceActor): boolean {
+ return source.introductionType === "scriptElement";
+}
+
+function getNthLine(str: string, lineNum: number) {
+ 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: SourceId,
+ asyncContent: AsyncValue<SourceContent> | null,
+ line: number
+ ) => {
+ 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: SourceId,
+ asyncContent: AsyncValue<SourceContent> | null,
+ location: SourceLocation
+): string {
+ const { column, line = 0 } = location;
+
+ const lineText = getLineText(sourceId, asyncContent, line);
+ return lineText.slice(column, column + 100).trim();
+}
+
+export function getSourceClassnames(
+ source: ?Object,
+ symbols: ?Symbols
+): string {
+ // Conditionals should be ordered by priority of icon!
+ const defaultClassName = "file";
+
+ if (!source || !source.url) {
+ return defaultClassName;
+ }
+
+ if (isPretty(source)) {
+ return "prettyPrint";
+ }
+
+ if (source.isBlackBoxed) {
+ return "blackBox";
+ }
+
+ if (symbols && !symbols.loading && symbols.framework) {
+ return symbols.framework.toLowerCase();
+ }
+
+ if (isUrlExtension(source.url)) {
+ return "extension";
+ }
+
+ return sourceTypes[getFileExtension(source)] || defaultClassName;
+}
+
+export function getRelativeUrl(source: Source, root: string): string {
+ const { group, path } = getURL(source);
+ if (!root) {
+ return path;
+ }
+
+ // + 1 removes the leading "/"
+ const url = group + path;
+ return url.slice(url.indexOf(root) + root.length + 1);
+}
+
+export function underRoot(
+ source: Source,
+ root: string,
+ threads: Array<Thread>
+): boolean {
+ // source.url doesn't include thread actor ID, so remove the thread actor ID from the root
+ threads.forEach(thread => {
+ if (root.includes(thread.actor)) {
+ root = root.slice(thread.actor.length + 1);
+ }
+ });
+
+ if (source.url && source.url.includes("chrome://")) {
+ const { group, path } = getURL(source);
+ return (group + path).includes(root);
+ }
+
+ return !!source.url && source.url.includes(root);
+}
+
+export function isOriginal(source: Source): boolean {
+ // Pretty-printed sources are given original IDs, so no need
+ // for any additional check
+ return isOriginalSource(source);
+}
+
+export function isGenerated(source: Source): boolean {
+ return !isOriginal(source);
+}
+
+export function getSourceQueryString(source: ?Source) {
+ if (!source) {
+ return;
+ }
+
+ return parseURL(getRawSourceURL(source.url)).search;
+}
+
+export function isUrlExtension(url: URL): boolean {
+ return url.includes("moz-extension:") || url.includes("chrome-extension");
+}
+
+export function isExtensionDirectoryPath(url: URL): ?boolean {
+ if (isUrlExtension(url)) {
+ const urlArr = url.replace(/\/+/g, "/").split("/");
+ let extensionIndex = urlArr.indexOf("moz-extension:");
+ if (extensionIndex === -1) {
+ extensionIndex = urlArr.indexOf("chrome-extension:");
+ }
+ return !urlArr[extensionIndex + 2];
+ }
+}
+
+export function getPlainUrl(url: URL): string {
+ const queryStart = url.indexOf("?");
+ return queryStart !== -1 ? url.slice(0, queryStart) : url;
+}
diff --git a/devtools/client/debugger/src/utils/sources-tree/addToTree.js b/devtools/client/debugger/src/utils/sources-tree/addToTree.js
new file mode 100644
index 0000000000..fdc1f97ccd
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/addToTree.js
@@ -0,0 +1,187 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import {
+ nodeHasChildren,
+ isPathDirectory,
+ isInvalidUrl,
+ partIsFile,
+ createSourceNode,
+ createDirectoryNode,
+ getPathParts,
+ type PathPart,
+} from "./utils";
+import { createTreeNodeMatcher, findNodeInContents } from "./treeOrder";
+import { getDisplayURL } from "./getURL";
+
+import type { ParsedURL } from "./getURL";
+import type { TreeDirectory, TreeNode } from "./types";
+import type { DisplaySource, Source } from "../../types";
+
+function createNodeInTree(
+ part: string,
+ path: string,
+ tree: TreeDirectory,
+ index: number
+): TreeDirectory {
+ const node = createDirectoryNode(part, path, []);
+
+ // we are modifying the tree
+ const contents = tree.contents.slice(0);
+ contents.splice(index, 0, node);
+ tree.contents = contents;
+
+ return node;
+}
+
+/*
+ * Look for the child node
+ * 1. if it exists return it
+ * 2. if it does not exist create it
+ */
+function findOrCreateNode(
+ parts: PathPart[],
+ subTree: TreeDirectory,
+ path: string,
+ part: string,
+ index: number,
+ url: Object,
+ debuggeeHost: ?string,
+ source: Source
+): TreeDirectory {
+ const addedPartIsFile = partIsFile(index, parts, url);
+
+ const { found: childFound, index: childIndex } = findNodeInContents(
+ subTree,
+ createTreeNodeMatcher(part, !addedPartIsFile, debuggeeHost)
+ );
+
+ // we create and enter the new node
+ if (!childFound) {
+ return createNodeInTree(part, path, subTree, childIndex);
+ }
+
+ // we found a path with the same name as the part. We need to determine
+ // if this is the correct child, or if we have a naming conflict
+ const child = subTree.contents[childIndex];
+ const childIsFile = !nodeHasChildren(child);
+
+ // if we have a naming conflict, we'll create a new node
+ if (childIsFile != addedPartIsFile) {
+ // pass true to findNodeInContents to sort node by url
+ const { index: insertIndex } = findNodeInContents(
+ subTree,
+ createTreeNodeMatcher(part, !addedPartIsFile, debuggeeHost, source, true)
+ );
+ return createNodeInTree(part, path, subTree, insertIndex);
+ }
+
+ // if there is no naming conflict, we can traverse into the child
+ return (child: any);
+}
+
+/*
+ * walk the source tree to the final node for a given url,
+ * adding new nodes along the way
+ */
+function traverseTree(
+ url: ParsedURL,
+ tree: TreeDirectory,
+ debuggeeHost: ?string,
+ source: Source,
+ thread: string
+): TreeNode {
+ const parts = getPathParts(url, thread, debuggeeHost);
+ return parts.reduce(
+ (subTree, { part, path, debuggeeHostIfRoot }, index) =>
+ findOrCreateNode(
+ parts,
+ subTree,
+ path,
+ part,
+ index,
+ url,
+ debuggeeHostIfRoot,
+ source
+ ),
+ tree
+ );
+}
+
+/*
+ * Add a source file to a directory node in the tree
+ */
+function addSourceToNode(
+ node: TreeDirectory,
+ url: ParsedURL,
+ source: Source
+): Source | TreeNode[] {
+ const isFile = !isPathDirectory(url.path);
+
+ if (node.type == "source" && !isFile) {
+ throw new Error(`Unexpected type "source" at: ${node.name}`);
+ }
+
+ // if we have a file, and the subtree has no elements, overwrite the
+ // subtree contents with the source
+ if (isFile) {
+ // $FlowIgnore
+ node.type = "source";
+ return source;
+ }
+
+ let { filename } = url;
+
+ if (filename === "(index)" && url.search) {
+ filename = url.search;
+ } else {
+ filename += url.search;
+ }
+
+ const { found: childFound, index: childIndex } = findNodeInContents(
+ node,
+ createTreeNodeMatcher(filename, false, null)
+ );
+
+ // if we are readding an existing file in the node, overwrite the existing
+ // file and return the node's contents
+ if (childFound) {
+ const existingNode = node.contents[childIndex];
+ if (existingNode.type === "source") {
+ existingNode.contents = source;
+ }
+
+ return node.contents;
+ }
+
+ // if this is a new file, add the new file;
+ const newNode = createSourceNode(filename, source.url, source);
+ const contents = node.contents.slice(0);
+ contents.splice(childIndex, 0, newNode);
+ return contents;
+}
+
+/**
+ * @memberof utils/sources-tree
+ * @static
+ */
+export function addToTree(
+ tree: TreeDirectory,
+ source: DisplaySource,
+ debuggeeHost: ?string,
+ thread: string
+): void {
+ const url = getDisplayURL(source, debuggeeHost);
+
+ if (isInvalidUrl(url, source)) {
+ return;
+ }
+
+ const finalNode = traverseTree(url, tree, debuggeeHost, source, thread);
+
+ // $FlowIgnore
+ finalNode.contents = addSourceToNode(finalNode, url, source);
+}
diff --git a/devtools/client/debugger/src/utils/sources-tree/collapseTree.js b/devtools/client/debugger/src/utils/sources-tree/collapseTree.js
new file mode 100644
index 0000000000..57d3e89849
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/collapseTree.js
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { createDirectoryNode } from "./utils";
+
+import type { TreeDirectory, TreeNode } from "./types";
+
+/**
+ * Take an existing source tree, and return a new one with collapsed nodes.
+ */
+function _collapseTree(node: TreeNode, depth: number): TreeNode {
+ // Node is a folder.
+ if (node.type === "directory") {
+ if (!Array.isArray(node.contents)) {
+ console.log(`Expected array at: ${node.path}`);
+ }
+
+ // Node is not a (1) thread and (2) root/domain node,
+ // and only contains 1 item.
+ if (depth > 2 && node.contents.length === 1) {
+ const next = node.contents[0];
+ // Do not collapse if the next node is a leaf node.
+ if (next.type === "directory") {
+ if (!Array.isArray(next.contents)) {
+ console.log(
+ `Expected array at: ${next.name} -- ${
+ node.name
+ } -- ${JSON.stringify(next.contents)}`
+ );
+ }
+ const name = `${node.name}/${next.name}`;
+ const nextNode = createDirectoryNode(name, next.path, next.contents);
+ return _collapseTree(nextNode, depth + 1);
+ }
+ }
+
+ // Map the contents.
+ return createDirectoryNode(
+ node.name,
+ node.path,
+ node.contents.map(next => _collapseTree(next, depth + 1))
+ );
+ }
+
+ // Node is a leaf, not a folder, do not modify it.
+ return node;
+}
+
+export function collapseTree(node: TreeDirectory): TreeDirectory {
+ const tree = _collapseTree(node, 0);
+ return ((tree: any): TreeDirectory);
+}
diff --git a/devtools/client/debugger/src/utils/sources-tree/formatTree.js b/devtools/client/debugger/src/utils/sources-tree/formatTree.js
new file mode 100644
index 0000000000..83598a0e44
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/formatTree.js
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { TreeNode } from "./types";
+
+export function formatTree(
+ tree: TreeNode,
+ depth: number = 0,
+ str: string = ""
+): string {
+ 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;
+}
diff --git a/devtools/client/debugger/src/utils/sources-tree/getDirectories.js b/devtools/client/debugger/src/utils/sources-tree/getDirectories.js
new file mode 100644
index 0000000000..fe5b813d09
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/getDirectories.js
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { createParentMap } from "./utils";
+import flattenDeep from "lodash/flattenDeep";
+import type { TreeNode, TreeDirectory } from "./types";
+import type { Source } from "../../types";
+
+function findSourceItem(sourceTree: TreeDirectory, source: Source): ?TreeNode {
+ function _traverse(subtree: TreeNode) {
+ if (subtree.type === "source") {
+ if (subtree.contents.id === source.id) {
+ return subtree;
+ }
+
+ return null;
+ }
+
+ const matches = subtree.contents.map(child => _traverse(child));
+ return matches && matches.filter(Boolean)[0];
+ }
+
+ return _traverse(sourceTree);
+}
+
+export function findSourceTreeNodes(
+ sourceTree: TreeDirectory,
+ path: string
+): TreeNode[] {
+ function _traverse(subtree: TreeNode) {
+ if (subtree.path.endsWith(path)) {
+ return subtree;
+ }
+
+ if (subtree.type === "directory") {
+ const matches = subtree.contents.map(child => _traverse(child));
+ return matches && matches.filter(Boolean);
+ }
+ }
+
+ const result = _traverse(sourceTree);
+ // $FlowIgnore
+ return Array.isArray(result) ? flattenDeep(result) : result;
+}
+
+function getAncestors(sourceTree: TreeDirectory, item: ?TreeNode) {
+ if (!item) {
+ return null;
+ }
+
+ const parentMap = createParentMap(sourceTree);
+ const directories = [];
+
+ directories.push(item);
+ while (true) {
+ item = parentMap.get(item);
+ if (!item) {
+ return directories;
+ }
+ directories.push(item);
+ }
+}
+
+export function getDirectories(source: Source, sourceTree: TreeDirectory) {
+ const item = findSourceItem(sourceTree, source);
+ const ancestors = getAncestors(sourceTree, item);
+ return ancestors || [sourceTree];
+}
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..78713db17e
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/getURL.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/>. */
+
+// @flow
+
+import { parse } from "../url";
+
+const {
+ getUnicodeHostname,
+ getUnicodeUrlPath,
+ // $FlowIgnore
+} = require("devtools/client/shared/unicode-url");
+
+import type { DisplaySource, Source } from "../../types";
+export type ParsedURL = {
+ path: string,
+ search: string,
+ group: string,
+ filename: string,
+};
+
+export function getFilenameFromPath(pathname?: string): string {
+ 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)";
+ }
+ }
+ return filename;
+}
+
+const NoDomain = "(no domain)";
+const def = { path: "", search: "", group: "", filename: "" };
+
+export function getURL(source: Source, defaultDomain: ?string = ""): ParsedURL {
+ const { url } = source;
+ if (!url) {
+ return def;
+ }
+ return getURLInternal(url, defaultDomain);
+}
+
+export function getDisplayURL(
+ source: DisplaySource,
+ defaultDomain: ?string = ""
+): ParsedURL {
+ const { displayURL } = source;
+ if (!displayURL) {
+ return def;
+ }
+ return getURLInternal(displayURL, defaultDomain);
+}
+
+function getURLInternal(url: string, defaultDomain: ?string): ParsedURL {
+ 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:":
+ case "resource:":
+ return {
+ ...def,
+ path: pathname,
+ search,
+ filename,
+ group: `${protocol}//${host || ""}`,
+ };
+
+ case "webpack:":
+ case "ng:":
+ return {
+ ...def,
+ path: pathname,
+ search,
+ filename,
+ group: `${protocol}//`,
+ };
+
+ case "about:":
+ // An about page is a special case
+ return {
+ ...def,
+ path: "/",
+ search,
+ filename,
+ group: url,
+ };
+
+ case "data:":
+ return {
+ ...def,
+ path: "/",
+ search,
+ group: NoDomain,
+ filename: url,
+ };
+
+ case "":
+ if (pathname && pathname.startsWith("/")) {
+ // use file protocol for a URL like "/foo/bar.js"
+ return {
+ ...def,
+ path: pathname,
+ search,
+ filename,
+ group: "file://",
+ };
+ } else if (!host) {
+ return {
+ ...def,
+ path: pathname,
+ search,
+ group: defaultDomain || "",
+ filename,
+ };
+ }
+ break;
+
+ case "http:":
+ case "https:":
+ return {
+ ...def,
+ path: pathname,
+ search,
+ filename,
+ group: getUnicodeHostname(host),
+ };
+ }
+
+ return {
+ ...def,
+ path: pathname,
+ search,
+ group: protocol ? `${protocol}//` : "",
+ filename,
+ };
+}
diff --git a/devtools/client/debugger/src/utils/sources-tree/index.js b/devtools/client/debugger/src/utils/sources-tree/index.js
new file mode 100644
index 0000000000..e9cc35e1c4
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/index.js
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+/**
+ * Utils for Sources Tree Component
+ * @module utils/sources-tree
+ */
+
+export { addToTree } from "./addToTree";
+export { collapseTree } from "./collapseTree";
+export { formatTree } from "./formatTree";
+export { getDirectories, findSourceTreeNodes } from "./getDirectories";
+export { getFilenameFromPath, getURL } from "./getURL";
+export { sortTree } from "./sortTree";
+export { createTree, updateTree } from "./updateTree";
+
+export * from "./utils";
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..f1439a46e3
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/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(
+ "addToTree.js",
+ "collapseTree.js",
+ "formatTree.js",
+ "getDirectories.js",
+ "getURL.js",
+ "index.js",
+ "sortTree.js",
+ "treeOrder.js",
+ "updateTree.js",
+ "utils.js",
+)
diff --git a/devtools/client/debugger/src/utils/sources-tree/sortTree.js b/devtools/client/debugger/src/utils/sources-tree/sortTree.js
new file mode 100644
index 0000000000..c090917fea
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/sortTree.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { nodeHasChildren, isExactUrlMatch } from "./utils";
+
+import type { TreeDirectory } from "./types";
+import type { URL } from "../../types";
+
+/**
+ * Look at the nodes in the source tree, and determine the index of where to
+ * insert a new node. The ordering is index -> folder -> file.
+ * @memberof utils/sources-tree
+ * @static
+ */
+export function sortTree(tree: TreeDirectory, debuggeeUrl: URL = ""): number {
+ return (tree.contents: any).sort((previousNode, currentNode) => {
+ const currentNodeIsDir = nodeHasChildren(currentNode);
+ const previousNodeIsDir = nodeHasChildren(previousNode);
+ if (currentNode.name === "(index)") {
+ return 1;
+ } else if (previousNode.name === "(index)") {
+ return -1;
+ } else if (isExactUrlMatch(currentNode.name, debuggeeUrl)) {
+ return 1;
+ } else if (isExactUrlMatch(previousNode.name, debuggeeUrl)) {
+ return -1;
+ // If neither is the case, continue to compare alphabetically
+ } else if (previousNodeIsDir && !currentNodeIsDir) {
+ return -1;
+ } else if (!previousNodeIsDir && currentNodeIsDir) {
+ return 1;
+ }
+ return previousNode.name.localeCompare(currentNode.name);
+ });
+}
diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/addToTree.spec.js.snap b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/addToTree.spec.js.snap
new file mode 100644
index 0000000000..a76261e0dc
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/addToTree.spec.js.snap
@@ -0,0 +1,90 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`sources-tree addToTree can add a file to an intermediate directory 1`] = `
+" - root path=
+ - FakeThread path=FakeThread
+ - unpkg.com path=FakeThread/unpkg.com
+ - codemirror@5.1 path=FakeThread/unpkg.com/codemirror@5.1
+ - mode path=FakeThread/unpkg.com/codemirror@5.1/mode
+ - xml path=FakeThread/unpkg.com/codemirror@5.1/mode/xml
+ - xml.js path=FakeThread/unpkg.com/codemirror@5.1/mode/xml/xml.js source_id=server1.conn13.child1/39
+ - codemirror@5.1 path=FakeThread/unpkg.com/codemirror@5.1 source_id=server1.conn13.child1/37
+"
+`;
+
+exports[`sources-tree addToTree correctly parses file sources 1`] = `
+" - root path=
+ - FakeThread path=FakeThread
+ - file:// path=FakeThread/file://
+ - a path=FakeThread/file:///a
+ - b.js path=FakeThread/file:///a/b.js source_id=actor1
+"
+`;
+
+exports[`sources-tree addToTree does not attempt to add two of the same directory 1`] = `
+" - root path=
+ - FakeThread path=FakeThread
+ - davidwalsh.name path=FakeThread/davidwalsh.name
+ - (index) path=https://davidwalsh.name/ source_id=server1.conn13.child1/37
+ - wp-content path=FakeThread/davidwalsh.name/wp-content
+ - prism.js path=FakeThread/davidwalsh.name/wp-content/prism.js source_id=server1.conn13.child1/39
+"
+`;
+
+exports[`sources-tree addToTree does not attempt to add two of the same file 1`] = `
+" - root path=
+ - FakeThread path=FakeThread
+ - davidwalsh.name path=FakeThread/davidwalsh.name
+ - (index) path=https://davidwalsh.name/ source_id=server1.conn13.child1/39
+ - util.js path=FakeThread/davidwalsh.name/util.js source_id=server1.conn13.child1/37
+ - FakeThread2 path=FakeThread2
+ - davidwalsh.name path=FakeThread2/davidwalsh.name
+ - util.js path=FakeThread2/davidwalsh.name/util.js source_id=server1.conn13.child1/37
+"
+`;
+
+exports[`sources-tree addToTree does not mangle encoded URLs 1`] = `
+" - root path=
+ - FakeThread path=FakeThread
+ - example.com path=FakeThread/example.com
+ - foo path=FakeThread/example.com/foo
+ - B9724220.131821496;dc_ver=42.111;sz=468x60;u_sd=2;dc_adk=2020465299;ord=a53rpc;dc_rfl=1,https%3A%2F%2Fdavidwalsh.name%2F$0;xdt=1 path=FakeThread/example.com/foo/B9724220.131821496;dc_ver=42.111;sz=468x60;u_sd=2;dc_adk=2020465299;ord=a53rpc;dc_rfl=1,https%3A%2F%2Fdavidwalsh.name%2F$0;xdt=1 source_id=actor1
+"
+`;
+
+exports[`sources-tree addToTree excludes javascript: URLs from the tree 1`] = `
+" - root path=
+ - FakeThread path=FakeThread
+ - example.com path=FakeThread/example.com
+ - source1.js path=FakeThread/example.com/source1.js source_id=actor2
+"
+`;
+
+exports[`sources-tree addToTree name does include query params 1`] = `
+" - root path=
+ - FakeThread path=FakeThread
+ - example.com path=FakeThread/example.com
+ - foo path=FakeThread/example.com/foo
+ - name.js?bar=3 path=FakeThread/example.com/foo/name.js?bar=3 source_id=actor1
+"
+`;
+
+exports[`sources-tree addToTree replaces a file with a directory 1`] = `
+" - root path=
+ - FakeThread path=FakeThread
+ - unpkg.com path=FakeThread/unpkg.com
+ - codemirror@5.1 path=FakeThread/unpkg.com/codemirror@5.1
+ - mode path=FakeThread/unpkg.com/codemirror@5.1/mode
+ - xml path=FakeThread/unpkg.com/codemirror@5.1/mode/xml
+ - xml.js path=FakeThread/unpkg.com/codemirror@5.1/mode/xml/xml.js source_id=server1.conn13.child1/39
+ - codemirror@5.1 path=FakeThread/unpkg.com/codemirror@5.1 source_id=server1.conn13.child1/37
+"
+`;
+
+exports[`sources-tree addToTree supports data URLs 1`] = `
+" - root path=
+ - FakeThread path=FakeThread
+ - (no domain) path=FakeThread/(no domain)
+ - data:text/html,<script>console.log(123)</script> path=data:text/html,<script>console.log(123)</script> source_id=server1.conn13.child1/39
+"
+`;
diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/collapseTree.spec.js.snap b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/collapseTree.spec.js.snap
new file mode 100644
index 0000000000..529874f8bd
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/collapseTree.spec.js.snap
@@ -0,0 +1,42 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`sources tree collapseTree can collapse a single source 1`] = `
+" - root path=
+ - Main Thread path=Main Thread
+ - example.com path=Main Thread/example.com
+ - a/b path=Main Thread/example.com/a/b
+ - c.js path=Main Thread/example.com/a/b/c.js source_id=actor1
+"
+`;
+
+exports[`sources tree collapseTree correctly merges in a collapsed source with a deeper level 1`] = `
+" - root path=
+ - Main Thread path=Main Thread
+ - example.com path=Main Thread/example.com
+ - a/b path=Main Thread/example.com/a/b
+ - c/d path=Main Thread/example.com/a/b/c/d
+ - e.js path=Main Thread/example.com/a/b/c/d/e.js source_id=actor2
+ - c.js path=Main Thread/example.com/a/b/c.js source_id=actor1
+"
+`;
+
+exports[`sources tree collapseTree correctly merges in a collapsed source with a shallower level 1`] = `
+" - root path=
+ - Main Thread path=Main Thread
+ - example.com path=Main Thread/example.com
+ - a/b path=Main Thread/example.com/a/b
+ - c.js path=Main Thread/example.com/a/b/c.js source_id=actor1
+ - x.js path=Main Thread/example.com/a/b/x.js source_id=actor3
+"
+`;
+
+exports[`sources tree collapseTree correctly merges in a collapsed source with the same level 1`] = `
+" - root path=
+ - Main Thread path=Main Thread
+ - example.com path=Main Thread/example.com
+ - a/b path=Main Thread/example.com/a/b
+ - c/d path=Main Thread/example.com/a/b/c/d
+ - e.js path=Main Thread/example.com/a/b/c/d/e.js source_id=actor2
+ - c.js path=Main Thread/example.com/a/b/c.js source_id=actor1
+"
+`;
diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/updateTree.spec.js.snap b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/updateTree.spec.js.snap
new file mode 100644
index 0000000000..d6bc9a653e
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/updateTree.spec.js.snap
@@ -0,0 +1,233 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`calls updateTree.js adds one source 1`] = `
+"{
+ \\"type\\": \\"directory\\",
+ \\"name\\": \\"root\\",
+ \\"path\\": \\"\\",
+ \\"contents\\": [
+ {
+ \\"type\\": \\"directory\\",
+ \\"name\\": \\"FakeThread\\",
+ \\"path\\": \\"FakeThread\\",
+ \\"contents\\": [
+ {
+ \\"type\\": \\"directory\\",
+ \\"name\\": \\"davidwalsh.name\\",
+ \\"path\\": \\"FakeThread/davidwalsh.name\\",
+ \\"contents\\": [
+ {
+ \\"type\\": \\"source\\",
+ \\"name\\": \\"(index)\\",
+ \\"path\\": \\"https://davidwalsh.name/\\",
+ \\"contents\\": {
+ \\"id\\": \\"server1.conn13.child1/39\\",
+ \\"url\\": \\"https://davidwalsh.name/\\",
+ \\"isBlackBoxed\\": false,
+ \\"isPrettyPrinted\\": false,
+ \\"relativeUrl\\": \\"https://davidwalsh.name/\\",
+ \\"isWasm\\": false,
+ \\"extensionName\\": null,
+ \\"isExtension\\": false,
+ \\"isOriginal\\": false,
+ \\"displayURL\\": \\"https://davidwalsh.name/\\"
+ }
+ },
+ {
+ \\"type\\": \\"source\\",
+ \\"name\\": \\"source1.js\\",
+ \\"path\\": \\"FakeThread/davidwalsh.name/source1.js\\",
+ \\"contents\\": {
+ \\"id\\": \\"server1.conn13.child1/37\\",
+ \\"url\\": \\"https://davidwalsh.name/source1.js\\",
+ \\"isBlackBoxed\\": false,
+ \\"isPrettyPrinted\\": false,
+ \\"relativeUrl\\": \\"https://davidwalsh.name/source1.js\\",
+ \\"isWasm\\": false,
+ \\"extensionName\\": null,
+ \\"isExtension\\": false,
+ \\"isOriginal\\": false,
+ \\"displayURL\\": \\"https://davidwalsh.name/source1.js\\"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}"
+`;
+
+exports[`calls updateTree.js adds two sources 1`] = `
+"{
+ \\"type\\": \\"directory\\",
+ \\"name\\": \\"root\\",
+ \\"path\\": \\"\\",
+ \\"contents\\": [
+ {
+ \\"type\\": \\"directory\\",
+ \\"name\\": \\"FakeThread\\",
+ \\"path\\": \\"FakeThread\\",
+ \\"contents\\": [
+ {
+ \\"type\\": \\"directory\\",
+ \\"name\\": \\"davidwalsh.name\\",
+ \\"path\\": \\"FakeThread/davidwalsh.name\\",
+ \\"contents\\": [
+ {
+ \\"type\\": \\"source\\",
+ \\"name\\": \\"(index)\\",
+ \\"path\\": \\"https://davidwalsh.name/\\",
+ \\"contents\\": {
+ \\"id\\": \\"server1.conn13.child1/39\\",
+ \\"url\\": \\"https://davidwalsh.name/\\",
+ \\"isBlackBoxed\\": false,
+ \\"isPrettyPrinted\\": false,
+ \\"relativeUrl\\": \\"https://davidwalsh.name/\\",
+ \\"isWasm\\": false,
+ \\"extensionName\\": null,
+ \\"isExtension\\": false,
+ \\"isOriginal\\": false,
+ \\"displayURL\\": \\"https://davidwalsh.name/\\"
+ }
+ },
+ {
+ \\"type\\": \\"source\\",
+ \\"name\\": \\"source1.js\\",
+ \\"path\\": \\"FakeThread/davidwalsh.name/source1.js\\",
+ \\"contents\\": {
+ \\"id\\": \\"server1.conn13.child1/37\\",
+ \\"url\\": \\"https://davidwalsh.name/source1.js\\",
+ \\"isBlackBoxed\\": false,
+ \\"isPrettyPrinted\\": false,
+ \\"relativeUrl\\": \\"https://davidwalsh.name/source1.js\\",
+ \\"isWasm\\": false,
+ \\"extensionName\\": null,
+ \\"isExtension\\": false,
+ \\"isOriginal\\": false,
+ \\"displayURL\\": \\"https://davidwalsh.name/source1.js\\"
+ }
+ },
+ {
+ \\"type\\": \\"source\\",
+ \\"name\\": \\"source2.js\\",
+ \\"path\\": \\"FakeThread/davidwalsh.name/source2.js\\",
+ \\"contents\\": {
+ \\"id\\": \\"server1.conn13.child1/40\\",
+ \\"url\\": \\"https://davidwalsh.name/source2.js\\",
+ \\"isBlackBoxed\\": false,
+ \\"isPrettyPrinted\\": false,
+ \\"relativeUrl\\": \\"https://davidwalsh.name/source2.js\\",
+ \\"isWasm\\": false,
+ \\"extensionName\\": null,
+ \\"isExtension\\": false,
+ \\"isOriginal\\": false,
+ \\"displayURL\\": \\"https://davidwalsh.name/source2.js\\"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}"
+`;
+
+exports[`calls updateTree.js shows all the sources 1`] = `
+"{
+ \\"type\\": \\"directory\\",
+ \\"name\\": \\"root\\",
+ \\"path\\": \\"\\",
+ \\"contents\\": [
+ {
+ \\"type\\": \\"directory\\",
+ \\"name\\": \\"FakeThread\\",
+ \\"path\\": \\"FakeThread\\",
+ \\"contents\\": [
+ {
+ \\"type\\": \\"directory\\",
+ \\"name\\": \\"davidwalsh.name\\",
+ \\"path\\": \\"FakeThread/davidwalsh.name\\",
+ \\"contents\\": [
+ {
+ \\"type\\": \\"source\\",
+ \\"name\\": \\"(index)\\",
+ \\"path\\": \\"https://davidwalsh.name/\\",
+ \\"contents\\": {
+ \\"id\\": \\"server1.conn13.child1/39\\",
+ \\"url\\": \\"https://davidwalsh.name/\\",
+ \\"isBlackBoxed\\": false,
+ \\"isPrettyPrinted\\": false,
+ \\"relativeUrl\\": \\"https://davidwalsh.name/\\",
+ \\"isWasm\\": false,
+ \\"extensionName\\": null,
+ \\"isExtension\\": false,
+ \\"isOriginal\\": false,
+ \\"displayURL\\": \\"https://davidwalsh.name/\\"
+ }
+ },
+ {
+ \\"type\\": \\"source\\",
+ \\"name\\": \\"source1.js\\",
+ \\"path\\": \\"FakeThread/davidwalsh.name/source1.js\\",
+ \\"contents\\": {
+ \\"id\\": \\"server1.conn13.child1/37\\",
+ \\"url\\": \\"https://davidwalsh.name/source1.js\\",
+ \\"isBlackBoxed\\": false,
+ \\"isPrettyPrinted\\": false,
+ \\"relativeUrl\\": \\"https://davidwalsh.name/source1.js\\",
+ \\"isWasm\\": false,
+ \\"extensionName\\": null,
+ \\"isExtension\\": false,
+ \\"isOriginal\\": false,
+ \\"displayURL\\": \\"https://davidwalsh.name/source1.js\\"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}"
+`;
+
+exports[`calls updateTree.js update sources that change their display URL 1`] = `
+"{
+ \\"type\\": \\"directory\\",
+ \\"name\\": \\"root\\",
+ \\"path\\": \\"\\",
+ \\"contents\\": [
+ {
+ \\"type\\": \\"directory\\",
+ \\"name\\": \\"FakeThread\\",
+ \\"path\\": \\"FakeThread\\",
+ \\"contents\\": [
+ {
+ \\"type\\": \\"directory\\",
+ \\"name\\": \\"davidwalsh.name\\",
+ \\"path\\": \\"FakeThread/davidwalsh.name\\",
+ \\"contents\\": [
+ {
+ \\"type\\": \\"source\\",
+ \\"name\\": \\"?param\\",
+ \\"path\\": \\"FakeThread/davidwalsh.name/?param\\",
+ \\"contents\\": {
+ \\"id\\": \\"server1.conn13.child1/39\\",
+ \\"url\\": \\"https://davidwalsh.name/?param\\",
+ \\"isBlackBoxed\\": false,
+ \\"isPrettyPrinted\\": false,
+ \\"relativeUrl\\": \\"https://davidwalsh.name/?param\\",
+ \\"isWasm\\": false,
+ \\"extensionName\\": null,
+ \\"isExtension\\": false,
+ \\"isOriginal\\": false,
+ \\"displayURL\\": \\"https://davidwalsh.name/?param\\"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}"
+`;
diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/addToTree.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/addToTree.spec.js
new file mode 100644
index 0000000000..392d854bea
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/tests/addToTree.spec.js
@@ -0,0 +1,374 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+/* eslint max-nested-callbacks: ["error", 4]*/
+
+import { makeMockDisplaySource } from "../../../utils/test-mockup";
+
+import {
+ addToTree,
+ createDirectoryNode,
+ createSourceNode,
+ createTree,
+ formatTree,
+ nodeHasChildren,
+} from "../index";
+
+type RawSource = {| url: string, id: string, actors?: any |};
+
+function createSourcesMap(sources: RawSource[]) {
+ const sourcesMap = sources.reduce((map, source) => {
+ map[source.id] = makeMockDisplaySource(source.url, source.id);
+ return map;
+ }, {});
+
+ return sourcesMap;
+}
+
+function createSourcesList(sources: { url: string, id?: string }[]) {
+ return sources.map((s, i) => makeMockDisplaySource(s.url, s.id));
+}
+
+function getChildNode(tree, ...path) {
+ return path.reduce((child, index) => child.contents[index], tree);
+}
+
+describe("sources-tree", () => {
+ describe("addToTree", () => {
+ it("should provide node API", () => {
+ const source = makeMockDisplaySource(
+ "http://example.com/a/b/c.js",
+ "actor1"
+ );
+
+ const root = createDirectoryNode("root", "", [
+ createSourceNode("foo", "/foo", source),
+ ]);
+
+ expect(root.name).toBe("root");
+ expect(nodeHasChildren(root)).toBe(true);
+ expect(root.contents).toHaveLength(1);
+
+ const child = root.contents[0];
+ expect(child.name).toBe("foo");
+ expect(child.path).toBe("/foo");
+ expect(child.contents).toBe(source);
+ expect(nodeHasChildren(child)).toBe(false);
+ });
+
+ it("builds a path-based tree", () => {
+ const source1 = makeMockDisplaySource(
+ "http://example.com/foo/source1.js",
+ "actor1"
+ );
+ const tree = createDirectoryNode("root", "", []);
+
+ addToTree(tree, source1, "http://example.com/", "FakeThread");
+ expect(tree.contents).toHaveLength(1);
+
+ const base = tree.contents[0].contents[0];
+ expect(base.name).toBe("example.com");
+ expect(base.contents).toHaveLength(1);
+
+ const fooNode = base.contents[0];
+ expect(fooNode.name).toBe("foo");
+ expect(fooNode.contents).toHaveLength(1);
+
+ const source1Node = fooNode.contents[0];
+ expect(source1Node.name).toBe("source1.js");
+ });
+
+ it("builds a path-based tree for webpack URLs", () => {
+ const source1 = makeMockDisplaySource(
+ "webpack:///foo/source1.js",
+ "actor1"
+ );
+ const tree = createDirectoryNode("root", "", []);
+
+ addToTree(tree, source1, "http://example.com/", "");
+ expect(tree.contents).toHaveLength(1);
+
+ const base = tree.contents[0];
+ expect(base.name).toBe("webpack://");
+ expect(base.contents).toHaveLength(1);
+
+ const fooNode = base.contents[0];
+ expect(fooNode.name).toBe("foo");
+ expect(fooNode.contents).toHaveLength(1);
+
+ const source1Node = fooNode.contents[0];
+ expect(source1Node.name).toBe("source1.js");
+ });
+
+ it("builds a path-based tree for webpack URLs with absolute path", () => {
+ const source1 = makeMockDisplaySource(
+ "webpack:////Users/foo/source1.js",
+ "actor1"
+ );
+ const tree = createDirectoryNode("root", "", []);
+
+ addToTree(tree, source1, "http://example.com/", "");
+ expect(tree.contents).toHaveLength(1);
+
+ const base = tree.contents[0];
+ expect(base.name).toBe("webpack://");
+ expect(base.contents).toHaveLength(1);
+
+ const emptyNode = base.contents[0];
+ expect(emptyNode.name).toBe("");
+ expect(emptyNode.contents).toHaveLength(1);
+
+ const userNode = emptyNode.contents[0];
+ expect(userNode.name).toBe("Users");
+ expect(userNode.contents).toHaveLength(1);
+
+ const fooNode = userNode.contents[0];
+ expect(fooNode.name).toBe("foo");
+ expect(fooNode.contents).toHaveLength(1);
+
+ const source1Node = fooNode.contents[0];
+ expect(source1Node.name).toBe("source1.js");
+ });
+
+ it("handles url with no filename", function() {
+ const source1 = makeMockDisplaySource("http://example.com/", "actor1");
+ const tree = createDirectoryNode("root", "", []);
+
+ addToTree(tree, source1, "http://example.com/", "");
+ expect(tree.contents).toHaveLength(1);
+
+ const base = tree.contents[0];
+ expect(base.name).toBe("example.com");
+ expect(base.contents).toHaveLength(1);
+
+ const indexNode = base.contents[0];
+ expect(indexNode.name).toBe("(index)");
+ });
+
+ it("does not mangle encoded URLs", () => {
+ const sourceName = // eslint-disable-next-line max-len
+ "B9724220.131821496;dc_ver=42.111;sz=468x60;u_sd=2;dc_adk=2020465299;ord=a53rpc;dc_rfl=1,https%3A%2F%2Fdavidwalsh.name%2F$0;xdt=1";
+
+ const source1 = makeMockDisplaySource(
+ `https://example.com/foo/${sourceName}`,
+ "actor1"
+ );
+
+ const tree = createDirectoryNode("root", "", []);
+
+ addToTree(tree, source1, "http://example.com/", "FakeThread");
+ const childNode = getChildNode(tree, 0, 0, 0, 0);
+ expect(childNode.name).toEqual(sourceName);
+ expect(formatTree(tree)).toMatchSnapshot();
+ });
+
+ it("name does include query params", () => {
+ const sourceName = "name.js?bar=3";
+
+ const source1 = makeMockDisplaySource(
+ `https://example.com/foo/${sourceName}`,
+ "actor1"
+ );
+
+ const tree = createDirectoryNode("root", "", []);
+
+ addToTree(tree, source1, "http://example.com/", "FakeThread");
+ expect(formatTree(tree)).toMatchSnapshot();
+ });
+
+ it("does not attempt to add two of the same directory", () => {
+ const sources = [
+ {
+ id: "server1.conn13.child1/39",
+ url: "https://davidwalsh.name/wp-content/prism.js",
+ },
+ {
+ id: "server1.conn13.child1/37",
+ url: "https://davidwalsh.name/",
+ },
+ ];
+
+ const sourceMap = { FakeThread: createSourcesMap(sources) };
+ const tree = createTree({
+ sources: sourceMap,
+ debuggeeUrl: "",
+ threads: [
+ {
+ actor: "FakeThread",
+ name: "FakeThread",
+ url: "https://davidwalsh.name",
+ targetType: "worker",
+ isTopLevel: false,
+ },
+ ],
+ }).sourceTree;
+
+ expect(tree.contents[0].contents).toHaveLength(1);
+ const subtree = tree.contents[0].contents[0];
+ expect(subtree.contents).toHaveLength(2);
+ expect(formatTree(tree)).toMatchSnapshot();
+ });
+
+ it("supports data URLs", () => {
+ const sources = [
+ {
+ id: "server1.conn13.child1/39",
+ url: "data:text/html,<script>console.log(123)</script>",
+ },
+ ];
+
+ const sourceMap = { FakeThread: createSourcesMap(sources) };
+ const tree = createTree({
+ sources: sourceMap,
+ debuggeeUrl: "",
+ threads: [
+ {
+ actor: "FakeThread",
+ url: "https://davidwalsh.name",
+ targetType: "worker",
+ name: "FakeThread",
+ isTopLevel: false,
+ },
+ ],
+ }).sourceTree;
+ expect(formatTree(tree)).toMatchSnapshot();
+ });
+
+ it("does not attempt to add two of the same file", () => {
+ const sources = [
+ {
+ id: "server1.conn13.child1/39",
+ url: "https://davidwalsh.name/",
+ },
+ {
+ id: "server1.conn13.child1/37",
+ url: "https://davidwalsh.name/util.js",
+ },
+ ];
+
+ const sourceMap = {
+ FakeThread: createSourcesMap(sources),
+ FakeThread2: createSourcesMap([sources[1]]),
+ };
+
+ const tree = createTree({
+ sources: sourceMap,
+ debuggeeUrl: "https://davidwalsh.name",
+ threads: [
+ {
+ actor: "FakeThread",
+ name: "FakeThread",
+ url: "https://davidwalsh.name",
+ targetType: "worker",
+ isTopLevel: false,
+ },
+ {
+ actor: "FakeThread2",
+ name: "FakeThread2",
+ url: "https://davidwalsh.name/WorkerA.js",
+ targetType: "worker",
+ isTopLevel: false,
+ },
+ ],
+ }).sourceTree;
+
+ expect(tree.contents[0].contents).toHaveLength(1);
+ const subtree = tree.contents[0].contents[0];
+ expect(subtree.contents).toHaveLength(2);
+ const subtree2 = tree.contents[1].contents[0];
+ expect(subtree2.contents).toHaveLength(1);
+ expect(formatTree(tree)).toMatchSnapshot();
+ });
+
+ it("excludes javascript: URLs from the tree", () => {
+ const source1 = makeMockDisplaySource(
+ "javascript:alert('Hello World')",
+ "actor1"
+ );
+ const source2 = makeMockDisplaySource(
+ "http://example.com/source1.js",
+ "actor2"
+ );
+ const source3 = makeMockDisplaySource(
+ "javascript:let i = 10; while (i > 0) i--; console.log(i);",
+ "actor3"
+ );
+ const tree = createDirectoryNode("root", "", []);
+
+ addToTree(tree, source1, "http://example.com/", "FakeThread");
+ addToTree(tree, source2, "http://example.com/", "FakeThread");
+ addToTree(tree, source3, "http://example.com/", "FakeThread");
+
+ const base = tree.contents[0].contents[0];
+ expect(tree.contents).toHaveLength(1);
+
+ const source1Node = base.contents[0];
+ expect(source1Node.name).toBe("source1.js");
+ expect(formatTree(tree)).toMatchSnapshot();
+ });
+
+ it("correctly parses file sources", () => {
+ const source = makeMockDisplaySource("file:///a/b.js", "actor1");
+ const tree = createDirectoryNode("root", "", []);
+
+ addToTree(tree, source, "file:///a/index.html", "FakeThread");
+ expect(tree.contents).toHaveLength(1);
+
+ const base = tree.contents[0].contents[0];
+ expect(base.name).toBe("file://");
+ expect(base.contents).toHaveLength(1);
+
+ const aNode = base.contents[0];
+ expect(aNode.name).toBe("a");
+ expect(aNode.contents).toHaveLength(1);
+
+ const bNode = aNode.contents[0];
+ expect(bNode.name).toBe("b.js");
+ expect(formatTree(tree)).toMatchSnapshot();
+ });
+
+ it("can add a file to an intermediate directory", () => {
+ const testData = [
+ {
+ id: "server1.conn13.child1/39",
+ url: "https://unpkg.com/codemirror@5.1/mode/xml/xml.js",
+ },
+ {
+ id: "server1.conn13.child1/37",
+ url: "https://unpkg.com/codemirror@5.1",
+ },
+ ];
+
+ const sources = createSourcesList(testData);
+ const tree = createDirectoryNode("root", "", []);
+ sources.forEach(source =>
+ addToTree(tree, source, "https://unpkg.com/", "FakeThread")
+ );
+ expect(formatTree(tree)).toMatchSnapshot();
+ });
+
+ it("replaces a file with a directory", () => {
+ const testData = [
+ {
+ id: "server1.conn13.child1/37",
+ url: "https://unpkg.com/codemirror@5.1",
+ },
+
+ {
+ id: "server1.conn13.child1/39",
+ url: "https://unpkg.com/codemirror@5.1/mode/xml/xml.js",
+ },
+ ];
+
+ const sources = createSourcesList(testData);
+ const tree = createDirectoryNode("root", "", []);
+ sources.forEach(source =>
+ addToTree(tree, source, "https://unpkg.com/", "FakeThread")
+ );
+ expect(formatTree(tree)).toMatchSnapshot();
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/collapseTree.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/collapseTree.spec.js
new file mode 100644
index 0000000000..fba925c2ed
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/tests/collapseTree.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/>. */
+
+// @flow
+
+import { makeMockDisplaySource } from "../../../utils/test-mockup";
+
+import {
+ collapseTree,
+ formatTree,
+ addToTree,
+ createDirectoryNode,
+} from "../index";
+
+const abcSource = makeMockDisplaySource(
+ "http://example.com/a/b/c.js",
+ "actor1"
+);
+const abcdeSource = makeMockDisplaySource(
+ "http://example.com/a/b/c/d/e.js",
+ "actor2"
+);
+const abxSource = makeMockDisplaySource(
+ "http://example.com/a/b/x.js",
+ "actor3"
+);
+
+describe("sources tree", () => {
+ describe("collapseTree", () => {
+ it("can collapse a single source", () => {
+ const fullTree = createDirectoryNode("root", "", []);
+ addToTree(fullTree, abcSource, "http://example.com/", "Main Thread");
+ expect(fullTree.contents).toHaveLength(1);
+ const tree = collapseTree(fullTree);
+
+ const host = tree.contents[0].contents[0];
+ expect(host.name).toBe("example.com");
+ expect(host.contents).toHaveLength(1);
+
+ const abFolder = host.contents[0];
+ expect(abFolder.name).toBe("a/b");
+ expect(abFolder.contents).toHaveLength(1);
+
+ const abcNode = abFolder.contents[0];
+ expect(abcNode.name).toBe("c.js");
+ expect(abcNode.path).toBe("Main Thread/example.com/a/b/c.js");
+ expect(formatTree(tree)).toMatchSnapshot();
+ });
+
+ it("correctly merges in a collapsed source with a deeper level", () => {
+ const fullTree = createDirectoryNode("root", "", []);
+ addToTree(fullTree, abcSource, "http://example.com/", "Main Thread");
+ addToTree(fullTree, abcdeSource, "http://example.com/", "Main Thread");
+ const tree = collapseTree(fullTree);
+
+ const host = tree.contents[0].contents[0];
+ expect(host.name).toBe("example.com");
+ expect(host.contents).toHaveLength(1);
+
+ const abFolder = host.contents[0];
+ expect(abFolder.name).toBe("a/b");
+ expect(abFolder.contents).toHaveLength(2);
+
+ const [cdFolder, abcNode] = abFolder.contents;
+ expect(abcNode.name).toBe("c.js");
+ expect(abcNode.path).toBe("Main Thread/example.com/a/b/c.js");
+ expect(cdFolder.name).toBe("c/d");
+
+ const [abcdeNode] = cdFolder.contents;
+ expect(abcdeNode.name).toBe("e.js");
+ expect(abcdeNode.path).toBe("Main Thread/example.com/a/b/c/d/e.js");
+ expect(formatTree(tree)).toMatchSnapshot();
+ });
+
+ it("correctly merges in a collapsed source with a shallower level", () => {
+ const fullTree = createDirectoryNode("root", "", []);
+ addToTree(fullTree, abcSource, "http://example.com/", "Main Thread");
+ addToTree(fullTree, abxSource, "http://example.com/", "Main Thread");
+ const tree = collapseTree(fullTree);
+
+ expect(tree.contents).toHaveLength(1);
+
+ const host = tree.contents[0].contents[0];
+ expect(host.name).toBe("example.com");
+ expect(host.contents).toHaveLength(1);
+
+ const abFolder = host.contents[0];
+ expect(abFolder.name).toBe("a/b");
+ expect(abFolder.contents).toHaveLength(2);
+
+ const [abcNode, abxNode] = abFolder.contents;
+ expect(abcNode.name).toBe("c.js");
+ expect(abcNode.path).toBe("Main Thread/example.com/a/b/c.js");
+ expect(abxNode.name).toBe("x.js");
+ expect(abxNode.path).toBe("Main Thread/example.com/a/b/x.js");
+ expect(formatTree(tree)).toMatchSnapshot();
+ });
+
+ it("correctly merges in a collapsed source with the same level", () => {
+ const fullTree = createDirectoryNode("root", "", []);
+ addToTree(fullTree, abcdeSource, "http://example.com/", "Main Thread");
+ addToTree(fullTree, abcSource, "http://example.com/", "Main Thread");
+ const tree = collapseTree(fullTree);
+
+ expect(tree.contents).toHaveLength(1);
+
+ const host = tree.contents[0].contents[0];
+ expect(host.name).toBe("example.com");
+ expect(host.contents).toHaveLength(1);
+
+ const abFolder = host.contents[0];
+ expect(abFolder.name).toBe("a/b");
+ expect(abFolder.contents).toHaveLength(2);
+
+ const [cdFolder, abcNode] = abFolder.contents;
+ expect(abcNode.name).toBe("c.js");
+ expect(abcNode.path).toBe("Main Thread/example.com/a/b/c.js");
+ expect(cdFolder.name).toBe("c/d");
+
+ const [abcdeNode] = cdFolder.contents;
+ expect(abcdeNode.name).toBe("e.js");
+ expect(abcdeNode.path).toBe("Main Thread/example.com/a/b/c/d/e.js");
+ expect(formatTree(tree)).toMatchSnapshot();
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/getDirectories.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/getDirectories.spec.js
new file mode 100644
index 0000000000..ffcad21905
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/tests/getDirectories.spec.js
@@ -0,0 +1,98 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { makeMockDisplaySource } from "../../../utils/test-mockup";
+
+import { getDirectories, findSourceTreeNodes, createTree } from "../index";
+
+function formatDirectories(source, tree) {
+ const paths: any = getDirectories(source, tree);
+ return paths.map(node => node.path);
+}
+
+function createSources(urls) {
+ return {
+ FakeThread: urls.reduce((sources, url, index) => {
+ const id = `a${index}`;
+ sources[id] = makeMockDisplaySource(url, id);
+ return sources;
+ }, {}),
+ };
+}
+
+describe("getDirectories", () => {
+ it("gets a source's ancestor directories", function() {
+ const sources = createSources([
+ "http://a/b.js",
+ "http://a/c.js",
+ "http://b/c.js",
+ ]);
+
+ const threads = [
+ {
+ actor: "FakeThread",
+ url: "http://a",
+ targetType: "worker",
+ name: "FakeThread",
+ isTopLevel: false,
+ },
+ ];
+
+ const debuggeeUrl = "http://a/";
+ const { sourceTree } = createTree({
+ sources,
+ debuggeeUrl,
+ threads,
+ });
+
+ expect(formatDirectories(sources.FakeThread.a0, sourceTree)).toEqual([
+ "FakeThread/a/b.js",
+ "FakeThread/a",
+ "FakeThread",
+ ]);
+ expect(formatDirectories(sources.FakeThread.a1, sourceTree)).toEqual([
+ "FakeThread/a/c.js",
+ "FakeThread/a",
+ "FakeThread",
+ ]);
+ expect(formatDirectories(sources.FakeThread.a2, sourceTree)).toEqual([
+ "FakeThread/b/c.js",
+ "FakeThread/b",
+ "FakeThread",
+ ]);
+ });
+});
+
+describe("findSourceTreeNodes", () => {
+ it("finds a node", () => {
+ const sources = createSources([
+ "http://src/main.js",
+ "http://src/utils/help.js",
+ "http://src/utils/print.js",
+ "http://workers/worker.js",
+ ]);
+
+ const threads = [
+ {
+ actor: "FakeThread",
+ url: "http://a",
+ targetType: "worker",
+ name: "FakeThread",
+ isTopLevel: false,
+ },
+ ];
+
+ const debuggeeUrl = "http://a/";
+ const { sourceTree } = createTree({
+ sources,
+ debuggeeUrl,
+ threads,
+ });
+
+ const nodes = findSourceTreeNodes(sourceTree, "src") || [];
+ expect(nodes[0].path).toEqual("FakeThread/src");
+ });
+});
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..1b176908e9
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/tests/getUrl.spec.js
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { getURL } from "../getURL";
+import { makeMockSource } from "../../../utils/test-mockup";
+import type { Source } from "../../../types";
+
+function createMockSource(props): Source {
+ const rv = {
+ ...makeMockSource(),
+ ...Object.assign(
+ {
+ id: "server1.conn13.child1/39",
+ url: "",
+ sourceMapURL: "",
+ isBlackBoxed: false,
+ isPrettyPrinted: false,
+ isWasm: false,
+ },
+ props
+ ),
+ };
+ return (rv: any);
+}
+
+describe("getUrl", () => {
+ it("handles normal url with http and https for filename", function() {
+ const urlObject = getURL(createMockSource({ url: "https://a/b.js" }));
+ expect(urlObject.filename).toBe("b.js");
+
+ const urlObject2 = getURL(
+ createMockSource({ id: "server1.conn13.child1/40", url: "http://a/b.js" })
+ );
+ expect(urlObject2.filename).toBe("b.js");
+ });
+
+ it("handles url with querystring for filename", function() {
+ const urlObject = getURL(
+ createMockSource({
+ url: "https://a/b.js?key=randomKey",
+ })
+ );
+ expect(urlObject.filename).toBe("b.js");
+ });
+
+ it("handles url with '#' for filename", function() {
+ const urlObject = getURL(
+ createMockSource({
+ url: "https://a/b.js#specialSection",
+ })
+ );
+ expect(urlObject.filename).toBe("b.js");
+ });
+
+ it("handles url with no file extension for filename", function() {
+ const urlObject = getURL(
+ createMockSource({
+ url: "https://a/c",
+ id: "c",
+ })
+ );
+ expect(urlObject.filename).toBe("c");
+ });
+
+ it("handles url with no name for filename", function() {
+ const urlObject = getURL(
+ createMockSource({
+ url: "https://a/",
+ id: "c",
+ })
+ );
+ expect(urlObject.filename).toBe("(index)");
+ });
+
+ it("separates resources by protocol and host", () => {
+ const urlObject = getURL(
+ createMockSource({
+ url: "moz-extension://xyz/123",
+ id: "c2",
+ })
+ );
+ expect(urlObject.group).toBe("moz-extension://xyz");
+ });
+
+ it("creates a group name for webpack", () => {
+ const urlObject = getURL(
+ createMockSource({
+ url: "webpack:///src/component.jsx",
+ id: "c3",
+ })
+ );
+ expect(urlObject.group).toBe("webpack://");
+ });
+
+ it("creates a group name for angular source", () => {
+ const urlObject = getURL(
+ createMockSource({
+ url: "ng://src/component.jsx",
+ id: "c3",
+ })
+ );
+ expect(urlObject.group).toBe("ng://");
+ });
+});
diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/treeOrder.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/treeOrder.spec.js
new file mode 100644
index 0000000000..6835be7bc9
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/tests/treeOrder.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/>. */
+
+// @flow
+
+import { getDomain } from "../treeOrder";
+
+describe("getDomain", () => {
+ it("parses a url and returns the host name", () => {
+ expect(getDomain("http://www.mozilla.com")).toBe("mozilla.com");
+ });
+
+ it("returns null for an undefined string", () => {
+ expect(getDomain(undefined)).toBe(null);
+ });
+
+ it("returns null for an empty string", () => {
+ expect(getDomain("")).toBe(null);
+ });
+
+ it("returns null for a poorly formed string", () => {
+ expect(getDomain("\\/~`?,.{}[]!@$%^&*")).toBe(null);
+ });
+});
diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/updateTree.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/updateTree.spec.js
new file mode 100644
index 0000000000..f837c7c1be
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/tests/updateTree.spec.js
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { makeMockDisplaySource } from "../../../utils/test-mockup";
+import { updateTree, createTree } from "../index";
+
+type RawSource = {| url: string, id: string, actors?: any |};
+
+function createSourcesMap(sources: RawSource[]) {
+ const sourcesMap = sources.reduce((map, source) => {
+ map[source.id] = makeMockDisplaySource(source.url, source.id);
+ return map;
+ }, {});
+
+ return { FakeThread: sourcesMap };
+}
+
+function formatTree(tree) {
+ if (!tree) {
+ throw new Error("Tree must exist");
+ }
+ return JSON.stringify(tree.uncollapsedTree, null, 2);
+}
+
+const sources = [
+ {
+ id: "server1.conn13.child1/39",
+ url: "https://davidwalsh.name/",
+ },
+ {
+ id: "server1.conn13.child1/37",
+ url: "https://davidwalsh.name/source1.js",
+ },
+ {
+ id: "server1.conn13.child1/40",
+ url: "https://davidwalsh.name/source2.js",
+ },
+];
+
+const threads = [
+ {
+ actor: "FakeThread",
+ url: "https://davidwalsh.name",
+ targetType: "worker",
+ name: "FakeThread",
+ isTopLevel: false,
+ },
+];
+
+const debuggeeUrl = "blah";
+
+describe("calls updateTree.js", () => {
+ it("adds one source", () => {
+ const prevSources = createSourcesMap([sources[0]]);
+ const { sourceTree, uncollapsedTree } = createTree({
+ debuggeeUrl,
+ sources: prevSources,
+ threads,
+ });
+
+ const newTree = updateTree({
+ debuggeeUrl,
+ prevSources,
+ newSources: createSourcesMap([sources[0], sources[1]]),
+ uncollapsedTree,
+ sourceTree,
+ threads,
+ });
+
+ expect(formatTree(newTree)).toMatchSnapshot();
+ });
+
+ it("adds two sources", () => {
+ const prevSources = createSourcesMap([sources[0]]);
+
+ const { sourceTree, uncollapsedTree } = createTree({
+ debuggeeUrl,
+ sources: prevSources,
+ threads,
+ });
+
+ const newTree = updateTree({
+ debuggeeUrl,
+ prevSources,
+ newSources: createSourcesMap([sources[0], sources[1], sources[2]]),
+ uncollapsedTree,
+ sourceTree,
+ projectRoot: "",
+ threads,
+ });
+
+ expect(formatTree(newTree)).toMatchSnapshot();
+ });
+
+ it("update sources that change their display URL", () => {
+ const prevSources = createSourcesMap([sources[0]]);
+
+ const { sourceTree, uncollapsedTree } = createTree({
+ debuggeeUrl,
+ sources: prevSources,
+ threads,
+ });
+
+ const newTree = updateTree({
+ debuggeeUrl,
+ prevSources,
+ newSources: createSourcesMap([
+ {
+ ...sources[0],
+ url: `${sources[0].url}?param`,
+ },
+ ]),
+ uncollapsedTree,
+ sourceTree,
+ projectRoot: "",
+ threads,
+ });
+
+ expect(formatTree(newTree)).toMatchSnapshot();
+ });
+
+ // NOTE: we currently only add sources to the tree and clear the tree
+ // on navigate.
+ it("shows all the sources", () => {
+ const prevSources = createSourcesMap([sources[0]]);
+
+ const { sourceTree, uncollapsedTree } = createTree({
+ debuggeeUrl,
+ sources: prevSources,
+ threads,
+ });
+
+ const newTree = updateTree({
+ debuggeeUrl,
+ prevSources,
+ newSources: createSourcesMap([sources[0], sources[1]]),
+ uncollapsedTree,
+ sourceTree,
+ projectRoot: "",
+ threads,
+ });
+
+ expect(formatTree(newTree)).toMatchSnapshot();
+ });
+});
diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/utils.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/utils.spec.js
new file mode 100644
index 0000000000..0740800c4d
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/tests/utils.spec.js
@@ -0,0 +1,223 @@
+/* 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/>. */
+
+// @flow
+
+import { makeMockDisplaySource } from "../../test-mockup";
+
+import {
+ createDirectoryNode,
+ getRelativePath,
+ isExactUrlMatch,
+ isDirectory,
+ addToTree,
+ isNotJavaScript,
+ getPathWithoutThread,
+ createTree,
+ getSourcesInsideGroup,
+ getAllSources,
+} from "../index";
+
+type RawSource = {| url: string, id: string, actors?: any |};
+
+function createSourcesMap(sources: RawSource[]) {
+ const sourcesMap = sources.reduce((map, source) => {
+ map[source.id] = makeMockDisplaySource(source.url, source.id);
+ return map;
+ }, {});
+
+ return sourcesMap;
+}
+
+describe("sources tree", () => {
+ describe("isExactUrlMatch", () => {
+ it("recognizes root url match", () => {
+ const rootA = "http://example.com/path/to/file.html";
+ const rootB = "https://www.demo.com/index.html";
+
+ expect(isExactUrlMatch("example.com", rootA)).toBe(true);
+ expect(isExactUrlMatch("www.example.com", rootA)).toBe(true);
+ expect(isExactUrlMatch("api.example.com", rootA)).toBe(false);
+ expect(isExactUrlMatch("example.example.com", rootA)).toBe(false);
+ expect(isExactUrlMatch("www.example.example.com", rootA)).toBe(false);
+ expect(isExactUrlMatch("demo.com", rootA)).toBe(false);
+
+ expect(isExactUrlMatch("demo.com", rootB)).toBe(true);
+ expect(isExactUrlMatch("www.demo.com", rootB)).toBe(true);
+ expect(isExactUrlMatch("maps.demo.com", rootB)).toBe(false);
+ expect(isExactUrlMatch("demo.demo.com", rootB)).toBe(false);
+ expect(isExactUrlMatch("www.demo.demo.com", rootB)).toBe(false);
+ expect(isExactUrlMatch("example.com", rootB)).toBe(false);
+ });
+ });
+
+ describe("isDirectory", () => {
+ it("identifies directories correctly", () => {
+ const sources = [
+ makeMockDisplaySource("http://example.com/a.js", "actor1"),
+ makeMockDisplaySource("http://example.com/b/c/d.js", "actor2"),
+ ];
+
+ const tree = createDirectoryNode("root", "", []);
+ sources.forEach(source =>
+ addToTree(tree, source, "http://example.com/", "Main Thread")
+ );
+ const [bFolderNode, aFileNode] = tree.contents[0].contents[0].contents;
+ const [cFolderNode] = bFolderNode.contents;
+ const [dFileNode] = cFolderNode.contents;
+
+ expect(isDirectory(bFolderNode)).toBe(true);
+ expect(isDirectory(aFileNode)).toBe(false);
+ expect(isDirectory(cFolderNode)).toBe(true);
+ expect(isDirectory(dFileNode)).toBe(false);
+ });
+ });
+
+ describe("getRelativePath", () => {
+ it("gets the relative path of the file", () => {
+ const relPath = "path/to/file.html";
+ expect(getRelativePath("http://example.com/path/to/file.html")).toBe(
+ relPath
+ );
+ expect(getRelativePath("http://www.example.com/path/to/file.html")).toBe(
+ relPath
+ );
+ expect(getRelativePath("https://www.example.com/path/to/file.js")).toBe(
+ "path/to/file.js"
+ );
+ expect(getRelativePath("webpack:///path/to/file.html")).toBe(relPath);
+ expect(getRelativePath("file:///path/to/file.html")).toBe(relPath);
+ expect(getRelativePath("file:///path/to/file.html?bla")).toBe(relPath);
+ expect(getRelativePath("file:///path/to/file.html#bla")).toBe(relPath);
+ expect(getRelativePath("file:///path/to/file")).toBe("path/to/file");
+ });
+ });
+
+ describe("isNotJavaScript", () => {
+ it("js file", () => {
+ const source = makeMockDisplaySource("http://example.com/foo.js");
+ expect(isNotJavaScript(source)).toBe(false);
+ });
+
+ it("css file", () => {
+ const source = makeMockDisplaySource("http://example.com/foo.css");
+ expect(isNotJavaScript(source)).toBe(true);
+ });
+
+ it("svg file", () => {
+ const source = makeMockDisplaySource("http://example.com/foo.svg");
+ expect(isNotJavaScript(source)).toBe(true);
+ });
+
+ it("png file", () => {
+ const source = makeMockDisplaySource("http://example.com/foo.png");
+ expect(isNotJavaScript(source)).toBe(true);
+ });
+ });
+
+ describe("getPathWithoutThread", () => {
+ it("main thread pattern", () => {
+ const path = getPathWithoutThread("server1.conn0.child1/context18");
+ expect(path).toBe("");
+ });
+
+ it("main thread host", () => {
+ const path = getPathWithoutThread(
+ "server1.conn0.child1/context18/dbg-workers.glitch.me"
+ );
+ expect(path).toBe("dbg-workers.glitch.me");
+ });
+
+ it("main thread children", () => {
+ const path = getPathWithoutThread(
+ "server1.conn0.child1/context18/dbg-workers.glitch.me/more"
+ );
+ expect(path).toBe("dbg-workers.glitch.me/more");
+ });
+
+ it("worker thread", () => {
+ const path = getPathWithoutThread(
+ "server1.conn0.child1/workerTarget25/context1"
+ );
+ expect(path).toBe("");
+ });
+
+ it("worker thread with children", () => {
+ const path = getPathWithoutThread(
+ "server1.conn0.child1/workerTarget25/context1/dbg-workers.glitch.me/utils"
+ );
+ expect(path).toBe("dbg-workers.glitch.me/utils");
+ });
+
+ it("worker thread with file named like pattern", () => {
+ const path = getPathWithoutThread(
+ "server1.conn0.child1/workerTarget25/context1/dbg-workers.glitch.me/utils/context38/index.js"
+ );
+ expect(path).toBe("dbg-workers.glitch.me/utils/context38/index.js");
+ });
+ });
+
+ it("gets all sources in all threads and gets sources inside of the selected directory", () => {
+ const testData1 = [
+ {
+ id: "server1.conn13.child1/39",
+ url: "https://example.com/a.js",
+ },
+ {
+ id: "server1.conn13.child1/37",
+ url: "https://example.com/b.js",
+ },
+ {
+ id: "server1.conn13.child1/35",
+ url: "https://example.com/c.js",
+ },
+ ];
+ const testData2 = [
+ {
+ id: "server1.conn13.child1/33",
+ url: "https://example.com/d.js",
+ },
+ {
+ id: "server1.conn13.child1/31",
+ url: "https://example.com/e.js",
+ },
+ ];
+ const sources = {
+ FakeThread: createSourcesMap(testData1),
+ OtherThread: createSourcesMap(testData2),
+ };
+ const threads = [
+ {
+ actor: "FakeThread",
+ name: "FakeThread",
+ url: "https://example.com/",
+ targetType: "worker",
+ isTopLevel: false,
+ },
+ {
+ actor: "OtherThread",
+ name: "OtherThread",
+ url: "https://example.com/",
+ targetType: "worker",
+ isTopLevel: false,
+ },
+ ];
+
+ const tree = createTree({
+ sources,
+ debuggeeUrl: "https://example.com/",
+ threads,
+ }).sourceTree;
+
+ const dirA = tree.contents[0];
+ const dirB = tree.contents[1];
+
+ expect(getSourcesInsideGroup(dirA, { threads, sources })).toHaveLength(3);
+ expect(getSourcesInsideGroup(dirB, { threads, sources })).toHaveLength(2);
+ expect(getSourcesInsideGroup(tree, { threads, sources })).toHaveLength(5);
+
+ expect(getAllSources({ threads, sources })).toHaveLength(5);
+ });
+});
diff --git a/devtools/client/debugger/src/utils/sources-tree/treeOrder.js b/devtools/client/debugger/src/utils/sources-tree/treeOrder.js
new file mode 100644
index 0000000000..1cf2536f32
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/treeOrder.js
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { parse } from "../url";
+
+import { nodeHasChildren } from "./utils";
+
+import type { TreeNode } from "./types";
+
+import type { Source } from "../../types";
+
+/*
+ * Gets domain from url (without www prefix)
+ */
+export function getDomain(url?: string): ?string {
+ if (!url) {
+ return null;
+ }
+ const { host } = parse(url);
+ if (!host) {
+ return null;
+ }
+ return host.startsWith("www.") ? host.substr("www.".length) : host;
+}
+
+/*
+ * Checks if node name matches debugger host/domain.
+ */
+function isExactDomainMatch(part: string, debuggeeHost: string): boolean {
+ return part.startsWith("www.")
+ ? part.substr("www.".length) === debuggeeHost
+ : part === debuggeeHost;
+}
+
+/*
+ * Checks if node name matches IndexName
+ */
+function isIndexName(part: string, ...rest): boolean {
+ return part === IndexName;
+}
+
+/*
+ * Function to assist with node search for a defined sorted order, see e.g.
+ * `createTreeNodeMatcher`. Returns negative number if the node
+ * stands earlier in sorting order, positive number if the node stands later
+ * in sorting order, or zero if the node is found.
+ */
+export type FindNodeInContentsMatcher = (node: TreeNode) => number;
+
+/*
+ * Performs a binary search to insert a node into contents. Returns positive
+ * number, index of the found child, or negative number, which can be used
+ * to calculate a position where a new node can be inserted (`-index - 1`).
+ * The matcher is a function that returns result of comparision of a node with
+ * lookup value.
+ */
+export function findNodeInContents(
+ tree: TreeNode,
+ matcher: FindNodeInContentsMatcher
+): {| found: boolean, index: number |} {
+ if (tree.type === "source" || tree.contents.length === 0) {
+ return { found: false, index: 0 };
+ }
+
+ let left = 0;
+ let right = tree.contents.length - 1;
+ while (left < right) {
+ const middle = Math.floor((left + right) / 2);
+ if (matcher(tree.contents[middle]) < 0) {
+ left = middle + 1;
+ } else {
+ right = middle;
+ }
+ }
+ const result = matcher(tree.contents[left]);
+ if (result === 0) {
+ return { found: true, index: left };
+ }
+ return { found: false, index: result > 0 ? left : left + 1 };
+}
+
+const IndexName = "(index)";
+
+/*
+ * An array of functions to identify exceptions when sorting sourcesTree.
+ * Each function must return a boolean. Keep functions in array in the
+ * order exceptions should be sorted in.
+ */
+const matcherFunctions = [isIndexName, isExactDomainMatch];
+
+/*
+ * Creates a matcher for findNodeInContents.
+ * The sorting order of nodes during comparison is:
+ * - "(index)" node
+ * - root node with the debuggee host/domain
+ * - hosts/directories (not files) sorted by name
+ * - files sorted by name
+ */
+export function createTreeNodeMatcher(
+ part: string,
+ isDir: boolean,
+ debuggeeHost: ?string,
+ source?: Source,
+ sortByUrl?: boolean
+): FindNodeInContentsMatcher {
+ return (node: TreeNode) => {
+ for (let i = 0; i < matcherFunctions.length; i++) {
+ // Check part against exceptions
+ if (matcherFunctions[i](part, debuggeeHost)) {
+ for (let j = 0; j < i; j++) {
+ // Check node.name against exceptions
+ if (matcherFunctions[j](node.name, debuggeeHost)) {
+ return -1;
+ }
+ }
+ // If part and node.name share the same exception, return 0
+ if (matcherFunctions[i](node.name, debuggeeHost)) {
+ return 0;
+ }
+ return 1;
+ }
+ // Check node.name against exceptions if part is not exception
+ if (matcherFunctions[i](node.name, debuggeeHost)) {
+ return -1;
+ }
+ }
+ // Sort directories before files
+ const nodeIsDir = nodeHasChildren(node);
+ if (nodeIsDir && !isDir) {
+ return -1;
+ } else if (!nodeIsDir && isDir) {
+ return 1;
+ }
+
+ if (sortByUrl && node.type === "source" && source) {
+ return node.contents.url.localeCompare(source.url);
+ }
+
+ if (isExactDomainMatch(part, node.name)) {
+ return 0;
+ }
+
+ return node.name.localeCompare(part);
+ };
+}
diff --git a/devtools/client/debugger/src/utils/sources-tree/types.js b/devtools/client/debugger/src/utils/sources-tree/types.js
new file mode 100644
index 0000000000..c8dacd6849
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/types.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/>. */
+
+// @flow
+
+import type { Source } from "../../types";
+
+/**
+ * TODO: createNode is exported so this type could be useful to other modules
+ * @memberof utils/sources-tree
+ * @static
+ */
+export type TreeNode = TreeSource | TreeDirectory;
+
+export type TreeSource = {
+ type: "source",
+ name: string,
+ path: string,
+ contents: Source,
+};
+
+export type TreeDirectory = {
+ type: "directory",
+ name: string,
+ path: string,
+ contents: TreeNode[],
+};
+
+export type ParentMap = WeakMap<TreeNode, TreeDirectory>;
+
+export type SourcesGroups = {
+ sourcesInside: Source[],
+ sourcesOuside: Source[],
+};
diff --git a/devtools/client/debugger/src/utils/sources-tree/updateTree.js b/devtools/client/debugger/src/utils/sources-tree/updateTree.js
new file mode 100644
index 0000000000..af67f0f95d
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/updateTree.js
@@ -0,0 +1,262 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { addToTree } from "./addToTree";
+import { collapseTree } from "./collapseTree";
+import {
+ createDirectoryNode,
+ createParentMap,
+ getPathParts,
+ isInvalidUrl,
+} from "./utils";
+import {
+ getDomain,
+ createTreeNodeMatcher,
+ findNodeInContents,
+} from "./treeOrder";
+
+import { getDisplayURL } from "./getURL";
+
+import type { SourcesMapByThread } from "../../reducers/types";
+import type { Thread, DisplaySource, URL } from "../../types";
+import type { TreeDirectory, TreeSource, TreeNode } from "./types";
+
+function getSourcesDiff(
+ newSources,
+ prevSources
+): {
+ toAdd: Array<DisplaySource>,
+ toUpdate: Array<[DisplaySource, DisplaySource]>,
+} {
+ const toAdd = [];
+ const toUpdate = [];
+
+ for (const sourceId in newSources) {
+ const newSource = newSources[sourceId];
+ const prevSource = prevSources ? prevSources[sourceId] : null;
+ if (!prevSource) {
+ toAdd.push(newSource);
+ } else if (prevSource.displayURL !== newSource.displayURL) {
+ toUpdate.push([prevSource, newSource]);
+ }
+ }
+
+ return { toAdd, toUpdate };
+}
+
+type UpdateTreeParams = {
+ newSources: SourcesMapByThread,
+ prevSources: SourcesMapByThread,
+ uncollapsedTree: TreeDirectory,
+ debuggeeUrl: URL,
+ threads: Thread[],
+ sourceTree?: TreeNode,
+};
+
+type CreateTreeParams = {
+ sources: SourcesMapByThread,
+ debuggeeUrl: URL,
+ threads: Thread[],
+};
+
+export function createTree({
+ debuggeeUrl,
+ sources,
+ threads,
+}: CreateTreeParams) {
+ const uncollapsedTree = createDirectoryNode("root", "", []);
+ const result = updateTree({
+ debuggeeUrl,
+ newSources: sources,
+ prevSources: {},
+ threads,
+ uncollapsedTree,
+ });
+
+ if (!result) {
+ throw new Error("Tree must exist");
+ }
+
+ return result;
+}
+
+export function updateTree({
+ newSources,
+ prevSources,
+ debuggeeUrl,
+ uncollapsedTree,
+ threads,
+ create,
+ sourceTree,
+}: UpdateTreeParams) {
+ const debuggeeHost = getDomain(debuggeeUrl);
+ const contexts = (Object.keys(newSources): any);
+
+ let shouldUpdate = !sourceTree;
+ for (const context of contexts) {
+ const thread = threads.find(t => t.actor === context);
+ if (!thread) {
+ continue;
+ }
+
+ const { toAdd, toUpdate } = getSourcesDiff(
+ (Object.values(newSources[context]): any),
+ prevSources[context] ? (Object.values(prevSources[context]): any) : null
+ );
+
+ for (const source of toAdd) {
+ shouldUpdate = true;
+ addToTree(uncollapsedTree, source, debuggeeHost, thread.actor);
+ }
+
+ for (const [prevSource, newSource] of toUpdate) {
+ shouldUpdate = true;
+ updateInTree(
+ uncollapsedTree,
+ prevSource,
+ newSource,
+ debuggeeHost,
+ thread.actor
+ );
+ }
+ }
+
+ if (!shouldUpdate) {
+ return false;
+ }
+
+ const newSourceTree = collapseTree(uncollapsedTree);
+
+ return {
+ uncollapsedTree,
+ sourceTree: newSourceTree,
+ parentMap: createParentMap(newSourceTree),
+ };
+}
+
+export function updateInTree(
+ tree: TreeDirectory,
+ prevSource: DisplaySource,
+ newSource: DisplaySource,
+ debuggeeHost: ?string,
+ thread: string
+): void {
+ const newUrl = getDisplayURL(newSource, debuggeeHost);
+ const prevUrl = getDisplayURL(prevSource, debuggeeHost);
+
+ const prevEntries = findEntries(
+ tree,
+ prevUrl,
+ prevSource,
+ thread,
+ debuggeeHost
+ );
+ if (!prevEntries) {
+ return;
+ }
+
+ if (!isInvalidUrl(newUrl, newSource)) {
+ const parts = getPathParts(newUrl, thread, debuggeeHost);
+
+ if (parts.length === prevEntries.length) {
+ let match = true;
+ for (let i = 0; i < parts.length - 2; i++) {
+ if (parts[i].path !== prevEntries[i + 1].node.path) {
+ match = false;
+ break;
+ }
+ }
+
+ if (match) {
+ const { node, index } = prevEntries.pop();
+ // This is guaranteed to be a TreeSource or else findEntries would
+ // not have returned anything.
+ const fileNode: TreeSource = (node.contents[index]: any);
+ fileNode.name = parts[parts.length - 1].part;
+ fileNode.path = parts[parts.length - 1].path;
+ fileNode.contents = newSource;
+ return;
+ }
+ }
+ }
+
+ // Fall back to removing the current entry and inserting a new one if we
+ // are unable do a straight find-replace of the name and contents.
+ for (let i = prevEntries.length - 1; i >= 0; i--) {
+ const { node, index } = prevEntries[i];
+
+ // If the node has only a single child, we want to keep stepping upward
+ // to find the overall value to remove.
+ if (node.contents.length > 1 || (i === 0 && thread)) {
+ node.contents.splice(index, 1);
+ break;
+ }
+ }
+ addToTree(tree, newSource, debuggeeHost, thread);
+}
+
+type Entry = {
+ node: TreeDirectory,
+ index: number,
+};
+
+function findEntries(tree, url, source, thread, debuggeeHost): ?Array<Entry> {
+ const parts = getPathParts(url, thread, debuggeeHost);
+
+ // We're searching for the directory containing the file so we pop off the
+ // potential filename. This is because the tree has some logic to inject
+ // special entries when filename parts either conflict with directories, and
+ // so the last bit of removal needs to do a broad search to find the exact
+ // target location.
+ parts.pop();
+
+ const entries = [];
+ let currentNode = tree;
+ for (const { part } of parts) {
+ const { found: childFound, index: childIndex } = findNodeInContents(
+ currentNode,
+ createTreeNodeMatcher(part, true, debuggeeHost)
+ );
+
+ if (!childFound || currentNode.type !== "directory") {
+ return null;
+ }
+
+ entries.push({
+ node: currentNode,
+ index: childIndex,
+ });
+
+ currentNode = currentNode.contents[childIndex];
+ }
+
+ // From this point, we do a depth-first search for a node containing the
+ // specified source, as mentioned above.
+ const found = (function search(node) {
+ if (node.type !== "directory") {
+ if (node.contents.id === source.id) {
+ return [];
+ }
+ return null;
+ }
+
+ for (let i = 0; i < node.contents.length; i++) {
+ const child = node.contents[i];
+ const result = search(child);
+ if (result) {
+ result.unshift({
+ node,
+ index: i,
+ });
+ return result;
+ }
+ }
+
+ return null;
+ })(currentNode);
+
+ return found ? [...entries, ...found] : null;
+}
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..f893fa76a5
--- /dev/null
+++ b/devtools/client/debugger/src/utils/sources-tree/utils.js
@@ -0,0 +1,292 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+import { parse } from "../../utils/url";
+
+import type { TreeNode, TreeSource, TreeDirectory, ParentMap } from "./types";
+import type { Source, Thread, URL } from "../../types";
+import type { SourcesMapByThread } from "../../reducers/types";
+import { isPretty } from "../source";
+import { getURL, type ParsedURL } from "./getURL";
+const IGNORED_URLS = ["debugger eval code", "XStringBundle"];
+
+export type PathPart = {
+ part: string,
+ path: string,
+ debuggeeHostIfRoot: ?string,
+};
+export function getPathParts(
+ url: ParsedURL,
+ thread: string,
+ debuggeeHost: ?string
+): Array<PathPart> {
+ const parts = url.path.split("/");
+ if (parts.length > 1 && parts[parts.length - 1] === "") {
+ parts.pop();
+ if (url.search) {
+ parts.push(url.search);
+ }
+ } else {
+ parts[parts.length - 1] += url.search;
+ }
+
+ parts[0] = url.group;
+ if (thread) {
+ parts.unshift(thread);
+ }
+
+ let path = "";
+ return parts.map((part, index) => {
+ if (index == 0 && thread) {
+ path = thread;
+ } else {
+ path = `${path}/${part}`;
+ }
+
+ const debuggeeHostIfRoot = index === 1 ? debuggeeHost : null;
+
+ return {
+ part,
+ path,
+ debuggeeHostIfRoot,
+ };
+ });
+}
+
+export function nodeHasChildren(item: TreeNode): boolean {
+ return item.type == "directory" && Array.isArray(item.contents);
+}
+
+export function isExactUrlMatch(pathPart: string, debuggeeUrl: URL): boolean {
+ // compare to hostname with an optional 'www.' prefix
+ const { host } = parse(debuggeeUrl);
+ if (!host) {
+ return false;
+ }
+ return (
+ host === pathPart ||
+ host.replace(/^www\./, "") === pathPart.replace(/^www\./, "")
+ );
+}
+
+export function isPathDirectory(path: string): boolean {
+ // Assume that all urls point to files except when they end with '/'
+ // Or directory node has children
+
+ if (path.endsWith("/")) {
+ return true;
+ }
+
+ let separators = 0;
+ for (let i = 0; i < path.length - 1; ++i) {
+ if (path[i] === "/") {
+ if (path[i + i] !== "/") {
+ return false;
+ }
+
+ ++separators;
+ }
+ }
+
+ switch (separators) {
+ case 0: {
+ return false;
+ }
+ case 1: {
+ return !path.startsWith("/");
+ }
+ default: {
+ return true;
+ }
+ }
+}
+
+export function isDirectory(item: TreeNode): boolean {
+ return (
+ (item.type === "directory" || isPathDirectory(item.path)) &&
+ item.name != "(index)"
+ );
+}
+
+export function getSourceFromNode(item: TreeNode): ?Source {
+ const { contents } = item;
+ if (!isDirectory(item) && !Array.isArray(contents)) {
+ return contents;
+ }
+}
+
+export function isSource(item: TreeNode): boolean {
+ return item.type === "source";
+}
+
+export function getFileExtension(source: Source): string {
+ const { path } = getURL(source);
+ if (!path) {
+ return "";
+ }
+
+ const lastIndex = path.lastIndexOf(".");
+ return lastIndex !== -1 ? path.slice(lastIndex + 1) : "";
+}
+
+export function isNotJavaScript(source: Source): boolean {
+ return ["css", "svg", "png"].includes(getFileExtension(source));
+}
+
+export function isInvalidUrl(url: ParsedURL, source: Source): boolean {
+ return (
+ !source.url ||
+ !url.group ||
+ isNotJavaScript(source) ||
+ IGNORED_URLS.includes(url) ||
+ isPretty(source)
+ );
+}
+
+export function partIsFile(
+ index: number,
+ parts: Array<PathPart>,
+ url: Object
+): boolean {
+ const isLastPart = index === parts.length - 1;
+ return isLastPart && !isDirectory(url);
+}
+
+export function createDirectoryNode(
+ name: string,
+ path: string,
+ contents: TreeNode[]
+): TreeDirectory {
+ return {
+ type: "directory",
+ name,
+ path,
+ contents,
+ };
+}
+
+export function createSourceNode(
+ name: string,
+ path: string,
+ contents: Source
+): TreeSource {
+ return {
+ type: "source",
+ name,
+ path,
+ contents,
+ };
+}
+
+export function createParentMap(tree: TreeNode): ParentMap {
+ const map = new WeakMap();
+
+ function _traverse(subtree) {
+ if (subtree.type === "directory") {
+ for (const child of subtree.contents) {
+ map.set(child, subtree);
+ _traverse(child);
+ }
+ }
+ }
+
+ if (tree.type === "directory") {
+ // Don't link each top-level path to the "root" node because the
+ // user never sees the root
+ tree.contents.forEach(_traverse);
+ }
+
+ return map;
+}
+
+export function getRelativePath(url: URL): string {
+ const { pathname } = parse(url);
+ if (!pathname) {
+ return url;
+ }
+ const index = pathname.indexOf("/");
+
+ return index !== -1 ? pathname.slice(index + 1) : "";
+}
+
+export function getPathWithoutThread(path: string): string {
+ const pathParts = path.split(/(context\d+?\/)/).splice(2);
+ if (pathParts && pathParts.length > 0) {
+ return pathParts.join("");
+ }
+ return "";
+}
+
+export function findSource(
+ { threads, sources }: { threads: Thread[], sources: SourcesMapByThread },
+ itemPath: string,
+ source: ?Source
+): ?Source {
+ const targetThread = threads.find(thread => itemPath.includes(thread.actor));
+ if (targetThread && source) {
+ const { actor } = targetThread;
+ if (sources[actor]) {
+ return sources[actor][source.id];
+ }
+ }
+ return source;
+}
+
+// NOTE: we get the source from sources because item.contents is cached
+export function getSource(
+ item: TreeNode,
+ { threads, sources }: { threads: Thread[], sources: SourcesMapByThread }
+): ?Source {
+ const source = getSourceFromNode(item);
+ return findSource({ threads, sources }, item.path, source);
+}
+
+export function getChildren(item: $Shape<TreeDirectory>) {
+ return nodeHasChildren(item) ? item.contents : [];
+}
+
+export function getAllSources({
+ threads,
+ sources,
+}: {
+ threads: Thread[],
+ sources: SourcesMapByThread,
+}): Source[] {
+ const sourcesAll = [];
+ threads.forEach(thread => {
+ const { actor } = thread;
+
+ for (const source in sources[actor]) {
+ sourcesAll.push(sources[actor][source]);
+ }
+ });
+ return sourcesAll;
+}
+
+export function getSourcesInsideGroup(
+ item: TreeNode,
+ { threads, sources }: { threads: Thread[], sources: SourcesMapByThread }
+): Source[] {
+ const sourcesInsideDirectory = [];
+
+ const findAllSourcesInsideDirectory = (directoryToSearch: TreeDirectory) => {
+ const childrenItems = getChildren(directoryToSearch);
+
+ childrenItems.forEach((itemChild: TreeNode) => {
+ if (itemChild.type === "directory") {
+ findAllSourcesInsideDirectory(itemChild);
+ } else {
+ const source = getSource(itemChild, { threads, sources });
+ if (source) {
+ sourcesInsideDirectory.push(source);
+ }
+ }
+ });
+ };
+ if (item.type === "directory") {
+ findAllSourcesInsideDirectory(item);
+ }
+ return sourcesInsideDirectory;
+}
diff --git a/devtools/client/debugger/src/utils/tabs.js b/devtools/client/debugger/src/utils/tabs.js
new file mode 100644
index 0000000000..12349c2faa
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tabs.js
@@ -0,0 +1,116 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { PersistedTab, VisibleTab } from "../reducers/tabs";
+import type { TabList, Tab, TabsSources } from "../reducers/types";
+import type { URL } from "../types";
+
+/*
+ * 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: TabsSources,
+ sourceTabEls: Array<any>
+): TabsSources {
+ sourceTabEls = [].slice.call(sourceTabEls);
+ function getTopOffset(): number {
+ const topOffsets = sourceTabEls.map(t => t.getBoundingClientRect().top);
+ return Math.min(...topOffsets);
+ }
+
+ function hasTopOffset(el): boolean {
+ // 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: number) => {
+ const element = sourceTabEls[index];
+ return element && hasTopOffset(element);
+ });
+}
+
+export function getFramework(tabs: TabList, url: URL): string {
+ const tab = tabs.find(t => t.url === url);
+ return tab?.framework ?? "";
+}
+
+export function getTabMenuItems(): Object {
+ 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,
+ },
+ };
+}
+
+export function isSimilarTab(tab: Tab, url: URL, isOriginal: boolean): boolean {
+ return tab.url === url && tab.isOriginal === isOriginal;
+}
+
+export function persistTabs(tabs: VisibleTab[]): PersistedTab[] {
+ return [...tabs]
+ .filter(tab => tab.url)
+ .map(tab => ({ ...tab, sourceId: 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..012baaba37
--- /dev/null
+++ b/devtools/client/debugger/src/utils/task.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+/**
+ * This object provides the public module functions.
+ */
+export const Task = {
+ // XXX: Not sure if this works in all cases...
+ async: function(task: any) {
+ 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: any, scope: any, args: any): Promise<any> {
+ 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..b48b9d30c9
--- /dev/null
+++ b/devtools/client/debugger/src/utils/telemetry.js
@@ -0,0 +1,83 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * 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
+ * );
+ */
+
+// @flow
+
+// $FlowIgnore
+const Telemetry = require("devtools/client/shared/telemetry");
+
+import { isNode } from "./environment";
+
+const telemetry = new Telemetry();
+
+/**
+ * @memberof utils/telemetry
+ * @static
+ */
+export function recordEvent(eventName: string, fields: {} = {}): void {
+ let sessionId = -1;
+
+ if (typeof window !== "object") {
+ return;
+ }
+
+ if (window.parent.frameElement) {
+ sessionId = window.parent.frameElement.getAttribute("session_id");
+ }
+
+ /* eslint-disable camelcase */
+ telemetry.recordEvent(eventName, "debugger", null, {
+ session_id: sessionId,
+ ...fields,
+ });
+ /* eslint-enable camelcase */
+
+ 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..270b86f5ac
--- /dev/null
+++ b/devtools/client/debugger/src/utils/test-head.js
@@ -0,0 +1,301 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+/**
+ * Utils for Jest
+ * @module utils/test-head
+ */
+
+import { combineReducers, type Store } from "redux";
+import sourceMaps from "devtools-source-map";
+import reducers from "../reducers";
+import actions from "../actions";
+import * as selectors from "../selectors";
+import { parserWorker, evaluationsParser } from "../test/tests-setup";
+import configureStore from "../actions/utils/create-store";
+import sourceQueue from "../utils/source-queue";
+import type {
+ ThreadContext,
+ Source,
+ OriginalSourceData,
+ GeneratedSourceData,
+} from "../types";
+import type { State } from "../reducers/types";
+import type { Action } from "../actions/types";
+
+type TestStore = Store<State, Action, any> & {
+ thunkArgs: () => {
+ dispatch: any,
+ getState: () => State,
+ client: any,
+ sourceMaps: any,
+ panel: {||},
+ },
+ cx: ThreadContext,
+};
+
+/**
+ * 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: any,
+ initialState: any = {},
+ sourceMapsMock: any
+): TestStore {
+ const store: any = configureStore({
+ log: false,
+ makeThunkArgs: args => {
+ return {
+ ...args,
+ client,
+ sourceMaps: sourceMapsMock !== undefined ? sourceMapsMock : sourceMaps,
+ parser: parserWorker,
+ evaluationsParser,
+ };
+ },
+ })(combineReducers(reducers), initialState);
+ sourceQueue.clear();
+ sourceQueue.initialize({
+ newQueuedSources: sources =>
+ store.dispatch(actions.newQueuedSources(sources)),
+ });
+
+ store.thunkArgs = () => ({
+ dispatch: store.dispatch,
+ getState: store.getState,
+ client,
+ sourceMaps,
+ panel: {},
+ });
+
+ // Put the initial context in the store, for convenience to unit tests.
+ store.cx = selectors.getThreadContext(store.getState());
+
+ return store;
+}
+
+/**
+ * @memberof utils/test-head
+ * @static
+ */
+function commonLog(msg: string, data: any = {}) {
+ console.log(`[INFO] ${msg} ${JSON.stringify(data)}`);
+}
+
+function makeFrame({ id, sourceId, thread }: Object, opts: Object = {}) {
+ return {
+ id,
+ scope: { bindings: { variables: {}, arguments: [] } },
+ location: { sourceId, line: 4 },
+ thread: thread || "FakeThread",
+ ...opts,
+ };
+}
+
+function createSourceObject(
+ filename: string,
+ props: {
+ isBlackBoxed?: boolean,
+ } = {}
+): Source {
+ return ({
+ id: filename,
+ url: makeSourceURL(filename),
+ isBlackBoxed: !!props.isBlackBoxed,
+ isPrettyPrinted: false,
+ isExtension: false,
+ isOriginal: filename.includes("originalSource"),
+ }: any);
+}
+
+function createOriginalSourceObject(generated: Source): Source {
+ const rv = {
+ ...generated,
+ id: `${generated.id}/originalSource`,
+ };
+
+ return (rv: any);
+}
+
+function makeSourceURL(filename: string) {
+ return `http://localhost:8000/examples/${filename}`;
+}
+
+type MakeSourceProps = {
+ sourceMapBaseURL?: string,
+ sourceMapURL?: string,
+ introductionType?: string,
+ isBlackBoxed?: boolean,
+};
+function createMakeSource(): (
+ // The name of the file that this actor is part of.
+ name: string,
+ props?: MakeSourceProps
+) => GeneratedSourceData {
+ const indicies = {};
+
+ return function(name, props = {}) {
+ const index = (indicies[name] | 0) + 1;
+ indicies[name] = index;
+
+ return {
+ id: name,
+ thread: "FakeThread",
+ source: {
+ actor: `${name}-${index}-actor`,
+ url: `http://localhost:8000/examples/${name}`,
+ sourceMapBaseURL: props.sourceMapBaseURL || null,
+ sourceMapURL: props.sourceMapURL || null,
+ introductionType: props.introductionType || null,
+ isBlackBoxed: !!props.isBlackBoxed,
+ extensionName: null,
+ },
+ isServiceWorker: false,
+ };
+ };
+}
+
+/**
+ * @memberof utils/test-head
+ * @static
+ */
+let creator;
+beforeEach(() => {
+ creator = createMakeSource();
+});
+afterEach(() => {
+ creator = null;
+});
+function makeSource(name: string, props?: MakeSourceProps) {
+ if (!creator) {
+ throw new Error("makeSource() cannot be called outside of a test");
+ }
+
+ return creator(name, props);
+}
+
+function makeOriginalSource(source: Source): OriginalSourceData {
+ return {
+ id: `${source.id}/originalSource`,
+ url: `${source.url}-original`,
+ };
+}
+
+function makeFuncLocation(startLine, endLine) {
+ if (!endLine) {
+ endLine = startLine + 1;
+ }
+ return {
+ start: {
+ line: startLine,
+ },
+ end: {
+ line: endLine,
+ },
+ };
+}
+
+function makeSymbolDeclaration(
+ name: string,
+ start: number,
+ end: ?number,
+ klass: ?string
+) {
+ return {
+ id: `${name}:${start}`,
+ name,
+ location: makeFuncLocation(start, end),
+ klass,
+ };
+}
+
+/**
+ * @memberof utils/test-head
+ * @static
+ */
+function waitForState(store: any, predicate: any): Promise<void> {
+ 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: any, predicate: any): () => boolean {
+ 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: string) {
+ return window.dbg._telemetry.events[eventName] || [];
+}
+
+function waitATick(callback: Function): Promise<*> {
+ 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..ff120bf157
--- /dev/null
+++ b/devtools/client/debugger/src/utils/test-mockup.js
@@ -0,0 +1,282 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+/**
+ * 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 type {
+ ActorId,
+ Breakpoint,
+ DisplaySource,
+ Expression,
+ Frame,
+ FrameId,
+ Scope,
+ Source,
+ SourceId,
+ SourceWithContentAndType,
+ SourceWithContent,
+ TextSourceContent,
+ URL,
+ WasmSourceContent,
+ Why,
+ Thread,
+} from "../types";
+import * as asyncValue from "./async-value";
+
+import { initialState } from "../reducers/index";
+
+import type { SourceBase } from "../reducers/sources";
+import type { State } from "../reducers/types";
+import type { FulfilledValue } from "./async-value";
+
+function makeMockSource(url: URL = "url", id: SourceId = "source"): SourceBase {
+ return {
+ id,
+ url,
+ isBlackBoxed: false,
+ isPrettyPrinted: false,
+ relativeUrl: url,
+ isWasm: false,
+ extensionName: null,
+ isExtension: false,
+ isOriginal: id.includes("originalSource"),
+ };
+}
+
+function makeMockDisplaySource(
+ url: URL = "url",
+ id: SourceId = "source"
+): DisplaySource {
+ return {
+ ...makeMockSource(url, id),
+ displayURL: url,
+ };
+}
+
+function makeMockSourceWithContent(
+ url?: string,
+ id?: SourceId,
+ contentType?: string = "text/javascript",
+ text?: string = ""
+): SourceWithContent {
+ const source = makeMockSource(url, id);
+
+ return {
+ ...source,
+ content: text
+ ? asyncValue.fulfilled({
+ type: "text",
+ value: text,
+ contentType,
+ })
+ : null,
+ };
+}
+
+function makeMockSourceAndContent(
+ url?: string,
+ id?: SourceId,
+ contentType?: string = "text/javascript",
+ text: string = ""
+): { ...SourceBase, content: TextSourceContent } {
+ const source = makeMockSource(url, id);
+
+ return {
+ ...source,
+ content: {
+ type: "text",
+ value: text,
+ contentType,
+ },
+ };
+}
+
+function makeFullfilledMockSourceContent(
+ text: string = "",
+ contentType?: string = "text/javascript"
+): FulfilledValue<TextSourceContent> {
+ return asyncValue.fulfilled({
+ type: "text",
+ value: text,
+ contentType,
+ });
+}
+
+function makeMockWasmSource(): SourceBase {
+ return {
+ id: "wasm-source-id",
+ url: "url",
+ isBlackBoxed: false,
+ isPrettyPrinted: false,
+ relativeUrl: "url",
+ isWasm: true,
+ extensionName: null,
+ isExtension: false,
+ isOriginal: false,
+ };
+}
+
+function makeMockWasmSourceWithContent(text: {|
+ binary: Object,
+|}): SourceWithContentAndType<WasmSourceContent> {
+ const source = makeMockWasmSource();
+
+ return {
+ ...source,
+ content: asyncValue.fulfilled({
+ type: "wasm",
+ value: text,
+ }),
+ };
+}
+
+function makeMockScope(
+ actor: ActorId = "scope-actor",
+ type: string = "block",
+ parent: ?Scope = null
+): Scope {
+ return {
+ actor,
+ parent,
+ bindings: {
+ arguments: [],
+ variables: {},
+ },
+ object: null,
+ function: null,
+ type,
+ scopeKind: "",
+ };
+}
+
+function mockScopeAddVariable(scope: Scope, name: string) {
+ if (!scope.bindings) {
+ throw new Error("no scope bindings");
+ }
+ scope.bindings.variables[name] = { value: null };
+}
+
+function makeMockBreakpoint(
+ source: Source = makeMockSource(),
+ line: number = 1,
+ column: ?number
+): Breakpoint {
+ const location = column
+ ? { sourceId: source.id, line, column }
+ : { sourceId: source.id, line };
+ return {
+ id: "breakpoint",
+ location,
+ astLocation: null,
+ generatedLocation: location,
+ disabled: false,
+ text: "text",
+ originalText: "text",
+ options: {},
+ };
+}
+
+function makeMockFrame(
+ id: FrameId = "frame",
+ source: Source = makeMockSource("url"),
+ scope: Scope = makeMockScope(),
+ line: number = 4,
+ displayName: string = `display-${id}`,
+ index: number = 0
+): Frame {
+ const location = { sourceId: source.id, line };
+ return {
+ id,
+ thread: "FakeThread",
+ displayName,
+ location,
+ generatedLocation: location,
+ source,
+ scope,
+ this: {},
+ index,
+ asyncCause: null,
+ state: "on-stack",
+ type: "call",
+ };
+}
+
+function makeMockFrameWithURL(url: URL): Frame {
+ return makeMockFrame(undefined, makeMockSource(url));
+}
+
+function makeWhyNormal(frameReturnValue: any = undefined): Why {
+ if (frameReturnValue) {
+ return { type: "why-normal", frameFinished: { return: frameReturnValue } };
+ }
+ return { type: "why-normal" };
+}
+
+function makeWhyThrow(frameThrowValue: any): Why {
+ return { type: "why-throw", frameFinished: { throw: frameThrowValue } };
+}
+
+function makeMockExpression(value: Object): Expression {
+ 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: $Shape<Thread>) {
+ return {
+ actor: "test",
+ url: "example.com",
+ type: "worker",
+ name: "test",
+ ...fields,
+ };
+}
+
+function makeMockState(state: $Shape<State>) {
+ return {
+ ...initialState(),
+ ...state,
+ };
+}
+
+export {
+ makeMockDisplaySource,
+ makeMockSource,
+ makeMockSourceWithContent,
+ makeMockSourceAndContent,
+ makeMockWasmSource,
+ makeMockWasmSourceWithContent,
+ makeMockScope,
+ mockScopeAddVariable,
+ makeMockBreakpoint,
+ makeMockFrame,
+ makeMockFrameWithURL,
+ makeWhyNormal,
+ makeWhyThrow,
+ makeMockExpression,
+ mockcx,
+ mockthreadcx,
+ makeMockState,
+ makeMockThread,
+ makeFullfilledMockSourceContent,
+};
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..796c752453
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/DevToolsUtils.spec.js
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { 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..68dd5fad1c
--- /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 exxpression should wrap an expression 1`] = `
+"try {
+ foo
+} catch (e) {
+ e
+}"
+`;
+
+exports[`expressions wrap exxpression should wrap expression with a comment 1`] = `
+"try {
+ foo // yo yo
+} catch (e) {
+ e
+}"
+`;
+
+exports[`expressions wrap exxpression 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/__snapshots__/project-search.spec.js.snap b/devtools/client/debugger/src/utils/tests/__snapshots__/project-search.spec.js.snap
new file mode 100644
index 0000000000..e022edd6ee
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/__snapshots__/project-search.spec.js.snap
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`project search - highlightMatches simple 1`] = `
+<span
+ className="line-value"
+>
+ <span
+ className="line-match"
+ >
+ This is a sample
+ </span>
+ <span
+ className="query-match"
+ >
+ sentence
+ </span>
+ <span
+ className="line-match"
+ >
+
+ </span>
+</span>
+`;
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..5a3ff546b4
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/assert.spec.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/>. */
+
+// @flow
+
+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));
+ });
+ });
+
+ describe("when not in Node test", () => {
+ it("does not throw an Error", () => {
+ process.env.NODE_ENV = "production";
+ expect(() => assert(false, testAssertMessage)).not.toThrow();
+ delete process.env.NODE_ENV;
+ });
+ });
+});
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..0f00ae4fda
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/ast.spec.js
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { 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..57fda592d3
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/build-query.spec.js
@@ -0,0 +1,259 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { escapeRegExp } from "lodash";
+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(escapeRegExp("(?!\\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(escapeRegExp("(?!\\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..3f804a817d
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/clipboard.spec.js
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { 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..c2760c2d15
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/expressions.spec.js
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { wrapExpression, getValue } from "../expressions";
+import { makeMockExpression } from "../test-mockup";
+
+function createError(type, preview) {
+ return makeMockExpression({
+ result: { getGrip: () => ({ class: type, isError: true, preview }) },
+ });
+}
+
+describe("expressions", () => {
+ describe("wrap exxpression", () => {
+ 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)", () => {
+ expect(
+ getValue(createError("ReferenceError", { name: "ReferenceError" }))
+ ).toEqual({
+ unavailable: true,
+ });
+ });
+
+ it("Errors messages should be shown", () => {
+ expect(
+ getValue(createError("Error", { name: "Foo", message: "YO" }))
+ ).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..abb65b47c3
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/function.spec.js
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { 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, symbols);
+ expect(text).toMatchSnapshot();
+ });
+
+ it("finds function signature", () => {
+ const source = populateOriginalSource("func");
+ const symbols = getSymbols(source.id);
+
+ const text = findFunctionText(13, source, symbols);
+ expect(text).toMatchSnapshot();
+ });
+
+ it("misses function closing brace", () => {
+ const source = populateOriginalSource("func");
+ const symbols = getSymbols(source.id);
+
+ const text = findFunctionText(15, source, 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, symbols);
+ expect(text).toMatchSnapshot();
+ });
+
+ it("finds class function", () => {
+ const source = populateOriginalSource("func");
+ const symbols = getSymbols(source.id);
+
+ const text = findFunctionText(33, source, symbols);
+ expect(text).toMatchSnapshot();
+ });
+
+ it("cant find function", () => {
+ const source = populateOriginalSource("func");
+ const symbols = getSymbols(source.id);
+
+ const text = findFunctionText(20, source, 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..3681f59707
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/indentation.spec.js
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { 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..b4f73669ab
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/isMinified.spec.js
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { 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)).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..645a1054bd
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/log.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/>. */
+
+// @flow
+
+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..309e12c88d
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/memoize.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/>. */
+
+// @flow
+
+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..53426c8aa4
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/memoizeLast.spec.js
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { 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..c691bb1ebf
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/path.spec.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/>. */
+
+// @flow
+
+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/project-search.spec.js b/devtools/client/debugger/src/utils/tests/project-search.spec.js
new file mode 100644
index 0000000000..62c1c58aeb
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/project-search.spec.js
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { highlightMatches } from "../project-search";
+
+describe("project search - highlightMatches", () => {
+ it("simple", () => {
+ const lineMatch = {
+ type: "MATCH",
+ value: "This is a sample sentence",
+ line: 1,
+ column: 17,
+ matchIndex: 17,
+ match: "sentence",
+ sourceId: "source",
+ text: "text",
+ };
+
+ expect(highlightMatches(lineMatch)).toMatchSnapshot();
+ });
+});
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..517a7f53ab
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/quick-open.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/>. */
+
+// @flow
+
+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: undefined },
+ { 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..d72b4f42a2
--- /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/>. */
+
+// @flow
+
+import { scrollList } from "../result-list.js";
+
+describe("scrollList", () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ it("just returns if element not found", () => {
+ const li = document.createElement("li");
+ scrollList([li], 1);
+ });
+
+ it("calls scrollIntoView ", () => {
+ const ul = document.createElement("ul");
+ const li = document.createElement("li");
+
+ (li: any).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..86d21c5997
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/source.spec.js
@@ -0,0 +1,602 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import {
+ getFilename,
+ getTruncatedFileName,
+ getFileURL,
+ getDisplayPath,
+ getMode,
+ getSourceLineCount,
+ isThirdParty,
+ isJavaScript,
+ underRoot,
+ isUrlExtension,
+ isExtensionDirectoryPath,
+ getLineText,
+} from "../source.js";
+
+import {
+ makeMockSource,
+ makeMockSourceWithContent,
+ makeMockSourceAndContent,
+ makeMockWasmSourceWithContent,
+ makeMockThread,
+ makeFullfilledMockSourceContent,
+} from "../test-mockup";
+import { isFulfilled } from "../async-value.js";
+
+import type { Source } from "../../types";
+
+const defaultSymbolDeclarations = {
+ classes: [],
+ functions: [],
+ memberExpressions: [],
+ callExpressions: [],
+ objectProperties: [],
+ identifiers: [],
+ imports: [],
+ comments: [],
+ literals: [],
+ hasJsx: false,
+ hasTypes: false,
+ loading: false,
+ framework: undefined,
+};
+
+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: Source[] = [
+ 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: Source[] = [
+ 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: Source[] = [
+ 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: Source[] = [
+ 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: Source[] = [
+ 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("isThirdParty", () => {
+ it("node_modules", () => {
+ expect(isThirdParty(makeMockSource("/node_modules/foo.js"))).toBe(true);
+ });
+
+ it("bower_components", () => {
+ expect(isThirdParty(makeMockSource("/bower_components/foo.js"))).toBe(
+ true
+ );
+ });
+
+ it("not third party", () => {
+ expect(isThirdParty(makeMockSource("/bar/foo.js"))).toBe(false);
+ });
+ });
+
+ describe("getMode", () => {
+ it("//@flow", () => {
+ const source = makeMockSourceAndContent(
+ undefined,
+ undefined,
+ "text/javascript",
+ "// @flow"
+ );
+ expect(getMode(source, source.content)).toEqual({
+ name: "javascript",
+ typescript: true,
+ });
+ });
+
+ it("/* @flow */", () => {
+ const source = makeMockSourceAndContent(
+ undefined,
+ undefined,
+ "text/javascript",
+ " /* @flow */"
+ );
+ expect(getMode(source, source.content)).toEqual({
+ name: "javascript",
+ typescript: true,
+ });
+ });
+
+ it("mixed html", () => {
+ const source = makeMockSourceAndContent(
+ undefined,
+ undefined,
+ "",
+ " <html"
+ );
+ expect(getMode(source, source.content)).toEqual({ name: "htmlmixed" });
+ });
+
+ it("elm", () => {
+ const source = makeMockSourceAndContent(
+ 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 = makeMockSourceAndContent(
+ 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 = makeMockSourceAndContent(
+ 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 = makeMockSourceAndContent(
+ "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 = makeMockSourceAndContent(
+ "myComponent.hx",
+ undefined,
+ "",
+ "function foo(){}"
+ );
+ expect(getMode(source, source.content)).toEqual({ name: "text/x-haxe" });
+ });
+
+ it("typescript", () => {
+ const source = makeMockSourceAndContent(
+ undefined,
+ undefined,
+ "text/typescript",
+ "function foo(){}"
+ );
+ expect(getMode(source, source.content)).toEqual({
+ name: "javascript",
+ typescript: true,
+ });
+ });
+
+ it("typescript-jsx", () => {
+ const source = makeMockSourceAndContent(
+ 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 = makeMockSourceAndContent(
+ "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 = makeMockSourceAndContent(
+ "my-clojurescript-source.cljs",
+ undefined,
+ "text/x-clojurescript",
+ "(+ 1 2 3)"
+ );
+ expect(getMode(source, source.content)).toEqual({ name: "clojure" });
+ });
+
+ it("coffeescript", () => {
+ const source = makeMockSourceAndContent(
+ 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 = makeMockSourceAndContent(
+ "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 = makeMockSourceAndContent(
+ "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 = makeMockSourceAndContent(
+ "http://localhost.com:7999/increment/sometestfile.vue?query=string",
+ undefined,
+ "does not matter",
+ "function foo(){}"
+ );
+ expect(getMode(source, source.content)).toEqual({ name: "javascript" });
+ });
+ });
+
+ 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("underRoot", () => {
+ const threads = [
+ makeMockThread({ actor: "server0.conn1.child1/thread19" }),
+ ];
+
+ it("should detect normal source urls", () => {
+ const source = makeMockSource(
+ "resource://activity-stream/vendor/react.js"
+ );
+ expect(underRoot(source, "resource://activity-stream", threads)).toBe(
+ true
+ );
+ });
+
+ it("should detect source urls under chrome:// as root", () => {
+ const source = makeMockSource(
+ "chrome://browser/content/contentSearchUI.js"
+ );
+ expect(underRoot(source, "chrome://", threads)).toBe(true);
+ });
+
+ it("should detect source urls if root is a thread actor Id", () => {
+ const source = makeMockSource(
+ "resource://activity-stream/vendor/react-dom.js"
+ );
+ expect(underRoot(source, "server0.conn1.child1/thread19", threads)).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("isExtensionDirectoryPath", () => {
+ it("should detect mozilla extension directory", () => {
+ expect(isExtensionDirectoryPath("moz-extension://id")).toBe(true);
+ });
+ it("should detect chrome extension directory", () => {
+ expect(isExtensionDirectoryPath("chrome-extension://id")).toBe(true);
+ });
+ it("should return false for child file within the extension directory", () => {
+ expect(isExtensionDirectoryPath("moz-extension://id/js/content.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..d23d4facce
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/telemetry.spec.js
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+jest.mock("devtools/client/shared/telemetry", () => {
+ function MockTelemetry() {}
+ MockTelemetry.prototype.recordEvent = jest.fn();
+
+ return MockTelemetry;
+});
+
+// $FlowIgnore
+const Telemetry = require("devtools/client/shared/telemetry");
+
+import { recordEvent } from "../telemetry";
+
+const telemetry = new Telemetry();
+
+describe("telemetry.recordEvent()", () => {
+ it("Receives the correct telemetry information", () => {
+ recordEvent("foo", { bar: 1 });
+
+ expect(telemetry.recordEvent).toHaveBeenCalledWith(
+ "foo",
+ "debugger",
+ null,
+ {
+ // eslint-disable-next-line camelcase
+ session_id: -1,
+ 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..52d1ca1b98
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/text.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/>. */
+
+// @flow
+
+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..1e3baaf4a4
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/ui.spec.js
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+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..ed3749a4b0
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/url.spec.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/>. */
+
+// @flow
+
+import { stripQuery, parse } from "../url";
+
+describe("url", () => {
+ describe("stripQuery", () => {
+ it("strips properly", () => {
+ expect(stripQuery("/file/path")).toBe("/file/path");
+ expect(stripQuery("/file/path?param")).toBe("/file/path");
+ expect(stripQuery("/file/path#hash")).toBe("/file/path#hash");
+ expect(stripQuery("/file/path?param#hash")).toBe("/file/path#hash");
+ });
+ });
+
+ 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..342adee549
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/utils.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/>. */
+
+// @flow
+
+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..f3509a1a62
--- /dev/null
+++ b/devtools/client/debugger/src/utils/tests/wasm.spec.js
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import {
+ 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",
+ };
+ 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();
+ });
+ });
+
+ 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..b29c97b4ae
--- /dev/null
+++ b/devtools/client/debugger/src/utils/text.js
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+/**
+ * Utils for keyboard command strings
+ * @module utils/text
+ */
+import Services from "devtools-services";
+const { appinfo } = Services;
+
+const isMacOS = 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: string): string {
+ 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: string,
+ maxLength: number
+): string {
+ 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/timings.js b/devtools/client/debugger/src/utils/timings.js
new file mode 100644
index 0000000000..c042bc0f22
--- /dev/null
+++ b/devtools/client/debugger/src/utils/timings.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import { zip } from "lodash";
+
+export function getAsyncTimes(name: string): number[] {
+ return zip(
+ window.performance.getEntriesByName(`${name}_start`),
+ window.performance.getEntriesByName(`${name}_end`)
+ ).map(([start, end]) => +(end.startTime - start.startTime).toPrecision(2));
+}
+
+function getTimes(name): number[] {
+ return window.performance
+ .getEntriesByName(name)
+ .map(time => +time.duration.toPrecision(2));
+}
+
+function getStats(
+ times: number[]
+): {| times: number[], avg: ?number, median: ?number |} {
+ if (times.length == 0) {
+ return { times: [], avg: null, median: null };
+ }
+ const avg = times.reduce((sum, time) => time + sum, 0) / times.length;
+ const sortedtimings = [...times].sort((a, b) => a - b);
+ const median = sortedtimings[times.length / 2];
+ return {
+ times,
+ avg: +avg.toPrecision(2),
+ median: +median.toPrecision(2),
+ };
+}
+
+export function steppingTimings(): any {
+ const commandTimings = getAsyncTimes("COMMAND");
+ const pausedTimings = getTimes("PAUSED");
+
+ return {
+ commands: getStats(commandTimings),
+ paused: getStats(pausedTimings),
+ };
+}
+
+// console.log("..", asyncTimes("COMMAND"));
diff --git a/devtools/client/debugger/src/utils/ui.js b/devtools/client/debugger/src/utils/ui.js
new file mode 100644
index 0000000000..ef258dc052
--- /dev/null
+++ b/devtools/client/debugger/src/utils/ui.js
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+// @flow
+
+/* 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(): boolean {
+ const el = document.querySelector("#mount");
+ return !!(el && el.getBoundingClientRect().width > 0);
+}
+
+/* Gets the line numbers width in the code editor
+ */
+export function getLineNumberWidth(editor: Object): number {
+ 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: Object): void {
+ 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: Object): void {
+ 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..b421a95e9c
--- /dev/null
+++ b/devtools/client/debugger/src/utils/url.js
@@ -0,0 +1,90 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+import { memoize } from "lodash";
+import { URL as URLParser } from "whatwg-url";
+import type { URL } from "../types";
+
+const defaultUrl = {
+ hash: "",
+ host: "",
+ hostname: "",
+ href: "",
+ origin: "null",
+ password: "",
+ path: "",
+ pathname: "",
+ port: "",
+ protocol: "",
+ search: "",
+ // This should be a "URLSearchParams" object
+ searchParams: {},
+ username: "",
+};
+
+export const stripQuery = memoize(function stripQueryAndHash(url: URL): URL {
+ let queryStart = url.indexOf("?");
+
+ let before = url;
+ let after = "";
+ if (queryStart >= 0) {
+ const hashStart = url.indexOf("#");
+ if (hashStart >= 0) {
+ if (hashStart < queryStart) {
+ queryStart = hashStart;
+ }
+
+ after = url.slice(hashStart);
+ }
+
+ before = url.slice(0, queryStart);
+ }
+
+ return before + after;
+});
+
+export const parse = memoize(function parse(url: URL): any {
+ let urlObj;
+ try {
+ urlObj = new URLParser(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;
+ }
+ }
+ (urlObj: any).path = urlObj.pathname + urlObj.search;
+
+ return urlObj;
+});
+
+export function sameOrigin(firstUrl: URL, secondUrl: URL): boolean {
+ 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..0327a4f495
--- /dev/null
+++ b/devtools/client/debugger/src/utils/utils.js
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { SourceContent } from "../types";
+
+// $FlowIgnore
+const DevToolsUtils = require("devtools/shared/DevToolsUtils");
+
+/**
+ * Utils for utils, by utils
+ * @module utils/utils
+ */
+
+/**
+ * @memberof utils/utils
+ * @static
+ */
+export function handleError(err: any): void {
+ console.log("ERROR: ", err);
+}
+
+/**
+ * @memberof utils/utils
+ * @static
+ */
+export function promisify(
+ context: any,
+ method: any,
+ ...args: any
+): Promise<mixed> {
+ 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: any, size: number): string {
+ if (str.length > size) {
+ return `…${str.slice(str.length - size)}`;
+ }
+ return str;
+}
+
+export function waitForMs(ms: number): Promise<void> {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+export function downloadFile(content: SourceContent, fileName: string): void {
+ if (content.type !== "text") {
+ return;
+ }
+
+ const data = new TextEncoder().encode(content.value);
+ 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..78e667cf85
--- /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/>. */
+
+/* @flow */
+
+import { BinaryReader } from "wasmparser/dist/cjs/WasmParser";
+import {
+ WasmDisassembler,
+ NameSectionReader,
+} from "wasmparser/dist/cjs/WasmDis";
+
+import type { SourceId, WasmSourceContent } from "../types";
+type WasmState = {
+ lines: Array<number>,
+ offsets: Array<number>,
+};
+
+var wasmStates: { [string]: WasmState } = (Object.create(null): any);
+
+function maybeWasmSectionNameResolver(data: Uint8Array) {
+ 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: SourceId, data: Uint8Array) {
+ const nameResolver = maybeWasmSectionNameResolver(data);
+ const parser = new BinaryReader();
+ parser.setData(data.buffer, 0, data.length);
+ const dis = new WasmDisassembler();
+ if (nameResolver) {
+ dis.nameResolver = nameResolver;
+ }
+ dis.addOffsets = true;
+ const done = dis.disassembleChunk(parser);
+ let result = dis.getResult();
+ if (result.lines.length === 0) {
+ result = { lines: ["No luck with wast conversion"], offsets: [0], done };
+ }
+
+ 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: SourceId) {
+ const codeOf0 = 48,
+ codeOfA = 65;
+ const buffer = [
+ codeOf0,
+ codeOf0,
+ codeOf0,
+ codeOf0,
+ codeOf0,
+ codeOf0,
+ codeOf0,
+ codeOf0,
+ ];
+ let last0 = 7;
+ return function(number: 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: SourceId) {
+ return sourceId in wasmStates;
+}
+
+/**
+ * @memberof utils/wasm
+ * @static
+ */
+export function lineToWasmOffset(sourceId: SourceId, number: number): ?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: SourceId, offset: number): ?number {
+ const wasmState = wasmStates[sourceId];
+ if (!wasmState) {
+ return undefined;
+ }
+ return wasmState.lines[offset];
+}
+
+/**
+ * @memberof utils/wasm
+ * @static
+ */
+export function clearWasmStates() {
+ wasmStates = (Object.create(null): any);
+}
+
+const wasmLines: WeakMap<WasmSourceContent, string[]> = new WeakMap();
+export function renderWasmText(
+ sourceId: SourceId,
+ content: WasmSourceContent
+): string[] {
+ 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..09b6c1e59b
--- /dev/null
+++ b/devtools/client/debugger/src/utils/worker.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/>. */
+
+// @flow
+
+export type Message = {
+ data: {
+ id: string,
+ method: string,
+ args: Array<any>,
+ },
+};
+
+let msgId = 1;
+/**
+ * @memberof utils/utils
+ * @static
+ */
+function workerTask(worker: any, method: string): Function {
+ return function(...args: any): Promise<any> {
+ 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: any): Function {
+ return function onTask(msg: Message) {
+ 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 };