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